Custom SRP - Custom Render Pipeline
https://catlikecoding.com/unity/tutorials/custom-srp/custom-render-pipeline/
-
新建 Render Pipeline
任何内容的渲染,最终都是要由 unity 决定在哪里,什么时候,以哪些参数进行渲染。根据目标效果的复杂程度,决定渲染的过程也很复杂。灯光,阴影,透明,图像效果,体积效果等,必须以特定的顺序渲染到最终的图像。
实际项目中,建议从URP定制管线。本教程依然是从头定制管线。
本篇教程展示基于前向渲染最简单的 unlit 对象。之后会逐步加入光照,阴影等其它高级效果。
1.1 项目设置
创建3D项目。注意不要创建URP/HDRP项目。之后,可以到 Package Manager 中移除我们不需要的 package。我们只需要 Unity UI package 。
我们的项目要使用 linear color space,在 Edit/Project Settings/Player,平台设置区域,Other Settings中,找到 Rendering,检查并确保切换到 linear color space。
在场景中创建几个对象,并为其指定材质:
-
红色立方体:Standard shader
-
绿色,黄色立方体:Unlit/Color
-
蓝色球:Standard shader,并切换到透明模式,指定贴图
-
白色球:Unlit/Transparent
1.2 Pipeline Asset
我们按照URP的目录组织方式,创建我们的目录,并创建我们的 Pipeline Asset
创建 CustomeRenderPipelineAsset.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset
{protected override RenderPipeline CreatePipeline(){return null;}
}
-
[CreateAssetMenu] 语义,向资产右键创建菜单中添加菜单项
-
RP Asset 必须派生自 RenderPipelineAsset
-
必须实现 CreatePipeline 接口。Unity 通过调用该方法创建 RP 实例
在Asset窗口,右键 Create/Rendering/Custom Render Pipeline,创建 CustomeRenderPipeline.asset
在 Project Settings/Graphics 窗口,指定我们的管线:
由于我们目前没有创建管线实例,因此,整个 Unity 的渲染窗口,都不会执行任何渲染。
1.3 Render Pipeline Instance
创建 CustomRenderPipeline.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;public class CustomRenderPipeline : RenderPipeline
{// RenderPipeline 定义的抽象接口,必须实现。但是由于 cameras 每帧分配内存,因此废弃了。// 保持该方法为空即可protected override void Render(ScriptableRenderContext context, Camera[] cameras) { }// RenderPipeline 渲染入口protected override void Render(ScriptableRenderContext context, List<Camera> cameras){ }
}
-
渲染
Unity 每帧调用 RP 实例的 Render 来执行渲染:
-
ScriptableRenderContext 提供引擎渲染接口,连接 Native Engine,我们将用该对象完成渲染。
-
cameras 场景中可能会使用多个对象,Unity 根据顺序,用该参数传入。
2.1 Camera Renderer
每个 Camera 都需要独立渲染,我们可以直接在 CustomRenderPipeline.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;}
}
基于 CameraRenderer,RP中的渲染代码看起来是这样的:
public class CustomRenderPipeline : RenderPipeline
{CameraRenderer cameraRenderer = new CameraRenderer();// RenderPipeline 渲染入口protected override void Render(ScriptableRenderContext context, List<Camera> cameras){ for(int i = 0; i < cameras.Count; i++){// 用 CameraRenderer 渲染每个摄像机cameraRenderer.Render(context, cameras[i]);}}
}
URP 中也是定义了 CameraRenderer 来执行渲染。用这种方法,如果未来希望每个摄像机用不同的方式渲染,扩展起来会很方便,例如一个摄像机是 first-person 视口,而另一个用来渲染3D地图,或者使用 forward / deferred 渲染。
2.2 Draw the Skybox
CameraRenderer 渲染指定摄像机可以“看到”的对象。为了代码的清晰,把这些任务实现到独立的方法 DrawVisibleGeometry
中,同时先把 Skybox 绘制出来。
渲染时,通过方法 SetupCameraProperties
设置摄像机的VP矩阵,该矩阵可以在 shader 中,以 unity_MatrixVP 来访问。
public class CustomRenderPipeline : RenderPipeline
{CameraRenderer cameraRenderer = new CameraRenderer();// RenderPipeline 渲染入口protected override void Render(ScriptableRenderContext context, List<Camera> cameras){ for(int i = 0; i < cameras.Count; i++){// 用 CameraRenderer 渲染每个摄像机cameraRenderer.Render(context, cameras[i]);}}
}
现在,渲染视口将正常渲染 Skybox,并且可以旋转摄像机看到天空盒的不同角度。
2.3 Command Buffers
只有我们 Submit 之后,Context 才会渲染。在这之前,我们可以进行配置,以及添加我们的渲染指令。像绘制天空这种,有专门的接口来提交渲染,但是其它的渲染,则需要通过另外的 CommandBuffer 来进行渲染。场景中其它几何体的渲染,就是用 CommandBuffer 来渲染的。
创建 CommandBuffer 我们可以直接创建一个 CommandBuffer,同时可以给它起名字,以在 Frame Debugger 中看到。
分析 CommandBuffer CommandBuffer 可以注入分析,通过调用 BeginSample
和 EndSample
实现。分析数据可以显示在 Profiler 和 Frame Debugger 中。
执行 CommandBuffer CommandBuffer 执行通过调用 ExecuteCommandBuffer
。该方法将拷贝指令,不会清空它。我们后面要继续复用该 CommandBuffer,因此我们要手动 Clear。我们把该流程定义成 ExecuteBuffer 方法。
现在,代码看起来是这样
public class CameraRenderer
{ScriptableRenderContext context;Camera camera;const string bufferName = "Render Camera";CommandBuffer buffer = new CommandBuffer{name = bufferName};public void Render(ScriptableRenderContext context, Camera camera){this.context = context;this.camera = camera;Setup();DrawVisibleGeometry();Submit();}void Setup(){buffer.BeginSample(bufferName);ExecuteBuffer();context.SetupCameraProperties(camera);}void DrawVisibleGeometry(){context.DrawSkybox(camera);}void Submit(){buffer.EndSample(bufferName);ExecuteBuffer();context.Submit();}void ExecuteBuffer(){context.ExecuteCommandBuffer(buffer);buffer.Clear();}
}
2.4 Clearing the Render Target
渲染结果最终体现在 Render Target 上,为了避免上一帧(也可能是上上帧)的图像对当前帧产生影响,每次渲染,我们都要清理 Render Target,通过调用 CommandBuffer.ClearRenderTarget
完成。
ClearRenderTarget 会自动封装一个以 CommandBuffer 的名字的采样,因此在 FrameDebugger 中会出现嵌套
先执行 Clear,再启用我们的 Sample ,可以避免。
如果执行 Clear 时,还没有执行 SetupCameraProperties,Unity 会用 Hidden/InternalClear Shader 来渲染一个矩形的方式来“清理”(Draw GL),这种方式相对很慢。我们可以先执行 SetupCameraProperties,再 Clear,这样 Unity 会通过API层的 Clear 调用来完成清理,效率高得多。
现在,代码是这样
void Setup(){context.SetupCameraProperties(camera);buffer.ClearRenderTarget(true, true, Color.clear);buffer.BeginSample(bufferName);ExecuteBuffer();}
2.5 Culling
根据当前摄像机,裁剪出所有在视锥体内的 Renderer Component。
-
camera.TryGetCullingParameters(out ScriptableCullingParameters p)
-
CullingResults cullingResults = context.Cull(ref p);
定义一个 Cull 方法实现裁剪,如果成功,则获取裁剪结果:
CullingResults cullingResults;bool Cull(){if(camera.TryGetCullingParameters(out ScriptableCullingParameters p)){cullingResults = context.Cull(ref p);return true;}return false;}
在渲染中,执行裁剪,如果失败,则中止渲染,直接返回。
CullingResults cullingResults;bool Cull(){if(camera.TryGetCullingParameters(out ScriptableCullingParameters p)){cullingResults = context.Cull(ref p);return true;}return false;}
2.6 Draw Geometry 分别绘制不透明和透明物体
得到裁剪结果后,就可以通过 context.DrawRenderers
来渲染他们了。在调用该接口前,需要进行设置:
-
DrawingSettings
-
通过 ShaderTagId 指定绘制 shader 的哪个 pass 目前我们只绘制 Pass SRPDefaultUnlit
-
通过 SortingSetings 指定如何排序 指定排序策略为 SortingCriteria.CommonOpaque,从前到后的顺序
-
-
FilteringSettings 指示渲染哪些队列 通过为 filteringSettings 传入参数 RenderQueueRange,指示渲染哪些内容。
代码是这样的:
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);}
现在渲染结果是这样的:
-
Editor Rendering 编辑器渲染
现在我们的 RP 正确的渲染了 unlit 的材质,但是对于 standard 材质不能正确渲染。在编辑器中,我们要以特殊方式将无法渲染的材质渲染出来,并告诉用户出错了,这对用户体验很重要。
3.1 Drawing Legacy Shaders
如果项目过程中切到我们的 RP,场景中可能会使用一些我们不支持的 Shader。把不支持的 Shader 记录下来,并在最后用特殊的材质将他们渲染出来,以向用户提示这些材质需要更换。
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);}
错误材质
通过调用 new Material(Shader.Find("Hidden/InternalErrorShader")); 来创建一个材质,用来渲染材质错误的情况。
定义 DrawUnsupportedShaders 接口来渲染他们:
void DrawUnsupportedShaders(){if(errorMaterial == null){errorMaterial = new Material(Shader.Find("Hidden/InternalErrorShader"));}var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera))// 指示错误的材质{ overrideMaterial = errorMaterial };for (int i = 1; i < legacyShaderTagIds.Length; i++){drawingSettings.SetShaderPassName(i, legacyShaderTagIds[i]);}var filteringSettings = FilteringSettings.defaultValue;context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);}
不支持的 Standard Shader 将会以紫色显示:
3.2 Partial Class
渲染错误材质,仅在编辑器下是有用的,在 Release 时是不需要被渲染的。得益于C# 的 partial 机制,可以让我们将一个类的定义分散到多个文件中。因此我们把这部分代码定义到 CameraRender.Editor.cs 中,同时用 UNITY_EDITOR 宏让这部分代码仅在编辑器时有效:
3.3 Draw Gizmos
可以通过 UnityEditor.Handles.ShouldRenderGizmos 判断是否需要渲染 Gizmos,如果需要,则调用 context.DrawGizmos
-
第一个参数是摄像机
-
第二个参数指定要渲染的 Gizmos 的子集:
-
image effect 阶段之前
-
image effect 阶段之后
-
目前我们还没有 image effect,因此直接渲染两者:
partial void DrawGizmos();
#if UNITY_EDITORpartial void DrawGizmos(){if (Handles.ShouldRenderGizmos()){context.DrawGizmos(camera, GizmoSubset.PreImageEffects);context.DrawGizmos(camera, GizmoSubset.PostImageEffects);}}
#endif
3.4 Draw Unity UI
渲染 Scene 窗口 中的 UI
如果当前摄像机是 CameraType.SceneViewn 类型,通过调用 ScriptableRenderContext.EmitWorldGeometryForSceneView(camera) 提交UI的渲染。
partial void PrepareForSceneWindow();
#if UNITY_EDITOR partial void PrepareForSceneWindow(){if (camera.cameraType == CameraType.SceneView){ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);}}
#endif
在绘制中调用该接口:
public void Render(ScriptableRenderContext context, Camera camera)
{...PrepareForSceneWindow();if (!Cull())return;...
}
-
多摄像机
场景中可能有多个摄像机,需要我们正确的处理
4.1 两个摄像机
每个摄像机都有Depth
属性,默认著摄像机的 depth =-1,多个摄像机以 depth 升序进行渲染。
我们之前为 CommandBuffer 设置的剖析时的名字,是用的固定的字符串。当有多个摄像机时,由于名字一致,导致无法将两个摄像机的渲染区分开来。
因此我们需要根据摄像机的名字来设置剖析的名字
同时,在调用 BeginSample/EndSample 时,需要指定同样的名字,否则编辑器会报 BeginSample and EndSample counts 不匹配的错误信息。
由于获取摄像机名字,会导致内存分配,因此将其包裹在 "EditorOnly" 中,以做区分
#if UNITY_EDITOR string SampleName { get; set; }partial void PrepareBuffer(){// 由于获取摄像机名字,会导致内存分配,因此将其包裹在 "EditorOnly" 中,以做区分Profiler.BeginSample("Editor Only");buffer.name = SampleName = camera.name;Profiler.EndSample();}
#elseconst string SampleName = bufferName;
#endif
void Setup(){context.SetupCameraProperties(camera);buffer.ClearRenderTarget(true, true, Color.clear);buffer.BeginSample(SampleName);ExecuteBuffer();}...void Submit(){buffer.EndSample(SampleName);ExecuteBuffer();context.Submit();}
4.2 Layers
可以在编辑器中设置对象的 Layer,并设置摄像机的 Culling Mask,使摄像机只能看到我们想让它看到的东西。
4.3 Clear Flags
我们可以通过配置后续摄像机的 Clear Flags 来合并两个摄像机的渲染结果。
camera.clearFlags属性返回枚举类型 CameraClearFlags
。然后在 ClearRenderTarget 时,适当的使用这个属性。
如果使用摄像机颜色进行清空,也要正确使用摄像机颜色
void Setup(){context.SetupCameraProperties(camera);buffer.ClearRenderTarget(true, true, Color.clear);buffer.BeginSample(SampleName);ExecuteBuffer();}...void Submit(){buffer.EndSample(SampleName);ExecuteBuffer();context.Submit();}
如果修改了 Camera.ViewRect,则 Clear 将会利用 Hidden/InternalClear shader 进行清屏,效率低。