自定义渲染管线:初识渲染流程 (翻译一)
这是关于创建自定义脚本化渲染管线(Scriptable Render Pipeline, SRP)的系列教程的第一部分。它涵盖了我们将要在未来扩展的准系统渲染管线的初始创建。
- 创建渲染管线资源和实例
- 渲染摄像机视图
- 执行剔除、过滤和排序
- 分离不透明、透明和无效阶段
- 处理多个摄像机。
1 Render Pipeline
为了渲染任何内容,Unity 必须确定需要绘制哪些形状,以及在何时、何地、使用何种设置进行绘制。根据涉及效果的多寡,这一过程可能会变得非常复杂。光照、阴影、透明度、图像效果、体积效果等都必须按正确的顺序处理,才能得到最终的图像。这就是 render pipeline 的职责。
过去,Unity 仅支持几种内置的渲染方式。Unity 2018 引入了 scriptable render pipelines(简称 RPs),使我们能够随心所欲地实现自定义渲染,同时仍能依靠 Unity 完成剔除(culling)等基础步骤。Unity 2018 还添加了两个使用这种新方法制作的实验性 RP:Lightweight RP 和 High Definition RP。在 Unity 2019 中,Lightweight RP 不再是实验性的,并在 Unity 2019.3 中更名为 Universal RP。
Universal RP 旨在取代当前的 legacy RP 成为默认选项。其理念是将其打造为一个适用于大多数场景且易于自定义的 RP。本系列教程不会去自定义该 RP,而是将从头开始创建一个完整的 RP。
本教程将通过一个使用 forward rendering 进行绘制 unlit 形状的最小化的最基础的RPs。一旦该流程正常运行,我们就可以在后续教程中扩展我们的 pipeline,添加光照、阴影、不同的渲染方法以及更高级的功能。
1.1 项目设置
在 Unity 2022.3.5f1(以前是 2019.2.6)或更高版本中创建一个新的 3D 项目。我们将创建自己的管线,因此不要选择任何 RP 项目模板。项目打开后,你可以前往包管理器 (Package Manager) 并删除所有不需要的包。在本教程中,我们只会使用 Unity UI 包来试验绘制 UI,因此你可以保留该包。
我们将专门在线性颜色空间 linear color space 中工作,但 Unity 2019.2 仍将 Gamma 空间作为默认设置。通过 Edit / Project Settings 进入 Player 设置,然后在 Other Settings 部分下将 Color Space 切换为 Linear。
在默认场景中填充一些物体,混合使用标准、不透明无光照(unlit opaque)以及透明材质。由于 Unlit/Transparent shader 仅支持纹理,这里提供了一张用于该材质的 UV 球体贴图。
我在我的测试场景中放了几个不透明的立方体。红色的使用标准着色器材质,而绿色和黄色的使用 Unlit/Color 着色器材质。蓝色球体使用标准着色器,并将 Rendering Mode 设置为 Transparent,而白色球体使用 Unlit/Transparent 着色器。
1.2 管线资源 (Pipeline Asset)
目前,Unity 使用默认渲染管线。要将其替换为自定义渲染管线,我们首先必须为其创建一个资产类型。我们将使用与 Unity 用于通用 RP 大致相同的文件夹结构。创建一个 Custom RP 资产文件夹,其中包含一个 Runtime 子文件夹。在其中放入一个新的 C# 脚本,用于 CustomRenderPipelineAsset 类型。
资产类型必须扩展 UnityEngine.Rendering 命名空间中的 RenderPipelineAsset。
1
2
3
4
5
6
7
8
9
using UnityEngine;
using UnityEngine.Rendering;
[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset {
protected override RenderPipeline CreatePipeline () {
return null;
}
}
RP 资产的主要目的是让 Unity 能够获得负责渲染的管线对象实例。资产本身只是一个句柄和存储设置的地方。我们还没有任何设置,所以现在能做的就是先通过覆盖抽象的 CreatePipeline 方法来让 Unity 能够获取我们的管线对象实例。该方法应该返回一个 RenderPipeline 实例。但我们还没有定义自定义 RP 类型,所以先返回 null。
CreatePipeline 方法使用 protected 访问修饰符定义,这意味着只有定义该方法的类(即 RenderPipelineAsset)以及扩展它的类才能访问它。
为了让创建该资产成为可能,我们将 CreateAssetMenu 属性添加到 CustomRenderPipelineAsset。
这将会在 Asset / Create 菜单中放置一个条目。让我们保持整洁,将其放在 Rendering 子菜单中。我们通过将属性的 menuName 属性设置为 Rendering/Custom Render Pipeline 来实现这一点。
1
2
[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset { ... }
使用新的菜单项将资产添加到项目中,然后转到 Graphics 项目设置并在 Scriptable Render Pipeline Settings 下选择它。
替换默认 RP 改变了一些事情。首先,图形设置中消失了很多选项,这在信息面板中有所提及。其次,我们禁用了默认 RP 但没有提供有效的替代品,因此不再渲染任何内容。游戏窗口、场景窗口和材质预览不再起作用。如果你打开帧调试器 (Frame Debugger)——通过 Window / Analysis / Frame Debugger——并启用它,你会看到游戏窗口中确实没有绘制任何内容。
1.3 渲染管线实例 (Render Pipeline Instance)
创建一个 CustomRenderPipeline 类,并将其脚本文件放在与 CustomRenderPipelineAsset 相同的文件夹中。这将是我们的资产返回的 RP 实例所使用的类型,因此它必须扩展 RenderPipeline。
1
2
3
4
5
6
7
8
9
using UnityEngine;
using UnityEngine.Rendering;
public class CustomRenderPipeline : RenderPipeline {
//unity 2022之前的老版本API,每帧分配相机数组
protected override void Render (
ScriptableRenderContext context, Camera[] cameras
) {}
}
RenderPipeline 定义了一个受保护的抽象 Render 方法,我们必须覆盖它来创建一个具体的管线。它有两个参数:一个 ScriptableRenderContext 和一个List<Camera>,先让该方法保持为空。
1
2
3
protected override void Render (
ScriptableRenderContext context, System.Collections.Generic.List<Camera> cameras
) {}
此方法曾是自定义 SRP 定义的入口点,但由于相机数组参数需要每帧分配内存,因此引入了一个使用列表参数的替代方案。我们可以在 Unity 2022 中使用该方案,但仍必须保留旧版本,因为它被声明为抽象方法,尽管它不会被使用。请注意,后续的 profiler 截图仍会包含旧版相机数组的内存分配。
让 CustomRenderPipelineAsset.CreatePipeline 返回 CustomRenderPipeline 的新实例。这将让我们获得一个有效且功能完备的管线,尽管它还不会渲染任何内容。
1
2
3
protected override RenderPipeline CreatePipeline () {
return new CustomRenderPipeline();
}
2 渲染 (Rendering)
每一帧 Unity 都会在 RP 实例上调用 Render 。它会传递一个 context 结构体,该结构体提供了与原生引擎的连接,我们可以使用它进行渲染。它还会传递一个 camera 数组,因为场景中可能存在多个处于激活状态的 camera。RP 的职责是按照提供的顺序渲染所有这些 camera。
2.1 摄像机渲染器 (Camera Renderer)
每个 camera 都是独立渲染的。因此,我们不让 CustomRenderPipeline 渲染所有 camera,而是将该职责转发给一个专门负责渲染单个 camera 的新类。将其命名为 CameraRenderer ,并为其提供一个带有 context 和 camera 参数的公开 Render 方法。为了方便起见,我们将这些参数存储在字段中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 在创建时创建一个渲染器的实例,然后在循环中使用它来渲染所有摄像机。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class CustomRenderPipeline : RenderPipeline {
CameraRenderer renderer = new CameraRenderer();
protected override void Render (
ScriptableRenderContext context, Camera[] cameras
) {}
protected override void Render (
ScriptableRenderContext context, System.Collections.Generic.List<Camera> cameras
) {
for (int i = 0; i < cameras.Count; i++) {
renderer.Render(context, cameras[i]);
}
}
}
我们的 camera renderer 大致相当于 Universal RP 的 scriptable renderer。这种方法可以方便未来为每个 camera 支持不同的渲染方式,例如一个用于第一人称视角,另一个用于 3D 地图叠加,或者前向渲染与延迟渲染的对比。但目前我们将以相同的方式渲染所有 camera。
2.2 绘制天空盒 (Drawing the Skybox)
CameraRenderer.Render 的任务是绘制其 camera 可见的所有几何体。为了清晰起见,将该特定任务隔离在一个单独的 DrawVisibleGeometry 方法中。我们首先让它绘制默认的 skybox,这可以通过在 context 上调用 DrawSkybox 并将 camera 作为参数来实现。
1
2
3
4
5
6
7
8
9
10
11
public void Render (ScriptableRenderContext context, Camera camera) {
this.context = context;
this.camera = camera;
//这里默认场景只有一个相机,所以外部for循环调用Render后续再优化这个多相机流程
DrawVisibleGeometry();
}
void DrawVisibleGeometry () {
context.DrawSkybox(camera);
}
这还不能让 skybox 出现。这是因为我们向 context 发出的命令是缓冲处理的。我们必须通过调用 context 的 Submit 来提交排队的工作以执行。让我们在一个单独的 Submit 方法中完成此操作,该方法在 DrawVisibleGeometry 之后调用。
1
2
3
4
5
6
7
8
9
10
11
public void Render (ScriptableRenderContext context, Camera camera) {
this.context = context;
this.camera = camera;
DrawVisibleGeometry();
Submit();
}
void Submit () {
context.Submit();
}
skybox 终于出现在 game 窗口和 scene 窗口中了。启用 frame debugger 后,你也可以在其中看到它的条目。它被列为 Camera.RenderSkybox ,其下方有一个 Draw Mesh 项,代表实际的 draw call。这对应于 game 窗口的渲染。frame debugger 不会报告其他窗口的绘制情况。
请注意,摄像机的方向目前不会影响天空盒的渲染方式。我们将摄像机传递给 DrawSkybox,但这仅用于根据摄像机的清除标志 (clear flags) 确定是否应该绘制天空盒。
为了正确渲染天空盒以及整个场景,我们必须设置视图投影矩阵(view-projection matrix)。该变换矩阵结合了摄像机的位置和朝向(即视图矩阵 view matrix)与摄像机的透视或正交投影(即投影矩阵 projection matrix)。在 shader 中,它被称为 unity_MatrixVP ,是绘制几何体时使用的 shader 属性之一。当选中某个 draw call 时,你可以在 frame debugger 的 ShaderProperties 部分检查该矩阵。
目前,unity_MatrixVP 矩阵始终保持不变。我们必须通过 SetupCameraProperties 方法将摄像机的属性应用于 context。该方法会设置矩阵以及其他一些属性。请在调用 DrawVisibleGeometry 之前,在一个独立的 Setup 方法中执行此操作。
1
2
3
4
5
6
7
8
9
10
11
12
public void Render (ScriptableRenderContext context, Camera camera) {
this.context = context;
this.camera = camera;
Setup();
DrawVisibleGeometry();
Submit();
}
// 渲染之前把相机的属性传递给context
void Setup () {
context.SetupCameraProperties(camera);
}
2.3 命令缓冲区 (Command Buffers)
Context 会延迟实际的渲染,直到我们提交它。在此之前,我们会对其进行配置并添加命令以便稍后执行。某些任务(例如绘制 skybox)可以通过专用方法发布,但其他命令必须通过单独的 command buffer 间接发布。我们需要这样一个 buffer 来绘制场景中的其他几何体。
为了获取 buffer,我们必须创建一个新的 CommandBuffer 对象实例。我们只需要一个 buffer,因此默认在 CameraRenderer 中创建一个,并将其引用存储在字段中。同时给 buffer 起一个名字,以便我们在 frame debugger 中识别它。 命名Custom Render Camera 即可。
1
2
3
4
5
const string bufferName = "Render Camera";
CommandBuffer buffer = new CommandBuffer {
name = bufferName
};
我们可以使用 command buffers 来注入 (profiler samples),这些样本将同时显示在profiler 和 frame debugger 中。这通过在适当的时间点调用 BeginSample 和 EndSample 来完成,在我们的例子中是在 Setup 和 Submit 的开头。这两个方法必须提供相同的 sample 名称,我们将使用 buffer 的名称。
1
2
3
4
5
6
7
8
9
void Setup () {
buffer.BeginSample(bufferName);
context.SetupCameraProperties(camera);
}
void Submit () {
buffer.EndSample(bufferName);
context.Submit();
}
若要执行 buffer,请调用 context 上的 ExecuteCommandBuffer 并将 buffer 作为参数传入。这会从 buffer 中拷贝命令,但不会将其清除,如果我们要重复使用它,之后必须显式地进行清除。执行和清除操作总是连在一起进行的,因此添加一个同时完成这两项操作的方法会很方便。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void Setup () {
buffer.BeginSample(bufferName);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
void Submit () {
buffer.EndSample(bufferName);
ExecuteBuffer();
context.Submit();
}
void ExecuteBuffer () {
context.ExecuteCommandBuffer(buffer);
buffer.Clear();
}
Camera.RenderSkybox 示例现在嵌套在 Render Camera 内部。
2.4 清除渲染目标 (Clearing the Render Target)
我们绘制的所有内容最终都会渲染到摄像机的渲染目标中,默认情况下是帧缓冲区,但也可能是渲染纹理。之前绘制到该目标的内容仍然存在,这可能会干扰我们现在渲染的图像。为了保证正确的渲染,我们必须清除渲染目标以移除其旧内容。这是通过在 command buffer 上调用 ClearRenderTarget 来完成的,该调用应位于 Setup 方法中。
CommandBuffer.ClearRenderTarget 至少需要三个参数。前两个指示是否应该清除深度和颜色数据,这两者都设置为 true。第三个参数是用于清除的颜色,我们将使用 Color.clear。
1
2
3
4
5
6
void Setup () {
buffer.BeginSample(bufferName);
buffer.ClearRenderTarget(true, true, Color.clear);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
Frame Debugger 现在会为清除操作显示一个 Draw GL 条目,该条目嵌套在额外一层的 Render Camera 中。这是因为 ClearRenderTarget 会使用 Command Buffer 的名称将清除操作包装在一个 Sample 中。我们可以通过在开始自定义 Sample 之前进行清除来消除冗余的嵌套。这样会产生两个相邻的 Render Camera Sample 作用域,它们会被合并。
1
2
3
4
5
6
7
void Setup () {
//换一个位置
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(bufferName);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
Draw GL 条目表示使用 Hidden/InternalClear shader 绘制一个全屏四边形并写入 render target,这并不是清除它的最有效方式。之所以采用这种方法,是因为我们在设置 camera 属性之前进行了清除操作。如果我们交换这两个步骤的顺序,就可以使用更快速的清除方式。
1
2
3
4
5
6
7
void Setup () {
//把参数传递提前
context.SetupCameraProperties(camera);
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(bufferName);
ExecuteBuffer();
}
现在我们看到 Clear (color+Z+stencil),这表明颜色和深度缓冲区都被清除了。Z 代表深度缓冲区,模板数据是同一缓冲区的一部分。
2.5 剔除 (Culling)
我们目前可以看到天空盒,但看不到场景中放置的任何物体。与其绘制每一个物体,我们应该只渲染那些对摄像机可见的物体。为此,我们首先获取场景中所有带有 renderer 组件的物体,然后剔除那些位于摄像机视锥体 (view frustum) 之外的物体。
确定哪些物体可以被剔除需要我们跟踪多个摄像机设置和矩阵,为此我们可以使用 ScriptableCullingParameters 结构体。我们无需手动填充它,而是可以在摄像机上调用 TryGetCullingParameters 。它会返回参数是否成功获取,因为对于某些退化的摄像机设置可能会失败。为了获取参数数据,我们必须将其作为输出参数提供,即在它前面加上 out 。请在一个独立的 Cull 方法中执行此操作,该方法返回成功或失败。
1
2
3
4
5
6
bool Cull () {
if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
return true;
}
return false;
}
在 Render 中的 Setup 之前调用 Cull,如果失败则中止。
1
2
3
4
5
6
7
8
9
10
11
12
public void Render (ScriptableRenderContext context, Camera camera) {
this.context = context;
this.camera = camera;
if (!Cull()) {
return;
}
Setup();
DrawVisibleGeometry();
Submit();
}
实际的剔除是通过在上下文上调用 Cull 来完成的,这会产生一个 CullingResults 结构。如果成功,在 Cull 中执行此操作并将结果存储在字段中。在这种情况下,我们必须通过在前面写 ref 将剔除参数作为引用参数传递。
1
2
3
4
5
6
7
8
9
CullingResults cullingResults;
bool Cull () {
if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
cullingResults = context.Cull(ref p);
return true;
}
return false;
}
2.6 绘制几何图形 (Drawing Geometry)
一旦我们知道了哪些物体是可见的,就可以开始渲染它们。这是通过调用 context 的 DrawRenderers 方法并传入 culling results 作为参数来完成的,以此告知它使用哪些 renderer。除此之外,我们还必须提供 drawing settings 和 filtering settings。两者都是结构体—— DrawingSettings 和 FilteringSettings ——最初我们将使用它们的默认构造函数。两者都必须以引用方式传递。请在 DrawVisibleGeometry 中,绘制 skybox 之前执行此操作。
1
2
3
4
5
6
7
8
9
10
void DrawVisibleGeometry () {
var drawingSettings = new DrawingSettings();
var filteringSettings = new FilteringSettings();
context.DrawRenderers(
cullingResults, ref drawingSettings, ref filteringSettings
);
context.DrawSkybox(camera);
}
我们还看不到任何东西,因为我们还必须指示允许哪些类型的着色器通道 (shader passes)。由于我们在本教程中仅支持无光照 (unlit) 着色器,因此我们必须获取 SRPDefaultUnlit 通道的着色器标签 ID,我们可以执行一次并将其缓存在静态字段中。
1
static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");
将其作为第一个参数传递给 DrawingSettings 构造函数,同时传入一个新的 SortingSettings 结构体值。将 camera 传递给排序设置的构造函数,因为它被用于确定是应用正交排序还是基于距离的排序。
1
2
3
4
5
6
7
8
9
10
11
12
13
void DrawVisibleGeometry () {
var sortingSettings = new SortingSettings(camera);
var drawingSettings = new DrawingSettings(
unlitShaderTagId, sortingSettings
);
var filteringSettings = new FilteringSettings();
context.DrawRenderers(
cullingResults, ref drawingSettings, ref filteringSettings
);
context.DrawSkybox(camera);
}
除此之外,我们还必须指示允许哪些渲染队列(render queues)。将 RenderQueueRange.all 传递给 FilteringSettings 构造函数,以便我们包含所有内容。
1
var filteringSettings = new FilteringSettings(RenderQueueRange.all);
只有使用了 unlit shader 的可见对象才会被绘制。所有的 draw calls 都会列在 frame debugger 中,并被分组在 RenderLoop.Draw 下。透明对象似乎出现了一些异常,但让我们先来看看对象的绘制顺序。frame debugger 会显示这一顺序,你可以通过依次选择或使用方向键来逐步查看各个 draw calls。
逐步查看 frame debugger
绘制顺序是杂乱无章的。我们可以通过设置排序设置的 criteria 属性来强制执行特定的绘制顺序。让我们使用 SortingCriteria.CommonOpaque。
1
2
3
4
5
6
void DrawVisibleGeometry () {
var sortingSettings = new SortingSettings(camera) {
criteria = SortingCriteria.CommonOpaque
};
...
}
逐步查看 frame debugger
现在物体大致按照从前到后的顺序进行绘制,这对于不透明物体来说是最理想的。如果某个物体最终被绘制在另一个物体后面,其隐藏的片元(fragments)就可以被跳过,从而加快渲染速度。通用的不透明排序选项还会考虑其他一些标准,包括 render queue 和 materials。
2.7 分别绘制不透明和透明几何体
Frame debugger 显示透明对象已被绘制,但天空盒(skybox)却覆盖了所有未处于不透明对象前方的区域。天空盒在不透明几何体之后绘制,以便跳过所有被遮挡的片段,但它同时也会覆盖透明几何体。这是因为透明 shader 不会写入深度缓冲区(depth buffer)。它们不会遮挡背后的内容,因为我们可以看穿它们。解决方案是先绘制不透明对象,接着绘制天空盒,最后再绘制透明对象。
我们可以通过切换到 RenderQueueRange.opaque 来从初始的 DrawRenderers 调用中排除透明物体。
1
var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
然后在绘制天空盒之后再次调用 DrawRenderers。但在这样做之前,将渲染队列范围更改为 RenderQueueRange.transparent。还要将排序标准更改为 SortingCriteria.CommonTransparent 并再次设置绘制设置的排序。这将反转透明对象的绘制顺序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void DrawVisibleGeometry () {
var sortingSettings = new SortingSettings(camera) {
criteria = SortingCriteria.CommonOpaque
};
var drawingSettings = new DrawingSettings(
unlitShaderTagId, sortingSettings
);
var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
context.DrawRenderers(
cullingResults, ref drawingSettings, ref filteringSettings
);
context.DrawSkybox(camera);
sortingSettings.criteria = SortingCriteria.CommonTransparent;
drawingSettings.sortingSettings = sortingSettings;
filteringSettings.renderQueueRange = RenderQueueRange.transparent;
context.DrawRenderers(
cullingResults, ref drawingSettings, ref filteringSettings
);
}
逐步查看 frame debugger
不透明物体不会写入深度缓冲区,因此将它们从前到后排序没有性能优势。但当不透明物体在视觉上相互遮挡时,必须从后到前绘制以正确混合。
不幸的是,从后到前的排序并不能保证正确的混合,因为排序是针对单个物体,并且仅基于物体的位置。相交和大的不透明物体仍然可能产生错误的结果。这有时可以通过将几何体切割成更小的部分来解决。
3 编辑器渲染 (Editor Rendering)
我们的 RP 可以正确绘制 unlit 对象,但我们还可以做一些工作来提升在 Unity editor 中使用它的体验。
3.1 绘制旧版着色器 (Drawing Legacy Shaders)
由于我们的 pipeline 仅支持 unlit shader pass,使用其他 pass 的对象将不会被渲染,从而变得不可见。虽然这在逻辑上是正确的,但它掩盖了场景中某些对象使用了错误 shader 的事实。因此,让我们还是渲染它们,但采用分开渲染的方式。
如果有人从默认的 Unity 项目开始,随后切换到我们的 RP,那么他们的场景中可能会存在使用错误 shader 的对象。为了涵盖所有 Unity 的默认 shader,我们需要为 Always, ForwardBase, PrepassBase, Vertex, VertexLMRGBM, 和 VertexLM 使用 shader tag ID。请在一个静态数组中记录这些 ID。
1
2
3
4
5
6
7
8
static ShaderTagId[] legacyShaderTagIds = {
new ShaderTagId("Always"),
new ShaderTagId("ForwardBase"),
new ShaderTagId("PrepassBase"),
new ShaderTagId("Vertex"),
new ShaderTagId("VertexLMRGBM"),
new ShaderTagId("VertexLM")
};
在绘制可见几何体之后,通过一个独立的方法绘制所有不支持的 shader,从第一个 pass 开始。由于这些是无效的 pass,结果无论如何都会是错误的,所以我们不需要关心其他设置。我们可以通过 FilteringSettings.defaultValue 属性获取默认的过滤设置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
);
}
我们可以通过在 drawing settings 上调用 SetShaderPassName 并传入绘制顺序索引和标签作为参数来绘制多个 pass。对数组中的所有 pass 执行此操作。因为我们在构建 drawing settings 已经设置了第一个 pass且同时设置了 SortingSettings,后面从第二个开始指定shader tag id。
1
2
3
4
5
6
var drawingSettings = new DrawingSettings(
legacyShaderTagIds[0], new SortingSettings(camera)
);
for (int i = 1; i < legacyShaderTagIds.Length; i++) {
drawingSettings.SetShaderPassName(i, legacyShaderTagIds[i]);
}
使用 standard shader 渲染的对象会显示出来,但它们现在是纯黑色的,因为我们的 RP 尚未为它们设置所需的 shader 属性。
3.2 错误材质 (Error Material)
为了清晰地标出哪些对象使用了不支持的 shader,我们将使用 Unity 的错误 shader 来绘制它们。通过调用 Shader.Find 并传入 Hidden/InternalErrorShader 字符串作为参数来查找该 shader,并以此构造一个新的材质。通过静态字段缓存该材质,以免每帧都创建新材质。然后将其赋值给绘制设置的 overrideMaterial 属性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
};
...
}
现在所有无效对象都已可见,且明显呈现错误状态。
3.3 Partial类
绘制无效对象对开发很有用,但不适用于发布的应用程序。因此让我们将 CameraRenderer 的所有仅限编辑器的代码放在一个单独的分部类文件中。首先复制原始的 CameraRenderer 脚本资产并将其重命名为 CameraRenderer.Editor。
然后将原始的 CameraRenderer 更改为 partial class,并从中移除标签数组、错误材质以及 DrawUnsupportedShaders 方法。
1
public partial class CameraRenderer { ... }
清理另一个 partial class 文件,使其仅包含我们从原文件中移除的内容。
1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;
using UnityEngine.Rendering;
partial class CameraRenderer {
static ShaderTagId[] legacyShaderTagIds = { ... };
static Material errorMaterial;
void DrawUnsupportedShaders () { ... }
}
编辑器部分的内容仅需存在于编辑器中,因此请将其设置为以 UNITY_EDITOR 为条件。
1
2
3
4
5
6
7
8
9
10
11
12
partial class CameraRenderer {
#if UNITY_EDITOR
static ShaderTagId[] legacyShaderTagIds = { ... };
static Material errorMaterial;
void DrawUnsupportedShaders () { ... }
#endif
}
然而,此时进行 build 将会失败,因为另一部分始终包含对 DrawUnsupportedShaders 的调用,而该方法现在仅在 editor 模式下存在。为了解决这个问题,我们也需要将该方法设为 partial。具体做法是在方法签名之前始终加上 partial ,类似于抽象方法的声明。我们可以在类定义的任何部分执行此操作,所以让我们将其放在 editor 部分。完整的方法声明也必须标记为 partial 。
1
2
3
4
5
6
7
8
9
partial void DrawUnsupportedShaders ();
#if UNITY_EDITOR
...
partial void DrawUnsupportedShaders () { ... }
#endif
现在可以成功进行 build 编译了。编译器将自动移除所有未包含完整声明的 partial method 调用。
3.4 绘制辅助线 (Drawing Gizmos)
目前我们的 RP 不绘制辅助线 (Gizmos),无论是在场景窗口还是在启用了它们的游视窗口中。
我们可以通过调用 UnityEditor.Handles.ShouldRenderGizmos 来检查是否应该绘制 gizmos。如果是,我们必须在 context 上调用 DrawGizmos ,并将 camera 作为第一个参数,第二个参数用于指示应绘制哪个 gizmos 子集。共有两个子集,分别用于图像效果(image effects)之前和之后。由于我们目前不支持图像效果,我们将同时调用两者。请在一个新的仅限编辑器使用的 DrawGizmos 方法中执行此操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 应该在所有其他内容之后绘制。
1
2
3
4
5
6
7
8
public void Render (ScriptableRenderContext context, Camera camera) {
...
Setup();
DrawVisibleGeometry();
DrawUnsupportedShaders();
DrawGizmos();
Submit();
}
3.5 绘制 Unity UI
另外一件需要我们注意的事情是 Unity 的游戏内用户界面。例如,通过 GameObject / UI / Button 添加一个按钮来创建一个简单的 UI。它会显示在游戏窗口中,但不会出现在场景窗口中。
frame debugger 显示 UI 是单独渲染的,而不是由我们的 RP 渲染。
至少,当 Canvas 组件的 Render Mode 设置为 Screen Space - Overlay (这是默认值)时是这样的。将其更改为 Screen Space - Camera 并使用主摄像机作为其 Render Camera ,将使其成为透明几何体的一部分。
UI 在场景窗口中渲染时总是使用 World Space 模式,这就是为什么它通常看起来非常大。但虽然我们可以通过场景窗口编辑 UI,它却不会被绘制出来。
在为 scene 窗口进行渲染时,我们必须通过调用以相机为参数的 ScriptableRenderContext.EmitWorldGeometryForSceneView ,显式地将 UI 添加到世界几何体中。在一个新的仅限编辑器使用的 PrepareForSceneWindow 方法中执行此操作。当场景相机的 cameraType 属性等于 CameraType.SceneView 时,我们正在使用该相机进行渲染。
1
2
3
4
5
6
7
8
9
10
11
12
13
partial void PrepareForSceneWindow ();
#if UNITY_EDITOR
...
partial void PrepareForSceneWindow () {
if (camera.cameraType == CameraType.SceneView) {
ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
}
}
#endif
由于这可能会向场景添加几何体,因此必须在剔除之前完成。
1
2
3
4
5
6
7
8
9
10
public void Render (ScriptableRenderContext context, Camera camera) {
this.context = context;
this.camera = camera;
PrepareForSceneWindow();
if (!Cull()) {
return;
}
...
}
4 多个摄像机 (Multiple Cameras)
场景中可能会有多个处于激活状态的摄像机。如果是这样,我们必须确保它们能协同工作。
4.1 两个摄像机
每个摄像机都有一个 Depth 值,默认主摄像机为 -1。它们按深度增加的顺序渲染。为了观察到这一点,请复制 Main Camera ,将其重命名为 Secondary Camera ,并将其 Depth 设置为 0。同时建议给它设置另一个标签,因为 MainCamera 应该只由单个摄像机使用。
场景现在被渲染了两次。结果图像仍然是相同的,因为渲染目标在两者之间被清除了。帧调试器显示了这一点,但由于具有相同名称的相邻样本范围会被合并,我们最终得到了一个单独的 Render Camera 范围。
如果每个摄像机都有自己的范围,那会更清晰。为了实现这一点,添加一个仅限编辑器的 PrepareBuffer 方法,使缓冲区的名称等于摄像机的名称。
1
2
3
4
5
6
7
8
9
10
11
partial void PrepareBuffer ();
#if UNITY_EDITOR
...
partial void PrepareBuffer () {
buffer.name = camera.name;
}
#endif
在准备场景窗口之前调用它。
1
2
3
4
5
6
7
8
public void Render (ScriptableRenderContext context, Camera camera) {
this.context = context;
this.camera = camera;
PrepareBuffer();
PrepareForSceneWindow();
...
}
4.2 处理变化的缓冲区名称
虽然帧调试器(frame debugger)现在为每个摄像机显示了独立的采样层级,但当我们进入播放模式时,Unity 的控制台会充斥着警告消息,提示 BeginSample 和 EndSample 的计数必须匹配。这是因为我们为采样及其 buffer 使用了不同的名称,导致它产生了混淆。除此之外,每次访问摄像机的 name 属性时都会分配内存,因此我们不希望在构建版本(builds)中这样做。
为了解决这两个问题,我们将添加一个 SampleName 字符串属性。如果在编辑器中,我们在 PrepareBuffer 中将其与 buffer 的名称一起设置,否则它只是 Render Camera 字符串的一个常量别名。
1
2
3
4
5
6
7
8
9
#if UNITY_EDITOR
string SampleName { get; set; }
partial void PrepareBuffer () {
buffer.name = SampleName = camera.name;
}
#else
const string SampleName = bufferName;
#endif
在 Setup 和 Submit 中为样本使用 SampleName。
1
2
3
4
5
6
7
8
9
10
11
12
void Setup () {
context.SetupCameraProperties(camera);
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(SampleName);
ExecuteBuffer();
}
void Submit () {
buffer.EndSample(SampleName);
ExecuteBuffer();
context.Submit();
}
我们可以通过检查 profiler(通过 Window / Analysis / Profiler 打开)并先在编辑器中播放来查看差异。切换到 Hierarchy 模式并按 GC Alloc 列排序。你会看到两个 GC.Alloc 调用的条目,总共分配了 100 字节,这是由获取摄像机名称引起的。再往下看,你会看到这些名称作为采样显示: Main Camera 和 Secondary Camera 。
接下来,启用 Development Build 和 Autoconnect Profiler 进行构建。运行构建并确保分析器已连接并正在记录。在这种情况下,我们没有得到 100 字节的分配,我们得到了单个 Render Camera 样本。
我们可以通过将获取 camera 名称的代码包裹在名为 Editor Only 的 profiler 采样中,来明确我们仅在 editor 中分配内存,而不在 build 中分配。在这种情况下,我们需要调用 UnityEngine.Profiling 命名空间下的 Profiler.BeginSample 和 Profiler.EndSample。只有 BeginSample 需要传递名称参数。
1
2
3
4
5
partial void PrepareBuffer () {
Profiler.BeginSample("Editor Only");
buffer.name = SampleName = camera.name;
Profiler.EndSample();
}
4.3 Layers
Camera 也可以配置为仅观察特定层级(layer)上的物体。这可以通过调整它们的 Culling Mask 来实现。为了查看实际效果,让我们将所有使用 standard shader 的对象移动到 Ignore Raycast 层。
从 Main Camera 的剔除遮罩(culling mask)中排除该图层。
并使其成为 Secondary Camera 唯一可见的图层。
因为 Secondary Camera 最后渲染,我们最终只能看到无效对象。
4.4 清除标志 (Clear Flags)
我们可以通过调整第二个渲染摄像机的清除标志(clear flags)来合并两个摄像机的结果。这些标志由 CameraClearFlags 枚举定义,我们可以通过摄像机的 clearFlags 属性获取。请在 Setup 方法中的清除操作之前执行此步骤。
1
2
3
4
5
6
void Setup () {
context.SetupCameraProperties(camera);
CameraClearFlags flags = camera.clearFlags;
buffer.ClearRenderTarget(true, true, Color.clear);
...
}
CameraClearFlags 枚举定义了四个值。从 1 到 4 分别是 Skybox, Color, Depth, 和 Nothing。这些实际上不是独立的标志值,而是代表递减的清除量。深度缓冲区在除了最后一种情况外的所有情况下都必须被清除,因此当标志值最多为 Depth 时。
1
buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, true, Color.clear);
我们实际上只需要在 flags 设置为 Color 时清除 color buffer,因为在 Skybox 的情况下,我们最终都会替换掉所有之前的颜色数据。然而,
1
2
3
4
5
buffer.ClearRenderTarget(
flags <= CameraClearFlags.Depth,
flags <= CameraClearFlags.Color,
Color.clear
);
如果我们清除为纯色,我们必须使用摄像机的背景颜色。但因为我们在线性颜色空间中渲染,我们必须将该颜色转换为线性空间,所以我们最终需要 camera.backgroundColor.linear。在所有其他情况下,颜色并不重要,所以我们可以用 Color.clear 满足。
1
2
3
4
5
6
buffer.ClearRenderTarget(
flags <= CameraClearFlags.Depth,
flags <= CameraClearFlags.Color,
flags == CameraClearFlags.Color ?
camera.backgroundColor.linear : Color.clear
);
因为 Main Camera 是第一个渲染的,它的清除标志应该设置为 Skybox 或 Color。启用帧调试器时,我们总是从清除缓冲区开始,但这在一般情况下并不能保证。
Secondary Camera 的 clear flags 决定了两个 camera 的渲染结果如何合并。在选择 skybox 或 color 的情况下,之前的渲染结果会被完全替换。当仅清除 depth 时, Secondary Camera 会正常渲染,但不绘制 skybox,因此之前的渲染结果会作为背景显示。当不清除任何内容(nothing)时,depth buffer 会被保留,因此 unlit 对象会像是由同一个 camera 绘制的一样遮挡无效对象。然而,由前一个 camera 绘制的 transparent 对象没有深度信息,因此会被覆盖,就像之前的 skybox 一样。
通过调整摄像机的 Viewport Rect ,还可以将渲染区域缩小到仅占整个渲染目标的一小部分。渲染目标的其余部分保持不变。在这种情况下,清除操作通过 Hidden/InternalClear shader 完成。stencil buffer 用于将渲染限制在 viewport 区域。
请注意,每帧渲染多个摄像机意味着剔除、设置、排序等也必须执行多次。使用一个摄像机处理每个独特的视点通常是最有效的方法。
下一篇教程是绘制调用。






































