https://catlikecoding.com/unity/tutorials/custom-srp/
介绍
为了进行渲染,Unity会决定哪些图形会被绘制,会被绘制在何处,在什么时候绘制,以及用什么设置来绘制。这很复杂,包含许多效果,如灯光、阴影、透明效果、基于图片的效果、体积效果等。Unity除自带的内置渲染管线外,还提供了SRP,可让用户自定义渲染管线,官方据此给出了两种预制管线,URP和HDRP。这里会模仿URP制定一个渲染管线。
渲染管线
新建项目后先将颜色空间修改为Linear
(Edit/Project Settings/Player/Other Settings),这是我们之后会使用的颜色空间。
管线资产
首先模仿URP的结构创建项目,新建一个Runtime文件夹,并新建一个CustomRenderPipelineAsset
类。RP资产的目的是给让Unity得到一个pipeline对象实例,负责渲染。在类中我们需要重写抽象方法CreatePipeline
,用于得到我们的渲染管线对象:
using UnityEngine;
using UnityEngine.Rendering;
[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset
{
protected override RenderPipeline CreatePipeline()
{
return null;
}
}
使用该脚本创建一个pipeline asset后,拖到项目设置的Graphic对应位置即可:
渲染管线实例
接着创建一个CustomRenderPipeline
类,这是CreatePipeline
会返回的实例类型。由于继承自RenderPipeline
,我们需要重写Render
方法:
using UnityEngine;
using UnityEngine.Rendering;
public class CustomRenderPipeline : RenderPipeline
{
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
}
}
同时修改CreatePipeline
返回值:
protected override RenderPipeline CreatePipeline()
{
return new CustomRenderPipeline();
}
渲染
Unity每帧都会在RP实例上调用Render
方法,它有一个context结构体参数,其提供与原生引擎之间的连接,我们可以用来进行渲染,还有一个摄像机队列参数,我们可以按顺序渲染多个摄像机。
摄像机渲染器
每个摄像机独立渲染,因此这里建立一个CameraRenderer
类,用于渲染单个摄像机:
using UnityEngine;
using UnityEngine.Rendering;
public class CameraRenderer
{
public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;
}
接着在CustomRenderPipeline
中创建一个CameraRenderer
实例,在Render
方法中循环进行摄像机的渲染:
CameraRenderer renderer = new CameraRenderer();
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
foreach (Camera camera in cameras)
{
renderer.Render(context, camera, useDynamicBatching, useGPUInstancing, shadowSettings);
}
}
绘制天空盒
CameraRenderer.Render
的任务是绘制所有当前摄像机能看见的物体,我们将其归类到DrawVisibleGeometry
方法中,这里首先绘制一个天空盒:
public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;
DrawVisibleGeometry();
}
void DrawVisibleGeometry()
{
context.DrawSkybox(camera);
}
这样子还不能显示出天空盒,因为我们发布给context的命令是缓冲的,我们需要提交队列任务来执行,调用Submit
方法:
public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;
DrawVisibleGeometry();
Submit();
}
void Submit()
{
context.Submit();
}
这样的话就可以绘制天空盒了。如果我们打开frame debugger的话,会发现其名字是Camera.RenderSkybox
,其子列表下有一个Draw Mesh
命令。
此时摄像机的朝向还不能影响天空盒会被如何绘制。我们将camera
传递到DrawSkyBox
,但只决定了天空和是否被绘制,即清楚标志。为了正确的绘制天空盒,我们需要设置view-projection矩阵。我们通过SetUpCameraPeoperties
方法将矩阵传递给context:
public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;
Setup();
DrawVisibleGeometry();
Submit();
}
void Setup()
{
context.SetupCameraProperties(camera);
}
命令缓冲
context会在我们提交时才会进行真正的渲染,在此之前,我们可以进行相关配置,添加一些待执行的命令。一些任务(如绘制天空和)可以直接通过相应的方法发布,但其它的命令需要通过单独的命令缓冲发布。
这里首先创建一个命令缓冲对象,并赋予一个名字,以便在frame debugger中看到:
const string bufferName = "Render Camera";
CommandBuffer buffer = new CommandBuffer
{
name = bufferName
};
我们使用命令缓冲来注入profiler样本,这样就可以同时在profiler和frame debugger中看到:
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();
}
打开frame debugger就会看到Camera.RenderSkyBox
内嵌在Render Camera
中。
清除渲染目标
我们要渲染的东西会渲染到摄像机的渲染目标中,可以是帧缓冲也可以是渲染纹理。如果我们要渲染到的目标上有之前绘制的东西,我们当前要绘制的东西就会被干扰,因此我们需要去除旧的渲染内容,使用ClearRenderTarget
方法。该方法要求至少三个参数,前两个标识是否清除深度和颜色数据,第三个是要用什么颜色来清除:
void Setup()
{
buffer.BeginSample(bufferName);
buffer.ClearRenderTarget(true, true, Color.clear);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
此时在frame debugger中就会看到一个内嵌的Draw GL
,即清除这一行为的入口点,不过它内嵌在一个额外的Render Camera
中,因为我们在一个命令缓冲的样本下清除的。我们可以先清除再开始我们的样本:
void Setup()
{
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(bufferName);
ExecuteBuffer();
}
Draw GL
本身是使用一个Hidden/InternalClear
着色器来绘制一个屏幕四边形来完成的,其会写入到一个渲染目标中,这样的效率并不太高。之所以如此是因为我们是在设置摄像机前进行清除的,如果交换顺序就能高效完成:
void Setup()
{
context.SetupCameraProperties(camera);
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(bufferName);
ExecuteBuffer();
}
此时查看frame debugger会发现Clear(color+Z+stencil)
,即清除颜色和深度缓冲。
剔除
为保证效率,我们只绘制摄像机看的到的物体。在绘制前,我们会剔除掉所有在视锥体外的物体。
我们通过跟踪多个摄像机的设置和矩阵来找到要剔除的物体,这里使用ScriptableCullingParameters
结构体,我们可以使用摄像机的TryGetCullingParameters
方法来填充该结构体:
bool Cull()
{
if (camera.TryGetCullingParameters(out ScriptableCullingParameters p))
{
return true;
}
return false;
}
在Setup
前调用:
public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;
if (!Cull())
{
return;
}
Setup();
DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
Submit();
}
Cull
方法中,我们调用context的Cull方法:
bool Cull()
{
if (camera.TryGetCullingParameters(out ScriptableCullingParameters p))
{
cullingResults = context.Cull(ref p);
return true;
}
return false;
}
绘制几何体
我们调用context的DrawRenderers
方法来渲染一些东西,它将剔除的结果作为参数,告诉其要使用那个渲染器。除此之外,我们还要提供drawing settings
和filtering settings
,对应DrawingSettings
和FilteringSettings
结构体:
void DrawVisibleGeometry()
{
var drawingSettings = new DrawingSettings();
var filteringSettings = new FilteringSettings();
context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
context.DrawSkybox(camera);
}
仅仅如此还不能看见要绘制的集合体,因为我们还需要指示可以使用哪些shader pass。这里使用默认的:
static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");
该参数作为DrawingSettings
结构体构造函数的参数,同时提供一个SortingSettings
结构体变量,传入一个摄像机参数,其可以决定是使用正交还是基于距离的排序:
void DrawVisibleGeometry()
{
var sortingSettings = new SortingSettings(camera);
var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);
...
}
同时我们指示使用什么渲染队列:
var filteringSettings = new FilteringSettings(RenderQueueRange.all);
我们可以创建多个物体来测试,部分物体应用带透明通道的纹理。打开frame debugger我们可以看到这些Draw Mesh
命令内嵌在RenderLoop.Draw
中。仔细看渲染顺序,会发现透明物体的显示有问题,这是因为目前场景中物体的渲染顺序是随机的,如果想正确显示透明物体,我们需要在所有不透明物体之后渲染。我们可以在sortingSettings的criteria属性中进行设置:
var sortingSettings = new SortingSettings(camera)
{
criteria = SortingCriteria.CommonOpaque
};
此时会发现物体或多或少地会根据从前往后的顺序绘制,但也只是针对不透明物体,透明物体仍不会正常显示。
单独绘制不透明和透明几何体
观察frame debugger的话,会发现透明物体其实绘制了,但天空盒会进行覆盖,所有不在不透明物体前的片段都会被剔除,这是因为透明物体的shader不会写入深度缓冲。因此我们手动先绘制不透明物体,然后是透明物体。
首先将初始的渲染队列调整为opaque
:
var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
在绘制天空盒后再次调用DrawRenderers
,在此之前调整渲染队列为transparent
,同时调整渲染顺序,从后往前渲染:
context.DrawSkybox(camera);
sortingSettings.criteria = SortingCriteria.CommonTransparent;
drawingSettings.sortingSettings = sortingSettings;
filteringSettings.renderQueueRange = RenderQueueRange.transparent;
context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
编辑器渲染
绘制Legacy Shader
目前的渲染管线只支持unlit shader pass。为了方便进行管线的转换,我们将Legacy Shader包含在内:
static ShaderTagId[] legacyShaderTagIds = {
new ShaderTagId("Always"),
new ShaderTagId("ForwardBase"),
new ShaderTagId("PrepassBase"),
new ShaderTagId("Vertex"),
new ShaderTagId("VertexLMRGBM"),
new ShaderTagId("VertexLM")
};
我们在绘制完所有的可见几何体后绘制所有的不支持的shader,并且只使用默认的渲染设置:
public void Render(ScriptableRenderContext context, Camera camera)
{
...
Setup();
DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
DrawUnsupportedShaders();
Submit();
}
public 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:
for (int i = 1; i < legacyShaderTagIds.Length; i++)
{
drawingSettings.SetShaderPassName(i, legacyShaderTagIds[i]);
}
错误警示材质
当物体上的材质错误时,会显示一个警示颜色:
static Material errorMaterial;
...
public void DrawUnsupportedShaders()
{
if (errorMaterial == null)
{
errorMaterial = new Material(Shader.Find("Hidden/InternalErrorShader"));
}
var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera))
{
overrideMaterial = errorMaterial
};
Partial类
绘制非法物体只在编辑器中进行,因此我们将这些代码放到一个partial类中,创建一个CameraRenderer.Editor
类:
using UnityEngine;
using UnityEngine.Rendering;
partial class CameraRenderer
{
partial void DrawUnsupportedShaders();
#if UNITY_EDITOR
static ShaderTagId[] legacyShaderTagIds = {...};
static Material errorMaterial;
partial void DrawUnsupportedShaders()
{...}
#endif
}
绘制Gizmo
我们通过调用UnityEditor.Handles.ShouldRenderGizmos
来判断是否绘制gizmo。我们调用DrawGizmos
来绘制Gizmo,第一个参数为摄像机,第二个参数为绘制哪个gizmo子集:
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
在最后绘制:
public void Render(ScriptableRenderContext context, Camera camera)
{
...
Setup();
DrawVisibleGeometry();
DrawUnsupportedShaders();
DrawGizmos();
Submit();
}
绘制Unity UI
如果此时我们创建一个UI组件,会发现它只在Game窗口显示,不在Scene窗口显示。在frame debugger中会发现其被单独绘制,不由我们自己的RP绘制。
原因是此时画布组件的Render Mode
被设置为Screen Space - Overlay
,如果我们将其改为Screen Space Camera
,并使用主摄像机为Render Camera
,即可将其作为透明物体的一部分。
UI在设置为World Space
模式时可以在场景窗口中显示。为了渲染UI,我们需要将其添加到世界物体中,通过调用ScriptableRenderContext.EmitWorldGeometryForSceneView
,将摄像机作为参数:
partial void PrepareForSceneWindow();
#if UNITY_EDITOR
partial void PrepareForSceneWindow()
{
if (camera.cameraType == CameraType.SceneView)
{
ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
}
}
#endif
在剔除操作前将其加入到场景的几何体中:
PrepareForSceneWindow();
if (!Cull(shadowSettings.maxDistance))
{
return;
}
多个摄像机
两个摄像机
每个摄像机有一个深度值,默认的主摄像机的深度值为-1,它们按照深度值逐渐递增的顺序渲染。如果我们复制main camera,命名为Secondary Camera,将其深度值设为0,接着就会发现frame debugger中两个摄像机在同一个采样范围内,即场景渲染了两次。为了让每个摄像机有自己的采样范围,我们创建一个PrepareBuffer
方法,其缓冲名为摄像机名:
partial void PrepareBuffer();
#if UNITY_EDITOR
...
partial void PrepareBuffer()
{
Profiler.BeginSample("Editor Only");
buffer.name = SampleName = camera.name;
Profiler.EndSample();
}
#endif
处理变化缓冲名的问题
使用上述的方法后,的确每个摄像机都有了自己的采样层级,不过进入Unity的Play模式的话,Unity会报错,警告我们BeginSample
和EndSample
的数量必须匹配。
为了解决这一问题,我们添加一个SampleName
的字符串属性,在编辑器模式下我们在其设置为PrepareBuffer
中缓冲的名字,其余模式下简单的设为一个常量字符串Render Camera
:
#if UNITY_EDITOR
string SampleName { get; set; }
...
partial void PrepareBuffer()
{
buffer.name = SampleName = camera.name;
}
#else
const string SampleName = bufferName;
#endif
使用SampleName
在Setup
和Submit
中采样:
void Setup()
{
context.SetupCameraProperties(camera);
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(SampleName);
ExecuteBuffer();
}
void Submit()
{
buffer.EndSample(SampleName);
ExecuteBuffer();
context.Submit();
}
我们可以在profiler中查看编辑模式和其他模式的区别。按照GC Alloc
排序的话,会发现在编辑模式下GC.Alloc总共分配了100字节,Build模式下则没有这个100字节。
我们可以加入一个新的样本Editor Only
来标识清楚:
using UnityEngine.Profiling;
#if UNITY_EDITOR
string SampleName { get; set; }
...
partial void PrepareBuffer()
{
Profiler.BeginSample("Editor Only");
buffer.name = SampleName = camera.name;
Profiler.EndSample();
}
#else
const string SampleName = bufferName;
#endif
清除标志
我们可以将两个摄像机的结果组合在一起,将清除标志与另一个进行比较:
void Setup()
{
context.SetupCameraProperties(camera);
CameraClearFlags flags = camera.clearFlags;
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(SampleName);
ExecuteBuffer();
}
CameraClearFlags
枚举定义了四个值:Skybox,Color,Depth和Nothing。深度缓冲除了最后一个都要被清除:
buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, true, Color.clear);
只有标志被设为Color
时我们才清除颜色缓冲:
buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, flags == CameraClearFlags.Color,
Color.clear);
清除的颜色我们使用线性空间的摄像机背景颜色:
buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, flags == CameraClearFlags.Color,
flags == CameraClearFlags.Color ? camera.backgroundColor.linear : Color.clear);
由于Main Camera是首先被渲染的,因此它的Clear Flags应为Skybox或Color。Secondary Camera的Clear Flags决定了两个摄像机会如何结合。