学习链接:Custom Render Pipeline (catlikecoding.com)
使用Unity版本:Unity 2022.3.5f1
1.A new Render Pipeline
1.1Project Setup
创建一个默认的3D项目,项目打开后可以到默认的包管理器删掉所有不需要的包,我们只使用Unity UI这个包来绘制UI,因此可以保留这个包
另外,在项目设置里将颜色空间设置为线性空间
接着在场景中放置一些物体,分别使用 standard, unlit opaque and transparent 材质. The Unlit/Transparent shader only works with a texture, so here is a UV sphere map for that.
红色的立方体使用 Standard shader, 绿色和黄色的立方体使用Unlit/Color shader. 蓝色的球体使用 Standard shader with Rendering Mode set to Transparent, 白色的球体使用 Unlit/Transparent shader.
1.2Pipeline Asset
到目前位置,Unity还是使用的默认的渲染管线。我们首先需要创建一个可编程渲染管线资产并使用它来替换默认渲染管线。我们将使用和Unity通用渲染管线(URP)相似的文件结构。创建名为Custom RP资源文件夹和一个名为Runtime的子文件夹。在这里放入一个新的C#脚本名字为CustomRenderPipelineAsset。
这个资产类型必须继承自RenderPiplineAsset,它在UnityEngine.Rendering命名空间下。
using System.Collections;
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;
}
}
渲染管线资产的主要目的是为Unity提供一种获取负责渲染的管道对象实例的方法。资产本身只是一个句柄和一个存储设置的地方。我们暂时还没有任何设置,所以我们要做的只是给予Unity一个获取我们渲染管线实例的方式。我们通过重写抽象方法CreatePipeline返回一个RenderPipeline的实例来实现。但是我们现在还没有定义一个自定义渲染管线,所以我们先返回空(null)。
CreatePipeline方法是由protected限制符定义的,这意味着类本身和继承自RenderPipelineAsset的类才可以使用此方法。
我们需要为我们的项目添加一个这个类的资产。为了实现这个功能我们为CustomRenderPipelineAsset添加一个CreateAssetMenu的特性。
这将在Asset/Create按钮下添加一个入口。为了保持整洁让我们把它放到Rendering子按钮下。我们通过menuName属性设置为Rendering/Custom Render Pipeline来实现。此属性可以直接设置在属性类型后面的圆括号内。
使用这个新按钮来添加资产到项目中,然后到Graphics 项目设置中,在Scriptable Render Pipeline Settings面板选择设置这个资产。
替换默认的渲染管线会造成一些事情发生变化。首先在graphics settings信息面板中的一些选项将消失。第二,我们禁用了默认的渲染管线,但没有提供有效的替换,因此不会再呈现任何内容。Game视图,场景视图,材质预览将不再起作用。如果你打开frame debugger -Window / Analysis / Frame Debugger并开启它,你会看到没有任何东西绘制到game窗口。
1.3Render Pipeline Instance
创建一个CustomRenderPipeline类,把它放入和CustomRenderPipelineAsset相同的文件夹。这个类必须继承自RenderPipeline,它将被使用在CustomRenderPipelineAsset中创建并返回的渲染管线的实例。
RenderPipeline定义了一个受保护的抽象方法Render,我们必须重写它来实现一个具体的管线。它有两个参数:一个ScriptableRenderContext和一个Camera数组。暂时将方法置空。
using UnityEngine;
using UnityEngine.Rendering;
public class CustomRenderPipeline : RenderPipeline
{
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
}
}
使CustomRenderPipelineAsset.CreatePipeline函数返回一个CustomeRenderPipeline的实例。这将为我们提供一个有效且功能强大的管线,尽管它还没有呈现任何内容。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset
{
protected override RenderPipeline CreatePipeline()
{
return new CustomRenderPipeline();
}
}
2.Rendering
在渲染管线实例中Unity每帧都会调用Render函数。这个函数传递了一个上下文结构来提供一个和原生引擎的连接,我们可以使用它来进行渲染。因为场景中可能有多个激活的摄像机,所以这个函数同时也传递一组摄像机的信息。根据渲染的顺序对摄像机进行渲染是渲染管线的责任。
2.1Camera Renderer
每一个摄像机的渲染都是独立的。因此与其让CustomRenderPipeline渲染所有摄像机,不如创建一个全新的类来专门负责单独摄像机的渲染。这个类的名字叫做CameraRenderer,给它创建一个公开Render函数包括context和camera两个参数。为了方便起见让我们保存这些参数到字段中。
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创建的时候创建一个CameraRenderer的实例,然后在一个循环中使用它来渲染所以摄像机。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
public class CustomRenderPipeline : RenderPipeline
{
CameraRenderer renderer = new CameraRenderer();
protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{
}
protected override void Render(ScriptableRenderContext context, List<Camera> cameras)
{
for (int i = 0; i < cameras.Count; i++)
{
renderer.Render(context, cameras[i]);
}
}
}
我们的摄像机渲染逻辑大致相当于可编程渲染管线中的通用渲染管线。这种方式使得将来每个摄像机支持不同的渲染方式变得更加简单,例如第一人称视角,3D地图覆盖,或者是正向渲染和延迟渲染。但是现在我们将使用同样的方式渲染所有摄像机。
2.2Drawing the Skybox
CameraRenderer.Render的工作是绘制所有相机可以看到的几何体。为了使代码清晰,我们在单独的一个函数DrawVisibleGeometry内执行此逻辑。我们先通过使用context的DrawSkybox函数,并传递camera的作为参数来绘制默认的天空盒。
但这还不能使天空盒显示。因为这些我们发布到context的命令只是一些缓冲。我们必须使用context的Submit方法来提交这些排队等待执行指令。让我们在执行完DrawVisibleGemometry后,在一个独立的Submit函数执行提交。
public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;
DrawVisibleGeometry();
Submit();
}
void Submit()
{
context.Submit();
}
void DrawVisibleGeometry()
{
context.DrawSkybox(camera);
}
天空盒最终在game和scene视图中都显示了。当你激活frame debugger的时候你也可以看到一个天空盒的入口。它以列表的形式的呈现在Camera.RenderSkybox中,在它下面有一个唯一的选项DrawMesh,这代表了实际发生的draw call。这个列表对应游戏窗口的渲染情况。FrameDebgger不会提供其他窗口的渲染情况。
请注意,当前摄像机的方向并不会影响天空盒的绘制结果。我们传递camera给DrawSkybox函数,只用于通过控制摄像机的清除标志来确认天空盒是否应该被绘制。
为了正确的渲染天空盒-整个场景-我们需要设置视图-投影矩阵。这个转换矩阵包含了摄像机的位置和方向-视图矩阵-摄像机的正交和透视投影-投影矩阵。它被熟知是作为着色器中的绘制几何体的时候使用的着色器属性之一untiy_MatrixVP。你可以在frame debugger中选中一个drawcall,在ShaderProperties部分观察这个矩阵。
目前unity_MatrixVP矩阵总是相同的。我们需要通过SetupCameraProperties方法将摄像机的属性应用于context上下文。它设置了矩阵和其他一些摄像机的属性。我们在DrawVisibleGeometry函数之前的单独函数Setup中执行这些操作。
执行完这一步以后天空盒就正确对齐了,并且会随着摄像机的朝向而改变
2.3Command Buffers
上下文将延迟渲染直到我们提交它。在这之前,我们将对其进行配置并添加命令以供后面执行。一些任务-像绘制天空盒-可以通过专用方法发布,但是其他的指令必须通过单独的命令缓冲区间接发布。我们需要这样的缓冲区来绘制场景中的其他几何体。
为了获得一个缓冲区,我们必须创建一个新的CommandBuffer实例对象。我们只需要一个缓冲区,所以我们在默认情况下为CameraRender创建一个缓冲区,并将它的引用存储在字段中。同时我们给缓冲区一个名字,这样我们就可以在frame debugger中识别它。那就叫做 Render Camera吧。
const string bufferName = "Render Camera";
CommandBuffer buffer = new CommandBuffer {
name = bufferName
};
对象的实例化语法如何工作?
就像如果我们代码为buffer.name = bufferName;在构造方法执行完毕后作为一段分离的部分。但是当创建一个新对象的时候,也可以添加一个代码块到构造函数中执行。然后你可以设置对象的字段和属性而不需要显示的调用对象的引用。这明确的规定了实例只有在设置完这些属性和字段之后才可以被使用。除此之外它使只使用一个语句进行初始化变为可能-例如,字段的初始化,我们在这里使用它,从而不在需要有许多参数变量的构造函数。
请注意我们省略了空参数列表的构造函数的执行,这种语法也是被允许的。
我们可以使用命令缓冲区注入到采样分析器,他将同时显示在profiler中和framedebugger中。这是通过在适当的点调用BeginSample和EndSample来完成的,在我们的案例中它们在Setup函数和Submit函数中。两个方法都必须提供相同的采样名称,我们将使用缓冲区的名字。
void Setup () {
buffer.BeginSample(bufferName);
context.SetupCameraProperties(camera);
}
void Submit () {
buffer.EndSample(bufferName);
context.Submit();
}
为了执行缓冲区,我们将缓冲区作为参数执行context的ExecuteCommandBuffer方法。它从缓冲区复制命令,但并没有清除缓冲区,如果我们想重用它,我们必须显示的清除它。因为执行和清除总是一起完成的,所以添加一个同时执行和清除的方法会很方便。
Camera.RenderSkyBox采样嵌套在RenderCamera内部。
2.4Clearing the Render Target
无论我们绘制什么最终都会渲染到摄像机的渲染目标中,默认情况下是帧缓冲区,但也可以是渲染纹理(RT)。之前被绘制到目标上的东西依然存在,这可能会干扰我们现在渲染的图像。为了保证正确的渲染,我们必须清除渲染目标以去除其旧内容。这是通过调用命令缓冲区上的ClearRenderTarget来完成的,它在Setup方法中执行。
CommandBuffer.ClearRenderTarget函数至少需要三个参数。前两个参数表示是否清除深度缓冲和颜色缓冲数据,我们设置为true。第三个参数为清除后的颜色(可以理解为替换的颜色),我们选择Color.clear。
void Setup () {
buffer.BeginSample(bufferName);
buffer.ClearRenderTarget(true, true, Color.clear);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
FrameDebugger现在显示了一个DrawGL入口用于清理行为,嵌套在RenderCamera的内部。之所以发生这种情况是因为ClearRenderTarget在示例中放置在开始采样之后,会被程序认为是开始采样之后的操作。我们可以在开始采样前,清除多余的嵌套内容。这样做的结果是两个相邻的"Render Camera"采样将可以合并。
void Setup()
{
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(bufferName);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
Draw GL入口表示使用Hidden/InternalClear着色器来绘制全屏四边形,并写入渲染目标,但这不是清除目标的最有效方法。之所以使用这种方法,是因为我们在设置相机属性之前先进行了清理。如果我们交换这两个步骤的顺序,便可以更快速清除。
这里DrawGL是通过着色器渲染之后写入之后渲染目标,而clear方法是直接修改三个缓冲的值,很明显后者效率要更好一些,现在我们看到Clear(color+Z+stencil),这表示颜色,深度和模板缓冲区都被清除了。
2.5Culling
现在可以看见天空盒了,但是仍然看不见物体。我们只需要渲染在摄像机可视范围内的可视对象,而不是所有的对象。我们首先从场景中所有具有渲染组件的对象开始,然后剔除掉那些不在摄像机视椎体之内的对象。
要想弄清楚哪些是可以被剔除的,我们需要通过使用ScriptableCullingParameters结构体来跟踪多个摄像机的设置和矩阵。我们可以调用camera的TryGetCullingParameters函数来代替自己填充结构体的数据。这个函数将返回这些参数是否可以成功获取并返回,对于错误的摄像机参数它有可能返回失败。为了获取这个参数数据我们必须将它作为一个输出参数,通过在变量前面添加out关键字。我们在一个独立的Cull函数内执行这些操作,它将返回成功或者失败。
bool Cull () {
ScriptableCullingParameters p
if (camera.TryGetCullingParameters(out p)) {
return true;
}
return false;
}
为什么我们需要写out关键字?
当一个结构体参数被定义为一个输出参数的时候它的行为像是一个对象引用,指向了该参数所在的内存堆栈上的位置。当方法改变这个参数的时候它将影响这个值对象,而不是创建一个拷贝。
out关键字告诉我们该方法负责正确设置参数,替换先前的值。
Try-get方法是一个普遍的方式来判断执行的成功或失败,并返回一个结果。
当用作输出参数时,可以在参数列表中内联变量声明,所以让我们这样做吧。
bool Cull()
{
//ScriptableCullingParameters p
if (camera.TryGetCullingParameters(out ScriptableCullingParameters p))
{
return true;
}
return false;
}
在Render函数中Setup函数之前执行Cull函数,如果执行函数失败将返回。
public void Render(ScriptableRenderContext context, Camera camera)
{
this.context = context;
this.camera = camera;
if (!Cull())
{
return;
}
Setup();
DrawVisibleGeometry();
Submit();
}
实际的剔除操作是通过执行context的Cull函数来完成的,它会生成一个CullingResults的结构体。它将在Cull函数中执行如果成功会将结构保存在字段中。在这种情况下,我们必须将剔除参数作为引用参数传递,方法是在它前面写ref关键字。
CullingResults cullingResults;
…
bool Cull () {
if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
cullingResults = context.Cull(ref p);
return true;
}
return false;
}
为什么我们必须使用ref?
ref关键字的执行和out很像,不同的是方法中不需要为它初始化。谁调用该方法就需要提取对参数进行初始化。所有它一点可比被作为输入,同时也可以作为输出。
设个例子中ref被使用作为一个优化,避免了传参过程中ScriptableCullingParameters结构体的复制,这个结构体确实很大。它是一个结构体来代替一个对象时另一个优化,为了减少内存的分配。
2.6Drawing Geometry
一旦我们知道哪些物体可见,我们就可以继续渲染这些物体了。渲染这些物体需要使用剔除的结果作为参数,执行context的DrawRenderers函数来告诉管线哪些物体可以被渲染。除此之外,我们还必须提供绘制设置参数和过滤设置参数。它们都是结构体-DrawingSettings和FilteringSettings-我们将首先使用它们的默认构造函数,它们都需要通过引用进行传递。在DrawVisibleGeometry函数中绘制天空盒之前执行这些逻辑。
void DrawVisibleGeometry()
{
var drawingSettings = new DrawingSettings();
var filteringSettings = new FilteringSettings();
context.DrawRenderers(
cullingResults, ref drawingSettings, ref filteringSettings
);
context.DrawSkybox(camera);
}
我们还是看不到任何东西,因为我们需要表明哪些类型的着色器过程(shader passes)是被允许渲染的。由于我们本节教程只支持无光照的着色器,因此我们需要获取SRPDefaultUnlit的着色器标记ID。我们可以只获取一次并将它缓存到静态字段中。
static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");
将它作为DrawingSettings构造函数的第一个参数,并创建一个SortingSettings作为第二个参数。把camera传给sortingsetting的构造函数,因为用camera来确定是使用正交排序还是使用基于距离的排序。
除此之外,我们还需要指定哪些渲染队列被允许渲染。传递RenderQueueRange.all到FilteringSettings的构造函数,以便我们可以允许所有内容进行渲染。
最终代码:
void DrawVisibleGeometry()
{
//决定物体绘制顺序是正交排序还是基于深度排序的配置
var sortingSettings = new SortingSettings(camera)
{
criteria = SortingCriteria.CommonOpaque
};
//决定摄像机支持的Shader Pass和绘制顺序等的配置
var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);
//决定过滤哪些Visible Objects的配置,包括支持的RenderQueue等
var filteringSettings = new FilteringSettings(RenderQueueRange.all);
//渲染CullingResults内的VisibleObjects
context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
//添加“绘制天空盒”指令,DrawSkybox为ScriptableRenderContext下已有函数,这里就体现了为什么说Unity已经帮我们封装好了很多我们要用到的函数,SPR的画笔~
context.DrawSkybox(camera);
}
绘制结果:
可以看到这个透明的球的效果还有一些奇怪
分析Frame Debugger又增加了一个RenderLoop.Draw,可以知道每调用一次context.DrawRenderers就是一次renderloop
这里如果调用两次绘制的话,会自动合并到RenderLoop.Draw里
如果在中间加入一次绘制天空盒的调用,就会产生两个renderLoop.draw
接下俩就是要正确绘制透明物体。
2.7Drawing Opaque and Transparent Geometry Separately
通过Frame Debugger 可以知道目前的渲染顺序是先绘制所有unlit物体然后绘制天空盒,但是unlit物体中的透明物体渲染的时候是不会写入深度的,因此天空盒会将透明物体覆盖。所以,正确的绘制顺序应该是先绘制不透明物体,然后绘制天空盒,最后绘制透明物体。
我们可以通过切换到RenderQueueRange.opaque来排除透明物体。
然后在绘制天空盒之后,我们再次调用DrawRenderers。但是在这之前先改变渲染范围为RenderQueueRange.transparent。也要修改排序标准为SortingCriteria.CommonTransparent然后设置给DrawingSettings。这将反转透明对象的绘制顺序。
这里修改了渲染顺序为从后往前,因为这样才能体现出前后关系
绘制结果:
最终代码:
void DrawVisibleGeometry()
{
//决定物体绘制顺序是正交排序还是基于深度排序的配置
var sortingSettings = new SortingSettings(camera)
{
criteria = SortingCriteria.CommonOpaque
};
//决定摄像机支持的Shader Pass和绘制顺序等的配置
var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);
//决定过滤哪些Visible Objects的配置,包括支持的RenderQueue等
var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
//渲染CullingResults内不透明的VisibleObjects
context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
//添加“绘制天空盒”指令,DrawSkybox为ScriptableRenderContext下已有函数,这里就体现了为什么说Unity已经帮我们封装好了很多我们要用到的函数,SPR的画笔~
context.DrawSkybox(camera);
//渲染透明物体
//设置绘制顺序为从后往前
sortingSettings.criteria = SortingCriteria.CommonTransparent;
//注意值类型
drawingSettings.sortingSettings = sortingSettings;
//过滤出RenderQueue属于Transparent的物体
filteringSettings.renderQueueRange = RenderQueueRange.transparent;
//绘制透明物体
context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
}
帧调试器结果:
可以看到是先绘制两个不透明物体,然后是天空盒,最后是三个不透明物体
目前我们已经可以渲染出所有无光照着色器的物体,但是使用其他着色器的物体并没有显示出来,下一节的目的就是将使用其他着色器的物体也显示出来。