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_EDITOR
partial 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();
}
#else
const 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 进行清屏,效率低。