Custom SRP - Custom Render Pipeline
该教程部分完成的工程状态可见:Custom Render Pipeline
#A new Render Pipeline
早期的 Unity 仅支持 内置渲染管线(Default Render Pipeline, DRP / Built-in Render Pipleline)
。自 Unity 2018 后,Unity 引入了 可编程渲染管线(Scriptable Render Piplelines,SRP)
,但在 2018 中该功能是试验预览的状态,在 Unity 2019 中该功能才成为 正式功能。
基于 SRP
,Unity 官方在 2018 的版本中实现了两套管线, Lightweight Render Pipeline
和 High Definition Render Pipeline
。前者针对于移动端这样的轻量级平台,而后者针对如 PC,主机这样的高性能平台。在 Unity 2019 的版本中, Lightweight Render Pipeline
被拓展为 Universal Render Pipeline
。
Lightweight Render Pipeline
和 Universal Render Pipeline
实际上是同一套管线,Lightweight Render Pipeline
仅是 Unity 2018 中的早期实现版本的命名。
Universal Render Pipeline
计划最终取代目前的内置渲染管线,成为 Unity 渲染的默认渲染管线。
#Project Setup
该笔记使用的 Unity 版本为 2022.3.12f1
#Color Space
Unity 工程的默认色彩空间 Gamma,而为了保证后续光照等计算的准确性,首先需要将颜色空间切换为线性空间,可通过 Edit -> Project Settings -> Player -> Other Settings -> Rendering -> Color Space
修改。
#Sample Scene
在场景中随意放置一些 Cube 和 Sphere,并附加不同的材质,结果如下图所示:
所使用的材质设置如下图所示:
#Pipeline Asset
SRP 相关的脚本基本都在 UnityEngine.Rendering
命名空间下,且 SRP 已经在引擎内包含,因此此时并不需要额外导入其他的 Package。
当使用 SRP
时,Unity 引擎需要通过 RenderPipe Asset(RP Asset)
来获取渲染管线的实例,同时也会从 RP Asset
中读取关于渲染管线的设置。
为了创建 RP Asset
,首先需要创建对应的 ScriptableObject
。可以通过继承 RenderPipelineAsset
基类创建出可以构建 RP Asset
的 ScriptableObject
。如下所示:
1 | using UnityEngine; |
所有派生自 RenderPipelineAsset
的类都必须实现 CreatePipeline
函数,Unity 使用该函数获取渲染管线的实例。
之后可以通过 Assets -> Create -> Rendering -> Custom Render Pipeline
创造出 RP Asset
,结果如下所示:
可以通过 Project Settings -> Graphics -> Scriptable Render Pipeline Settings
将自定义的 RP Asset
设置给 Unity,如下所示:
当替换后了 RP Asset
后,主要有两个变化:
-
原
Graphics
面板中的许多设置消失了。
因为替换的RP Asset
并没有提供相关的设置选项。 -
Scene / Game / Material 界面都不再渲染任何东西
因为替换的RP Asset
实际上返回的是空,即 Unity 此时没有任何的渲染管线可以用。
#Render Pipeline Instance
为了创建出一个渲染管线,需要通过继承 RenderPipeline
构建自定义的渲染管线类,所有的派生自 RenderPipeline
的类都必须实现 Render
函数,Unity 在每一帧通过触发该函数进行渲染,如下所示:
1 | using UnityEngine; |
可以看到上述的实现中,对于 Render
函数有两个重载,其分别有 Camera[]
和 List<Camera>
的形参,在 Unity 2022 之前, 引擎仅支持形参为 Camera[]
的重载版本。而在 Unity 2022 之后,引擎又引入了形参为 List<Camera>
的重载版本。
为了后续的遍历的便捷性,这里使用 List<Camera>
版本的重载,对于 Camera[]
版本的重载,保持空实现即可。
因为形参为 Camera[]
的函数原先被标记为了 abstract
,因此必须被定义。
之前的 CustomRenderPipelineAsset.CreatePipeline
函数就可以返回该自定义渲染管线的示例,如下所示:
1 | protected override RenderPipeline CreatePipeline() |
此时 Unity 已经可以使用 CustomRenderPipeline
进行绘制,但此时所有的界面与之前并没有任何的区别,因为定义的 CustomRenderPipeline
中并没有进行任何的实质渲染。
#Rendering
Unity 通过 Render Pipeline Instance 中的 Render
函数进行渲染,Render
函数有两个形参:
-
ScriptableRenderContext
:该形参表示SRP
渲染的上下文。 RP 使用该形参与 Unity Native 的渲染部分进行通信 -
Camera[]
,该形参表示所有激活的 CamerasRP 使用该形参来控制每个摄像机的渲染与不同摄像机间的渲染顺序
#Camera Renderer
通过 ScriptableRenderContext
和 Camera
就可以控制每个摄像机的渲染,如可以通过自定义的 CameraRenderer
类来负责特定摄像机的渲染:
1 | public class CameraRenderer |
在之前的 CustomRenderPipeline
中,让每一个相机都调用 CameraRenderer.Render
函数,如下所示:
1 | public class CustomRenderPipeline : RenderPipeline |
#Drawing the Skybox
CameraRenderer.Render
的功能就是渲染所有该摄像机可以看到的物体。如以下的实现,可以让 CameraRender
渲染出天空盒:
1 | public void Render(ScriptableRenderContext renderContext, Camera camera) |
在实现中,对 ScriptableRenderContext
调用了一系列函数来完成绘制目的:
SetupCameraProperties
用于在 Shader 中设置摄像机相关的变量,如 View 矩阵,Projection 矩阵DrawSkybox
将渲染天空盒的命令添加到 Context 的缓冲中Submit
将 Context 缓冲中的命令添加到执行队列中。
仅当 Camera 的 ClearFlags 是 Skybox 时, DrawSkybox
才会真正的将绘制天空盒的命令添加到缓冲中。
结果如下所示:
#Command Buffers
之前的 DrawSkybox
命令向 Context 的缓冲中增加了一条渲染天空盒的命令。除此之外,可以通过 CommandBuffer
类和 context.ExecuteCommandBuffer
函数向 Context 中添加自定义的渲染命令。
通过如下命令创建 CommandBuffer
, CommandBuffer
的 name
属性可以在 FrameDebugger
中查看:
1 | private const string k_BufferName = "Render Camera"; |
FrameDebugger 可以通过 Window -> Analysis -> Frame Debugger
打开。
Profiler 可以通过 Window -> Analysis -> Profiler
打开。
而如果想要在 Profiler
中调试, 则可以使用 commandBuffer.BeginSample
和 commandBuffer.EndSample
API 将开始采样和结束采样的命令添加至 Command Buffer 中,再通过 ScriptableRenderContext.ExecuteCommandBuffer
执行 Command Buffer。如下所示:
1 | private void Setup() |
BeginSample
和 EndSample
的命名需要与 Buffer 的名称相同,否则可能会出现 Non matching Profiler.EndSample (BeginSample and EndSample count must match
的错误。
像 BeginSample
和 EndSample
这样的 API 每次执行都会向 Command Buffer 中增加一条命令,因此在 ExecuteCommandBuffer
中执行后需要对 Command Buffer 进行 Clear
操作,否则 Command Buffer 中的命令会越来越多。
此时在 Frame Debugger 中既可以看到之前添加的 Buffer Name 信息:
在 Profiler Window 中也能看到相应的信息:
#Clearing the Render Target
可通过在 Command Buffer 中添加 ClearRenderTarget
命令来清除渲染目标的内容,如下所示:
1 | private void Setup() |
此时可以在 Frame Debugger 中看到 Clear
的命令,如下所示:
对于增加 Clear Render Target 命令的顺序,必须严格按照上述例子,即先设置 Camera Properties,再进行 Clear,再进行 BeginSample。
如果先进行了 Clear,再进行了 SetupCameraProperties,那么 Frame Debugger 中会显示 Draw GL
命令而非 Clear
命令,即 Unity 通过渲染一张铺满整个渲染目标的 Quad 来达成清除的目的,而这会消费较多的性能。如下代码就会导致渲染 Quad:
1 | private void Setup() |
此时的 Frame Debugger 窗口将显示如下内容:
又因为 CommandBuffer.ClearRenderTarget
的实现会将 Clear 的操作放在一个以 Command Buffer 名称命名的 Sample 中,所以如下的代码将再 Frame Debugger 窗口中引发嵌套的 Sample,如下所示:
1 | private void Setup() |
此时的 Frame Debugger 窗口将显示如下内容:
#Culling
在正式的渲染前,为了保证仅渲染在摄像机的视锥体的内物体,需要让 Unity 进行 Culling 操作。为完成 Culling 操作,首先需要通过函数 TryGetCullingParameters
根据摄像机当前的状态获取到 Culling
相关的参数,再通过函数 context.Cull
将相关参数传递给渲染上下文,并得到 Culling 的结果,结果将以 CullingResults
表示,代码如下所示:
1 |
|
上述 Cull
函数,返回 bool 值表示获取 Culling 参数是否成功。在某些情况下,无法通过 TryGetCullingParameters
函数获取到 Culling 的参数,如摄像机的 Viewport 为空,或者近远剪切平面的设置不合法。
对于 Render
函数,应当仅在 Cull
成功的情况下再进行渲染如下所示:
1 | public void Render(ScriptableRenderContext renderContext, Camera camera) |
SRP 中许多函数都以 ref
传递数值,如 context.Cull
函数。但这通常是处于性能方面的考虑,避免数据的拷贝,而非是需要修改传入的参数。
#Drawing Geometry
现在可以通过 context.DrawRenderers
方法绘制具体的几何体,其中会用上之前获取到的 CullingResults
如下所示:
1 | private static ShaderTagId s_UnlitShaderTagId = new("SRPDefaultUnlit"); |
其中:
FilteringSetting
决定了渲染指定 RenderQueue 范围内的物体,这里填的RenderQueueRange.all
表示无论 RenderQueue 设置的为多少,都将被渲染。DrawingSettings
的第一个形参决定了需要执行的 Shader Pass, 这里传递的SRPDefaultUnlit
为 Unity 内置的 Tag,因为目前场景中的许多游戏物体选用的是Unlit
中的 Shader,所以使用该 Tag。
关于 Shader Tag 的内容,查看文档 Built-In Shader Tag 与 SRP Shader Tag
DrawingSettings
第二个形参是物体排序相关的设置 SortingSettings
,该变量的构造函数依赖 camera
变量,因为其中依赖 camera.transparencySortMode
决定以什么规则来计算排序的数值大小:
- Perspective:根据摄像机与物体中心的距离
- Orthographic:根据沿着摄像机 View 方向的距离
SortingSettings
中的 criteria
制定了排序的标准,如这里的 CommonOpaque
表示使用通常渲染不透明物体时的排序规则,该规则会综合考虑 RenderQueue,材质,距离等相关信息。
此时在 Frame Debugger 中查看渲染的顺序与结果,如下所示,可以看到基本是先渲染一个特定的材质,然后再渲染下一个:
如果查看 CommonOpaque
的定义可以看到,它考虑了尽可能的减少渲染上下文的切换,从前至后渲染等因素:
1 | public enum SortingCriteria |
物体具体的渲染顺序受 Unity 版本 / Unity 实现影响,这里的设置 criteria
更多的是一种“建议”,而具体的排序算法,在 Unity 引擎内部实现,是一个相对黑盒。
如果将 SortingSettings
中的 criteria
去除,即:
1 | private void DrawVisibleGeometry() |
则渲染的结果如下所示,几乎是一个无规律的状态在渲染:
#Drawing Opaque and Transparent Geometry Separately
在之前的最终渲染结果中,天空盒将半透明物体的一部分遮挡掉了,如下所示:
这是因为天空盒在半透明物体的之后进行渲染,而在 Unlit/Transparent
的 Shader 中,设置了 ZWrite Off
,即半透明物体不会写入深度缓冲,因此在绘制了半透明物体的部分,天空盒仍然能通过深度检测,即覆盖半透明物体。
解决这个问题的方式,就是调整渲染顺序为 不透明物体 -> 天空盒 -> 半透明物体
。实现方法如下所示:
1 | private void DrawVisibleGeometry() |
渲染结果如下:
#Editor Rendering
#Drawing Legacy Shaders
之前通过在初始化 DrawingSettings
时,设置的 Shader Tag 为 SRPDefaultUnlit
的 Shader,因此仅会渲染 Unlit Shader 的物体。而其余的物体,如使用了 Standard
Shader 的物体,可以通过以下 Built-in 的 Shader Tag 找到并渲染:
1 | private static ShaderTagId[] s_LegacyShaderTagIds = |
之后我们新建一个用以渲染这些 Built-in Shader 的物体的函数 DrawUnSupportedShadersGeometry
,如下所示:
1 | public void Render(ScriptableRenderContext renderContext, Camera camera) |
其中的 legacyShaderTagIds
中指定了常用的 Built-in 的 Shader Tag,即会尝试渲染 Built-in Shader 的物体。结果如下所示,可以看到使用了 Standard
Shader 的物体被渲染了出来:
在早期的 Unity 版本中,此时 Standard
Shader 的物体会被渲染成黑色,这是因为早期 SRP
无法设置 Standard
Shader 中的一些参数。
#Error Material
虽然在 Unity 2022 中,一些 Built-in Shader 的物体可以被渲染出来,但这仍然不健壮,且应当有明确的错误提示开发者应当将 Built-in Shader 替换为 SRP Shader,为达到这个目的,可以使用 Unity 内置的表示 Shader 错误的特殊 Shader 来渲染这些物体,只需要修改 DrawingSettings
中的 overrideMaterial
即可,如下所示:
1 | private static Material s_ErrorMaterial = null; |
此时结果如下:
#Partial Class
可以使用 Scripting Symbols 让不支持的 Shader 部分仅在 Editor 和 Development Build 才被显示,即将相关代码定义放到如下的代码块中:
1 |
|
同时为了更好的管理代码,可以将 Editor 部分放到 CameraRenderer.Editor
中,如下所示:
1 | // In CameraRenderer.Editor.cs |
这里使用了 Partial Classes 拆分 CameraRenderer
类,方便代码管理。并将 DrawUnSupportedShaderGeometry
函数定义为 Partial Methods,保证在非 Editor 和 Development Build 时,即使 DrawUnSupportedShaderGeometry
未被定义实现,代码仍然能正常编译。
#Drawing Gizmos
目前在 Scene 场景中并没有绘制 Gizmo
,如场景中并没有摄像机的显示,也没有摄像机的视锥体的展示。
可以通过 Handles.ShouldRenderGizmos
判断当前帧是否需要渲染 Gizmos
,如需要的话可通过函数 context.DrawGizmos
进行绘制。
Editor Scene 下的 Gizmos Toggle 会影响 Handles.ShouldRenderGizmos
的返回值。
Unity 的 Handles 存在许多关于 Gizmos 的帮助函数
Gizmos
的绘制应当在整个流程的最后,最终绘制 Gizmos
的代码如下:
1 | // In CameraRenderer |
其中 context.DrawGizmos
需要两个参数,第一个是表示当前 View 的 Camera, 第二个表示哪种 Gizmos
需要被绘制, GizmoSubset.PreImageEffects
表示受后处理影响的 Gizmos
, GizmoSubset.PostImageEffects
表示不受后处理影响的部分。这里选择渲染所有种类的 Gizmos
。渲染的结果如下:
#Drawing Unity UI
在场景中添加了一个 UGUI 的 Button 后,可以看到按钮在 Game 界面中被正常的渲染了出来,如下所示:
但通过 Frame Debugger 可以发现此时 UI 的渲染并没有经过自定义的 SRP 如下所示:
而当将 Canvas
中的 Render Mode
修改为 Screen Space - Camera
或 World Space
后,UI 的渲染被放到了渲染半透明物体的部分中,如下所示,且此时因为在半透明的队列中先渲染了 UI,所以 UI 几乎被其他物体遮挡住了:
但无论 Render Mode
是什么格式,在 Scene 界面中,UI 都没有被正常的渲染出来,能看到的只有 UI 的 Gizmo
,如下:
这是因为 UI 在 Scene 界面下,都是以 World Space
模式被渲染出来,而且用了不同的几何信息,且 UI 在 Scene 下的几何信息默认并没有被添加到 SRP 中。
对于在 Scene 中显示的 UI 的几何信息,需要通过函数 ScriptabEmitWorldGeometryForSceneView
添加到 SRP 中。且需要在调用 Cull
函数前被添加,保证这些几何信息同样会被进行正常裁剪。
整体代码如下所示:
1 | // In CameraRenderer |
ScriptableRenderContext.EmitWorldGeometryForSceneView
函数的描述如下:
1 | /// <summary> |
此时在 Scene 界面下就可以看到 UI 被正确的渲染出来。
#Multiply Cameras
#Two Cameras
在场景中可以将 Main Camera
进行拷贝,并将新的 Camera 命名为 Second Camera
,并将 Second Camera
的 Depth
参数设置为 0,即此时会先渲染 Main Camera
,然后再渲染 Second Camera
:
此时在 Frame Debugger 中可以看到两个摄像机的渲染被合并在了一起,如下所示:
这是因为此时两个 Camera 对应的 CameraRenderer
中的 Command Buffer
命名相同,因此 Frame Debugger 将两者的信息合并在了一起。
可以通过分别对两个 Command Buffer 进行命令来分开两者的渲染信息,如下所示:
1 | // In CameraRenderer |
camera.name
会造成内存分配,但因为 PrepareBuffer
是定义在 CameraRender.Editor
中,因此仅在 Editor 模式下运行,不会造成运行时的性能浪费。
此时 Frame Debugger 界面如下:
#Layers
可以调整物体的 Layer
以及摄像机的 Culling Mask
来控制摄像机仅渲染特定的游戏物体。
如将所有使用了 Standard
的游戏物体的 Layer
调整为 Ignore Raycast
,并将两个摄像机的 Culling Mask
设置为如下:
此时的渲染结果如下,因为 Second Camera 仅渲染 Ignore Raycast
Layer 的物体,又 Second Camera 会覆盖 Main Camera 的内容:
#Clear Flags
可以通过修改两个摄像机的 Clear Flags 来合并两个摄像机的渲染内容。并根据摄像机的 Clear Flags 调整 ClearRenderTarget 的逻辑,如下所示:
1 | private void Setup() |
其中 CameraClearFlags
是 Unity 定义的一个枚举值,有四个参数,参数数值从 1 到 4,分别为 Skybox
, Color
, Depth
, Nothing
。
上述代码中,除了 Nothing
的情况,都会将 Depth Buffer 清除,而仅在为 Color
的时候会对 Color Buffer 进行清除。在清除时,仅当为 Color
时使用 camera.backgroundColor
其余时候都用 Color.clear
。
使用 camera.backgroundColor.linear
是因为项目建立时,将颜色空间设置为了 Linear
。
理论上,在 CameraClearFlags
为 Skybox
时也应当清除 Color Buffer
,但因为 Skybox
时擦除了 Depth Buffer,又会在渲染的最后绘制 Skybox,所以上一帧的颜色内容即使不清除,也会被这一帧渲染的 Skybox 覆盖,因此不会造成显示的错误。
Main Camera
作为第一个渲染的摄像机,为了保证渲染的正确性,必须使用 Skybox
或 Color
作为 Clear Flags。 Second Camera
为了不 Clear 掉 Main Camera
渲染的内容,则必须使用 Depth
或 Nothing
保证 Main Camear
渲染的 Color Buffer 被保持。
当 Second Camera
选择 Depth
时, Main Camera
渲染的 Depth Buffer 会被 Clear,此时 Second Camera
渲染的内容就都会叠加到 Main Camera
的内容上。
当 Second Camera
选择 Nothing
时, Main Camera
渲染的 Depth Buffer 会被保留,此时 Second Camera
渲染的内容就仍要与 Main Camera
渲染的内容进行深度检测。
当 Main Camera
Clear Flags 为 Skybox
, Second Camera
的 Clear Flags 分别为 Skybox
, Color
, Depth
, Nothing
的结果如下:
还可以通过调整摄像机的 Viewport
决定摄像机渲染结果的输出范围,如下为 Second Camera
的 Clear Flag 为 Color
且 Viewport 为 (0.75, 0.75, 0.25, 0.25)
时的结果:
Unity 使用 Hidden/InternalClear
shader 来进行 Clear 操作。该 Shader 中会通过 Stencil Buffer 来实现 Camera Viewport 的效果。
当有多个摄像机时,每帧每个摄像机都需要进行 Culling
, Setup
, Sorting
等操作。因此增加摄像机数量会增大对性能的消耗。