自定义渲染管线 Custom Render Pipeline
Custom Render Pipelinehttps://catlikecoding.com/unity/tutorials/custom-srp/custom-render-pipeline/
翻译:
Create a render pipeline asset and instance
Render a camera's view 渲染一个相机视图
执行 Perform culling, filtering, and sorting
Separate opaque, transparent, and invalid passes 分离不透明、透明及无效渲染通道
Work with more than one camera 支持多相机协作渲染
本教程是关于创建 custom scriptable render pipeline 的系列教程的第一部分。主要内容是初步创建一个基础渲染管线框架,后续我们将对其进行扩展。
本系列为专家级教程,假设您已完成或已熟悉多个其他系列教程所介绍的概念。如果您已学习过
Unity Procedural Meshes Tutorials 及其先修内容,即可顺利掌握本教程。
本教程基于Unity 2019.2.6f1版本创建,并已升级至2022.3.5f1版本。由于最初使用Unity 2019开发,本系列内容可能与Unity 2022的界面存在细微差异,但所有功能仍可正常使用。文中已标注必要的版本升级调整事项。
全新的渲染管线
为了渲染任何内容,Unity都必须确定需要绘制哪些形状、在何时何处绘制、以及使用何种设置。根据涉及的效果数量不同,这个过程可能变得非常复杂。光照、阴影、透明度、图像效果、体积效果等都必须按正确顺序处理才能生成最终图像——这正是渲染管线所承担的工作。
过去Unity仅支持少数内置渲染方式。2018版引入了可编程渲染管线(简称RP),使我们能够自由定制渲染流程,同时仍可依赖Unity处理剔除等基础操作。Unity 2018还基于该技术新增了两款实验性RP:轻量级RP和高清RP。至Unity 2019版本,轻量级RP已结束实验阶段,并在2019.3版中正式更名为通用RP。
通用RP被设计为取代传统内置RP的新默认方案,其定位是满足大多数需求的通用型渲染管线,同时保持较高的可定制性。本系列教程将不基于现有RP进行修改,而是从头开始完整构建一个自定义RP。
本教程将搭建最基础的渲染管线框架,通过前向渲染绘制无光照物体。待基础功能实现后,我们将在后续课程中逐步扩展管线功能,增加光照、阴影、多种渲染方法及更高级的特性
1.1 项目设置
在Unity 2022.3.5f1(原教程使用2019.2.6)或更高版本中创建新的3D项目。由于我们将创建自定义渲染管线,因此不要选择任何RP项目模板。项目打开后,可通过包管理器移除所有不需要的包。本教程仅会使用Unity UI包进行界面绘制实验,因此可保留该包。
我们将全程在线性颜色空间下工作。请注意:Unity 2019.2默认仍使用伽马颜色空间,请通过编辑/项目设置→Player→Other Settings面板,将颜色空间切换为Linear
Color space set to linear
在默认场景中放置若干物体,混合使用标准着色器、无光照不透明材质以及透明材质。请注意,Unlit/Transparent着色器需要配合纹理使用,这里提供一张UV球体贴图供您使用。
UV sphere alpha map, on black background.
我在测试场景中放置了一些立方体,它们都是不透明的。红色立方体使用了标准着色器(Standard Shader)的材质,而绿色和黄色立方体则使用了无光照颜色着色器(Unlit/Color Shader)的材质。蓝色球体使用了渲染模式设置为透明(Transparent)的标准着色器,白色球体则使用了无光照透明着色器(Unlit/Transparent Shader)。Test scene
1.2 Pipeline Asset
目前,Unity正在使用默认的渲染管线。若要以自定义渲染管线进行替换,我们首先需要为其创建一种资产类型。我们将采用与Unity通用渲染管线(URP)大致相同的文件夹结构:创建一个名为"Custom RP"的资产文件夹,并在其中添加一个名为"Runtime"的子文件夹。在该子文件夹中新建一个C#脚本,用于定义CustomRenderPipelineAsset类型。
Folder structure
该 asset 类型必须继承自UnityEngine.Rendering命名空间中的RenderPipelineAsset类。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;public class CustomRenderPipelineAsset : RenderPipelineAsset {}
渲染管线资产的主要作用是为Unity提供一个获取负责渲染的管线对象实例的途径。资产本身只是一个句柄和存储设置的容器。目前我们尚未添加任何设置,因此只需为Unity提供获取管线对象实例的方法即可。这需要通过重写抽象的CreatePipeline方法来实现,该方法应返回一个RenderPipeline实例。但由于我们尚未定义自定义的渲染管线类型,可暂时返回null。
CreatePipeline方法被定义为protected访问修饰符,这意味着只有定义该方法的类(即RenderPipelineAsset)及其继承类才能访问它。
protected override RenderPipeline CreatePipeline () {return null;}
现在我们需要在项目中添加一个此类型的 asset。为此,请为 CustomRenderPipelineAsset 类添加一个 CreateAssetMenu 特性。
[CreateAssetMenu]
public class CustomRenderPipelineAsset : RenderPipelineAsset { … }
这样就会在"Asset / Create menu "菜单中添加一个选项。为了保持整洁,我们将其放入 Rendering submenu 中。只需将该属性的menuName设置为 Rendering/Custom Render Pipeline 即可。此属性可直接在特性类型后的圆括号内进行设置。
[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset { … }
使用新的菜单项将资产添加到项目中,然后进入 Graphics project settings,在"Scriptable Render Pipeline Settings"(可编程渲染管线设置)下选择该资产。
Custom RP selected
替换默认渲染管线带来了一些变化。首先,图形设置中的许多选项已消失(界面会有提示说明)。其次,由于我们禁用了默认渲染管线却未提供有效替代方案,现在所有内容都无法渲染了。游戏窗口、场景窗口和材质预览功能均已失效。如果您打开帧调试器(通过Window/Analysis/Frame Debugger)并启用调试,会发现游戏窗口确实没有任何绘制内容。
1.3 Render Pipeline Instance
创建一个CustomRenderPipeline类,并将其脚本文件放在与 CustomRenderPipelineAsset 相同的文件夹中。这将作为我们的资产返回的渲染管线实例类型,因此它必须继承自 RenderPipeline 类
using UnityEngine;
using UnityEngine.Rendering;public class CustomRenderPipeline : RenderPipeline {}
RenderPipeline 定义了一个受保护的抽象 Render 方法,我们必须重写此方法才能创建具体的渲染管线。该方法有两个参数:一个 ScriptableRenderContext 和一个 Camera 数组。暂时让该方法保持为空。
protected override void Render (ScriptableRenderContext context, Camera[] cameras) {}
该方法是专为自定义SRP定义的入口点,但由于相机数组参数需要每帧分配内存,因此引入了使用列表参数替代的版本。在Unity 2022中我们可以使用新版本,但仍需保留旧版本——尽管不会被使用,因为它被声明为抽象方法。请注意后续性能分析器截图仍会显示相机数组的旧内存分配情况
protected override void Render (ScriptableRenderContext context, List<Camera> cameras) { }
让 CustomRenderPipelineAsset.CreatePipeline
方法返回一个新的 CustomRenderPipeline
实例。这样我们将获得一个有效且可运行的渲染管线,尽管它暂时还不会渲染任何内容。
protected override RenderPipeline CreatePipeline () {return new CustomRenderPipeline();}
2.Rendering
每一帧Unity都会调用渲染管线实例上的Render方法。该方法会接收一个提供与原生引擎连接的场景上下文结构体,我们可以利用它进行渲染。同时还会传入一个相机数组,因为场景中可能存在多个激活的相机。按照传入顺序渲染所有这些相机正是渲染管线的职责所在
2.1 Camera Renderer
每个相机都是独立进行渲染的。因此,我们不会让 CustomRenderPipeline 直接渲染所有相机,而是将这个职责转交给一个专门用于渲染单个相机的新类。将其命名为 CameraRenderer,并为其添加一个带有 context 和 camera 参数的公共 Render 方法。为了方便起见,我们将这些参数存储在字段中
using UnityEngine;
using UnityEngine.Rendering;public class CameraRenderer {ScriptableRenderContext context;Camera camera;public void Render (ScriptableRenderContext context, Camera camera) {this.context = context;this.camera = camera;}
}
让 CustomRenderPipeline 在创建时生成一个渲染器的实例,然后在循环中使用该实例来渲染所有相机
CameraRenderer renderer = new CameraRenderer();protected override void Render (ScriptableRenderContext context, Camera[] cameras) {}protected override void Render (ScriptableRenderContext context, List<Camera> cameras) {for (int i = 0; i < cameras.Count; i++) {renderer.Render(context, cameras[i]);}}
我们的相机渲染器大致相当于通用渲染管线中的可编程渲染器。这种设计方式便于未来为每个相机支持不同的渲染方案,例如第一人称视角与3D地图叠加层可采用不同渲染方式,或分别使用前向渲染与延迟渲染。但目前我们将以统一方式渲染所有相机
2.2 Drawing the Skybox
CameraRenderer.Render 的职责是绘制其相机可见的所有几何体。为了清晰起见,我们将这个特定任务隔离在一个独立的 DrawVisibleGeometry 方法中。首先,我们将让它绘制默认的天空盒,这可以通过以相机为参数调用上下文上的 DrawSkybox 方法来实现。
public void Render (ScriptableRenderContext context, Camera camera) {this.context = context;this.camera = camera;DrawVisibleGeometry();}void DrawVisibleGeometry () {context.DrawSkybox(camera);}
仅这样做还不会让天空盒显示出来。这是因为我们向上下文发出的命令会被缓冲。必须通过调用上下文上的 Submit 方法来提交排队的工作以供执行。我们将在 DrawVisibleGeometry 之后调用的单独 Submit 方法中执行此操作
public void Render (ScriptableRenderContext context, Camera camera) {this.context = context;this.camera = camera;DrawVisibleGeometry();Submit();}void Submit () {context.Submit();}
天空盒终于出现在游戏窗口和场景窗口中。启用帧调试器后,您也能在其中看到对应的条目。它被列为Camera.RenderSkybox,其下有一个Draw Mesh项目,代表实际的绘制调用。这对应着游戏窗口的渲染过程。帧调试器不会显示其他窗口的绘制信息。
Skybox gets drawn
请注意,相机当前的朝向不会影响天空盒的渲染方式。我们将相机传递给DrawSkybox方法,但这仅用于根据相机的清除标志(clear flags)来决定是否应该绘制天空盒。
要正确渲染天空盒(以及整个场景),我们必须设置视图-投影矩阵(view-projection matrix)。这个变换矩阵结合了相机的位置和朝向(视图矩阵)与相机的透视或正交投影(投影矩阵)。它在着色器中被称为unity_MatrixVP,是几何体绘制时使用的着色器属性之一。在帧调试器中选中某个绘制调用时,您可以在ShaderProperties部分查看该矩阵。
目前,unity_MatrixVP 矩阵始终保持不变。我们需要通过SetupCameraProperties方法将相机的属性应用到上下文(context)中。该方法会设置该矩阵以及其他一些属性。请在调用DrawVisibleGeometry之前,在一个单独的 Setup 方法中执行此操作
public void Render (ScriptableRenderContext context, Camera camera) {this.context = context;this.camera = camera;Setup();DrawVisibleGeometry();Submit();}void Setup () {context.SetupCameraProperties(camera);}
Skybox, correctly aligned.
2.3Command Buffers
the context 会延迟实际渲染,直到我们提交为止。在此之前,我们需要对其进行配置并添加命令以供后续执行。有些任务(例如绘制 skybox )可以通过专用方法直接发出,但其他命令必须通过单独的命令缓冲区间接发出。我们需要这样的缓冲区来绘制场景中的其他几何体。
要获取缓冲区,必须创建新的 CommandBuffer 对象实例。我们只需要一个缓冲区,因此默认为 CameraRenderer 创建一个,并将其引用存储在字段中。同时为缓冲区命名,以便在帧调试器中能够识别它。命名为 " Render Camera " 即可。
const string bufferName = "Render Camera";CommandBuffer buffer = new CommandBuffer {name = bufferName};
我们可以使用命令缓冲区来注入性能分析器样本,这些样本将同时显示在性能分析器和帧调试器中。具体方法是在适当的位置调用 BeginSample
和 EndSample
方法,在我们的例子中,这些位置分别是 Setup
方法的开始处和 Submit
方法的开始处。这两个方法必须使用相同的样本名称,我们将使用缓冲区的名称作为样本名。
void Setup () {buffer.BeginSample(bufferName);context.SetupCameraProperties(camera);}void Submit () {buffer.EndSample(bufferName);context.Submit();}
要执行缓冲区,需在上下文上调用 ExecuteCommandBuffer
方法并将缓冲区作为参数传入。这会复制缓冲区中的命令但不会清除它,如果我们需要重用缓冲区,必须在此后显式清除。由于执行和清除总是需要一起完成,添加一个同时完成这两个操作的方法会非常方便。
void Setup () {buffer.BeginSample(bufferName);ExecuteBuffer();context.SetupCameraProperties(camera);}void Submit () {buffer.EndSample(bufferName);ExecuteBuffer();context.Submit();}void ExecuteBuffer () {context.ExecuteCommandBuffer(buffer);buffer.Clear();}
The Camera.RenderSkyBox sample now gets nested inside Render Camera.
Render camera sample.
2.4 Clearing the Render Target
我们绘制的所有内容最终都会渲染到相机的渲染目标上,默认情况下是帧缓冲区,但也可能是渲染纹理。该目标上先前绘制的任何内容仍然存在,这可能会干扰我们现在正在渲染的图像。为确保正确渲染,我们必须清除渲染目标以去除其旧内容。这可以通过在命令缓冲区上调用 ClearRenderTarget
方法来实现,该方法应放在 Setup
方法中。
CommandBuffer.ClearRenderTarget 方法至少需要三个参数。前两个参数表示是否应清除深度和颜色数据,这里两者都应设为 true。第三个参数是用于清除的颜色,我们将使用 Color.clear。
void Setup () {buffer.BeginSample(bufferName);buffer.ClearRenderTarget(true, true, Color.clear);ExecuteBuffer();context.SetupCameraProperties(camera);}
Clearing, with nested sample.
帧调试器现在会为清除操作显示一个"Draw GL"条目,该条目嵌套在额外一层的"Render Camera"中。这是因为ClearRenderTarget
方法将清除操作包装在了一个以命令缓冲区名称命名的样本中。我们可以在开始自己的样本之前执行清除操作,从而消除这种冗余的嵌套。这样会产生两个相邻的"Render Camera"样本范围,它们最终会被合并显示。
void Setup () {buffer.ClearRenderTarget(true, true, Color.clear);buffer.BeginSample(bufferName);//buffer.ClearRenderTarget(true, true, Color.clear);ExecuteBuffer();context.SetupCameraProperties(camera);}
Clearing, without nesting.
"Draw GL"条目表示使用 Hidden/InternalClear
着色器绘制全屏四边形来写入渲染目标,这并不是最高效的清除方式。出现这种情况是因为我们在设置相机属性之前执行了清除操作。如果交换这两个步骤的顺序,就会采用更快速的清除方式
void Setup () {context.SetupCameraProperties(camera);buffer.ClearRenderTarget(true, true, Color.clear);buffer.BeginSample(bufferName);ExecuteBuffer();//context.SetupCameraProperties(camera);
Correct clearing.
现在我们可以看到"Clear (color+Z+stencil)",这表明颜色缓冲区和深度缓冲区都被清除了。其中Z代表深度缓冲区,而模板数据则是同一缓冲区的一部分。
2.5 Culling
目前我们能看到天空盒,但场景中放置的物体却未显示。我们不会绘制所有物体,而是只渲染相机可见的那些对象。具体做法是:首先收集场景中所有带渲染器组件的物体,然后剔除位于相机视锥体范围外的对象。
要进行剔除计算,我们需要跟踪相机的多项设置和矩阵数据,这时可以使用ScriptableCullingParameters
结构体。我们无需手动填充该结构体,只需调用相机上的TryGetCullingParameters
方法即可。该方法会返回参数是否成功获取(因为异常的相机设置可能导致失败)。要获取参数数据,需要将其作为输出参数传递(在参数前添加out
关键字)。我们将在一个独立的Cull
方法中执行此操作,该方法会返回成功或失败状态。
bool Cull () {ScriptableCullingParameters pif (camera.TryGetCullingParameters(out p)) {return true;}return false;}
为什么需要使用 out
关键字?
当一个结构体参数被定义为输出参数时,它就像对象引用一样,指向参数所在的内存栈位置。当方法修改该参数时,直接影响的是原始值而非副本。
out
关键字表明该方法负责正确设置参数值,并替换之前的值。
Try-get 模式是一种常见的设计方式,既能指示操作成功与否,又能生成结果数据。
当参数作为输出参数使用时,可以将变量声明直接内联在参数列表中,我们就采用这种方式。
bool Cull () {//ScriptableCullingParameters pif (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {return true;}return false;}
在Render方法中,先于Setup调用Cull方法,如果失败则中止渲染。
public void Render (ScriptableRenderContext context, Camera camera) {this.context = context;this.camera = camera;if (!Cull()) {return;}Setup();DrawVisibleGeometry();Submit();}
实际剔除操作通过调用上下文上的 Cull
方法完成,该方法会生成一个 CullingResults
结构体。如果成功获取剔除参数,就在 Cull
方法中执行剔除并将结果存储在字段中。此时我们需要将剔除参数作为引用参数传递,即在参数前添加 ref
关键字。
CullingResults cullingResults;…bool Cull () {if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {cullingResults = context.Cull(ref p);return true;}return false;}
Why do we have to use ref
?
ref
关键字的作用与out
类似,但区别在于方法不强制要求必须对其赋值。调用方法的一方需要负责先对值进行正确的初始化。因此它可以用于输入,也可选择性地用于输出。
在这种情况下使用ref
是为了优化性能,避免传递较大的 ScriptableCullingParameters
结构体的副本。而将其定义为结构体而非对象是另一种优化手段,目的是避免内存分配。
2.6 Drawing Geometry
一旦确定了可见物体,我们就可以继续渲染这些对象。这需要通过调用上下文上的 DrawRenderers
方法来实现,并将剔除结果作为参数传入,以告知系统要使用哪些渲染器。此外,我们还需要提供绘制设置和过滤设置。这两个设置都是结构体——分别是 DrawingSettings
和 FilteringSettings
——我们最初将使用它们的默认构造函数。这两个结构体都需要通过引用传递。在 DrawVisibleGeometry
方法中,在绘制天空盒之前执行此操作。
void DrawVisibleGeometry () {var drawingSettings = new DrawingSettings();var filteringSettings = new FilteringSettings();context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);context.DrawSkybox(camera);}
我们还看不到任何内容,因为还需要指定允许哪种类型的着色器通道。由于本教程仅支持无光照着色器,我们需要获取 SRPDefaultUnlit 通道的着色器标签 ID,这个操作只需执行一次并可以将其缓存到静态字段中。
static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");
将其作为 DrawingSettings
构造函数的第一个参数提供,同时传入一个新的 SortingSettings
结构体值。将相机传递给排序设置的构造函数,因为它用于确定是采用正交排序还是基于距离的排序。
void DrawVisibleGeometry () {var sortingSettings = new SortingSettings(camera);var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);…}
此外,我们还需要指定允许的渲染队列范围。将RenderQueueRange.all
传递给FilteringSettings
构造函数,以便包含所有渲染队列
var filteringSettings = new FilteringSettings(RenderQueueRange.all);
Drawing unlit geometry
只有使用无光照着色器的可见物体才会被绘制。所有绘制调用都列在帧调试器中,归组在 RenderLoop.Draw 下。透明物体的渲染出现了异常情况,但我们首先来看物体的绘制顺序。帧调试器会显示绘制顺序,您可以通过逐个选择绘制调用或使用方向键来逐步查看
Stepping through the frame debugger
绘制顺序是随机的。我们可以通过设置排序设置的 criteria 属性来强制指定绘制顺序。让我们使用 SortingCriteria.CommonOpaque
var sortingSettings = new SortingSettings(camera) {criteria = SortingCriteria.CommonOpaque};
Common opaque sorting.
现在物体的绘制大致按照从前到后的顺序进行,这对于不透明物体来说是理想的。如果某个物体被绘制在另一个物体后面,其被遮挡的片段就可以被跳过,从而加速渲染。常见的不透明排序选项还会考虑其他一些标准,包括渲染队列和材质。
2.7 Drawing Opaque and Transparent Geometry Separately
帧调试器显示透明物体已被绘制,但天空盒会覆盖所有未处于不透明物体前方的物体。天空盒在不透明几何体之后绘制,因此其被遮挡的片段可以被跳过,但它会覆盖透明几何体。这是因为透明着色器不会写入深度缓冲区——它们不会遮挡背后的物体(毕竟我们可以看穿它们)。解决方案是先绘制不透明物体,然后是天空盒,最后才绘制透明物体
我们可以通过切换到RenderQueueRange.opaque
来在初始的DrawRenderers
调用中排除透明物体
var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
接着在绘制完天空盒后,再次调用DrawRenderers
。但在执行之前,先将渲染队列范围更改为RenderQueueRange.transparent
,同时将排序标准改为SortingCriteria.CommonTransparent
,并重新设置绘制设置的排序方式。这将反转透明物体的绘制顺序
context.DrawSkybox(camera);sortingSettings.criteria = SortingCriteria.CommonTransparent;drawingSettings.sortingSettings = sortingSettings;filteringSettings.renderQueueRange = RenderQueueRange.transparent;context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
Opaque, then skybox, then transparent.
Why is the draw order reversed?
由于透明物体不会写入深度缓冲区,按从前到后排序对性能并无益处。但当透明物体在视觉上相互重叠时,必须按照从后往前的顺序绘制才能正确进行混合。
遗憾的是,从后往前排序并不能保证混合结果绝对正确,因为排序是基于每个物体且仅依据物体位置进行的。相交或大型透明物体仍可能产生错误的混合效果。这种情况有时可以通过将几何体分割成更小的部分来解决
3.Editor Rendering
我们的渲染管线已能正确绘制无光照物体,但还可以通过一些改进来优化在Unity编辑器中的使用体验
3.1 Drawing Legacy Shaders 渲染传统着色器
由于我们的渲染管线仅支持无光照着色器通道,使用其他通道的物体将不会被渲染,导致其不可见。虽然这是正确的行为,但这会掩盖场景中某些物体使用了错误着色器的事实。因此,我们不妨单独渲染这些物体。
如果用户从默认的Unity项目开始,后来切换到我们的渲染管线,那么他们的场景中可能会存在使用错误着色器的物体。为了覆盖Unity的所有默认着色器,我们必须使用以下着色器标签ID:Always、ForwardBase、PrepassBase、Vertex、VertexLMRGBM和VertexLM。将这些标签ID保存在一个静态数组中
static ShaderTagId[] legacyShaderTagIds = {new ShaderTagId("Always"),new ShaderTagId("ForwardBase"),new ShaderTagId("PrepassBase"),new ShaderTagId("Vertex"),new ShaderTagId("VertexLMRGBM"),new ShaderTagId("VertexLM")};
在可见几何体渲染完成后,通过一个独立的方法绘制所有不支持的着色器,先从第一个通道开始。由于这些是无效通道,渲染结果本身就不正确,因此我们无需关心其他设置。我们可以通过FilteringSettings.defaultValue
属性获取默认的过滤设置
public void Render (ScriptableRenderContext context, Camera camera) {…Setup();DrawVisibleGeometry();DrawUnsupportedShaders();Submit();}…void DrawUnsupportedShaders () {var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera));var filteringSettings = FilteringSettings.defaultValue;context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);}
我们可以通过在绘制设置上调用SetShaderPassName
方法来绘制多个通道,该方法以绘制顺序索引和标签作为参数。对数组中的所有通道执行此操作,从第二个通道开始(因为构造绘制设置时已设置了第一个通道)
var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera));for (int i = 1; i < legacyShaderTagIds.Length; i++) {drawingSettings.SetShaderPassName(i, legacyShaderTagIds[i]);}
Standard shader renders black.
使用标准着色器渲染的物体现在显示出来了,但由于我们的渲染管线没有为它们设置所需的着色器属性,它们呈现为纯黑色
3.2 Error Material
为了清晰标识使用不兼容着色器的物体,我们将使用Unity的错误着色器进行绘制。通过以该着色器为参数构建新材质(可使用Shader.Find
方法并传入"Hidden/InternalErrorShader"
字符串来获取),并利用静态字段缓存该材质以避免每帧重复创建。随后将其赋值给绘制设置的overrideMaterial
属性
static Material errorMaterial;…void DrawUnsupportedShaders () {if (errorMaterial == null) {errorMaterial =new Material(Shader.Find("Hidden/InternalErrorShader"));}var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera)) {overrideMaterial = errorMaterial};…}
Rendered with magenta error shader
现在所有无效物体都可见且明显呈现异常状态
3.3 Partial Class
绘制无效物体在开发阶段很有用,但并不适用于正式发布的应用程序。因此,我们将所有仅用于编辑器的CameraRenderer代码放到一个独立的分部类文件中。首先复制原始的CameraRenderer脚本资源,并将其重命名为CameraRenderer.Editor
One class, two script assets
然后将原始的 CameraRenderer 转换为分部类,并从中移除标签数组、错误材质和 DrawUnsupportedShaders 方法
public partial class CameraRenderer { … }
清理另一个分部类文件,使其仅包含我们从原文件中移除的内容
using UnityEngine;
using UnityEngine.Rendering;partial class CameraRenderer {static ShaderTagId[] legacyShaderTagIds = { … };static Material errorMaterial;void DrawUnsupportedShaders () { … }
}
编辑器部分的内容仅需在编辑器环境下存在,因此应将其条件编译为仅当定义了UNITY_EDITOR时生效
partial class CameraRenderer {#if UNITY_EDITORstatic ShaderTagId[] legacyShaderTagIds = { … }};static Material errorMaterial;void DrawUnsupportedShaders () { … }#endif
}
然而,此时进行项目构建将会失败,因为另一部分代码始终调用了DrawUnsupportedShaders
方法,而该方法现在仅存在于编辑器环境下。为了解决这个问题,我们将该方法也改为分部方法。具体做法是:始终在方法签名前使用partial
关键字进行声明,类似于抽象方法的声明方式。我们可以在类的任意部分进行此声明,这里将其放在编辑器部分。完整的方法声明也必须标记为partial
partial void DrawUnsupportedShaders ();#if UNITY_EDITOR…partial void DrawUnsupportedShaders () { … }#endif
现在构建项目的编译过程可以成功进行了。编译器会自动剔除所有最终没有完整声明的分部方法调用
3.4 Drawing Gizmos
目前我们的渲染管线不会绘制Gizmos,无论是在场景窗口还是已启用Gizmos的游戏窗口中都不会显示
Scene without gizmos
我们可以通过调用UnityEditor.Handles.ShouldRenderGizmos
来检查是否应该绘制Gizmos。如果是,则需要在上下文上调用DrawGizmos
方法,将相机作为第一个参数,第二个参数用于指示应绘制哪个Gizmo子集。共有两个子集,分别对应图像效果前和图像效果后。由于目前我们暂不支持图像效果,因此两个子集都会调用。这些操作应在一个新的仅限编辑器使用的DrawGizmos
方法中实现
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;partial class CameraRenderer {partial void DrawGizmos ();partial void DrawUnsupportedShaders ();#if UNITY_EDITOR…partial void DrawGizmos () {if (Handles.ShouldRenderGizmos()) {context.DrawGizmos(camera, GizmoSubset.PreImageEffects);context.DrawGizmos(camera, GizmoSubset.PostImageEffects);}}partial void DrawUnsupportedShaders () { … }#endif
}
Gizmos应在所有其他内容绘制完成后进行渲染
public void Render (ScriptableRenderContext context, Camera camera) {…Setup();DrawVisibleGeometry();DrawUnsupportedShaders();DrawGizmos();Submit();}
Scene with gizmos
3.5 Drawing Unity UI
另一个需要注意的事项是Unity的游戏内用户界面。例如,通过GameObject / UI / Button添加一个按钮来创建简单UI。它会在游戏窗口中显示,但不会出现在场景窗口中
UI button in game window.
帧调试器显示用户界面是独立渲染的,并非由我们的渲染管线处理
UI in frame debugger
至少,当画布组件的渲染模式设置为"屏幕空间-覆盖"(默认模式)时是这样的。将其更改为"屏幕空间-相机"并使用主相机作为其渲染相机,将使UI成为透明几何体的一部分
Screen-space-camera UI in frame debugger
在场景窗口中渲染UI时,系统始终会使用世界空间模式,这通常导致UI显示得异常巨大。但尽管我们可以通过场景窗口编辑UI,它却不会被绘制出来
UI invisible in scene window
在渲染场景窗口时,我们必须通过调用ScriptableRenderContext.EmitWorldGeometryForSceneView
方法(以相机作为参数)来显式地将UI添加到世界几何体中。这一操作应在一个新的仅限编辑器使用的PrepareForSceneWindow
方法中完成。当相机的cameraType
属性等于CameraType.SceneView
时,表示我们正在使用场景相机进行渲染
partial void PrepareForSceneWindow ();#if UNITY_EDITOR…partial void PrepareForSceneWindow () {if (camera.cameraType == CameraType.SceneView) {ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);}}
由于这可能会向场景中添加几何体,因此必须在进行剔除操作之前完成
PrepareForSceneWindow();if (!Cull()) {return;}
UI visible in scene window.
4. Multiple Cameras
场景中可能存在多个激活的相机。如果是这种情况,我们必须确保它们能够协同工作
4.1 Two Cameras
每个相机都有一个深度值,默认主相机的深度值为-1。它们按照深度值递增的顺序进行渲染。为了验证这一点,可以复制主相机,将其重命名为“次级相机”,并将其深度值设置为0。最好也为它设置另一个标签,因为MainCamera标签应该只被单个相机使用
Both cameras grouped in a single sample scope
现在场景会被渲染两次。由于渲染目标在两次渲染之间被清除了,最终图像仍然相同。帧调试器会显示这一过程,但由于相邻的同名样本范围会被合并,我们最终只会看到一个"Render Camera"范围。
如果每个相机都有自己独立的范围会更清晰。为了实现这一点,可以添加一个仅限编辑器使用的PrepareBuffer方法,将缓冲区的名称设置为相机的名称
partial void PrepareBuffer ();#if UNITY_EDITOR…partial void PrepareBuffer () {buffer.name = camera.name;}#endif
在准备场景窗口之前调用它
PrepareBuffer();
PrepareForSceneWindow();
每个相机使用独立的采样范围
4.2 Dealing with Changing Buffer Names
尽管帧调试器现在会为每个相机显示独立的采样层级,但当我们进入播放模式时,Unity的控制台会充满警告信息,提示BeginSample和EndSample的计数必须匹配。这是因为我们为采样和缓冲区使用了不同的名称,导致系统混淆。此外,每次访问相机的name属性都会分配内存,我们不希望在构建版本中这样做。
为了解决这两个问题,我们将添加一个SampleName字符串属性。如果在编辑器中,我们在PrepareBuffer方法中设置该属性及缓冲区名称;否则,它只是"Render Camera"字符串的常量别名
#if UNITY_EDITOR…string SampleName { get; set; }…partial void PrepareBuffer () {buffer.name = SampleName = camera.name;}#elseconst string SampleName = bufferName;#endif
在 Setup 和 Submit 方法中使用 SampleName 作为采样的名称
void Setup () {context.SetupCameraProperties(camera);buffer.ClearRenderTarget(true, true, Color.clear);buffer.BeginSample(SampleName);ExecuteBuffer();}void Submit () {buffer.EndSample(SampleName);ExecuteBuffer();context.Submit();}
我们可以通过性能分析器(通过Window/Analysis/Profiler打开)来观察差异。首先在编辑器中运行游戏,切换到Hierarchy模式并按GC Alloc列排序。您会看到两条GC.Alloc调用记录,总共分配了100字节,这是由于获取相机名称导致的。往下滚动还会看到这些名称显示为采样标签:Main Camera和Secondary Camera
Profiler with separate samples and 100B allocations
接下来,制作一个启用"Development Build"和"Autoconnect Profiler"的构建版本。运行该构建并确保性能分析器已连接并正在记录。在这种情况下,我们不会看到100字节的内存分配,而是会得到单一的"Render Camera"采样结果
Profiling build
我们可以通过将相机名称的获取操作包装在名为"Editor Only"的性能分析器采样中,来明确显示内存分配仅发生在编辑器环境下而非构建版本中。此时需要调用UnityEngine.Profiling
命名空间下的Profiler.BeginSample
和Profiler.EndSample
方法,且只需为BeginSample
方法传入名称参数
using UnityEditor;
using UnityEngine;
using UnityEngine.Profiling;
using UnityEngine.Rendering;partial class CameraRenderer {…#if UNITY_EDITOR…partial void PrepareBuffer () {Profiler.BeginSample("Editor Only");buffer.name = SampleName = camera.name;Profiler.EndSample();}#elsestring SampleName => bufferName;#endif
}
Editor-only allocations made obvious
4.3 Layers
相机还可以配置为仅显示特定图层上的内容,这通过调整其剔除遮罩来实现。要观察这一效果,让我们将所有使用标准着色器的物体移至"Ignore Raycast"图层
Layer switched to Ignore Raycast
将"Ignore Raycast"图层从主相机的剔除遮罩中排除
Culling the Ignore Raycast layer
并将该图层设置为次级相机唯一可见的图层
仅保留 Ignore Raycast 图层的可见性,剔除其他所有图层
由于次级相机最后渲染,我们最终只能看到无效物体
游戏窗口中仅显示 Ignore Raycast 图层的内容
4.4 Clear Flags
我们可以通过调整第二个被渲染相机的清除标志来合并两个相机的渲染结果。清除标志由CameraClearFlags
枚举定义,可通过相机的clearFlags
属性获取。在Setup
方法中的清除操作之前进行此判断
void Setup () {context.SetupCameraProperties(camera);CameraClearFlags flags = camera.clearFlags;buffer.ClearRenderTarget(true, true, Color.clear);buffer.BeginSample(SampleName);ExecuteBuffer();}
CameraClearFlags
枚举定义了四个值。从1到4依次是Skybox
、Color
、Depth
和Nothing
。这些实际上并非独立的标志位值,而是代表了递减的清除程度。除最后一种情况外,深度缓冲区在所有情况下都需要被清除,即当标志值不超过Depth
时都需要清除深度缓冲区
buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, true, Color.clear);
实际上,我们仅当清除标志设置为Color
时才需要清除颜色缓冲区,因为如果是Skybox
模式,我们最终会替换所有先前的颜色数据。然而……
buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth,flags == CameraClearFlags.Color,Color.clear);
针对Unity 2022版本,我将其修改为除非明确告知不清除,否则始终清除颜色缓冲区。这是因为渲染目标可能包含非数字和无穷大值,这些值会导致混合伪影。此外,帧调试器可能会显示随机数据,增加调试难度
flags <= CameraClearFlags.Color
此外,如果我们要清除为纯色,则必须使用相机的背景颜色。但由于我们在线性颜色空间中进行渲染,必须将该颜色转换为线性空间,因此最终需要使用camera.backgroundColor.linear
。在所有其他情况下,颜色并不重要,使用Color.clear
即可满足需求
buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth,flags == CameraClearFlags.Color,flags == CameraClearFlags.Color ?camera.backgroundColor.linear : Color.clear);
由于主相机首先渲染,其清除标志应设置为"Skybox"或"Color"。启用帧调试器时,我们总是从清除缓冲区开始,但这并非普遍保证的。
次级相机的清除标志决定了两个相机的渲染结果如何组合。当设置为"Skybox"或"Color"时,先前的结果会被完全替换。当仅清除深度时,次级相机会正常渲染但不绘制天空盒,因此先前的结果会显示为背景。当不清除任何内容时,深度缓冲区得以保留,因此无光照物体会像由同一相机绘制一样遮挡无效物体。然而,由前一个相机绘制的透明物体没有深度信息,因此会被覆盖,就像之前的天空盒一样
Clear color, depth-only, and nothing
通过调整相机的视口矩形(Viewport Rect),还可以将渲染区域缩小到整个渲染目标的一部分。渲染目标的其余部分保持不变。在这种情况下,清除操作会使用 Hidden/InternalClear 着色器进行。模板缓冲区用于将渲染限制在视口区域内
次级相机缩小视口范围,清除颜色缓冲区
请注意,每帧渲染多个相机意味着剔除、设置、排序等操作也需要执行多次。通常,每个独特视角使用一个相机是最高效的方法