|
- 创建渲染管线资源和实例。
- 渲染摄像机的视图。
- 执行剔除、筛选和排序。
- 分离不透明、透明和无效通道。
- 使用多个摄像机。
本教程翻译自 Jasper Flick 大神的 Cat Like 系列教程,原地址在下方:
这是有关“创建自定义的可编程渲染管线”教程系列的第一部分,它是我们将要扩展的基本渲染管线的基础。
本系列假定您至少已完成“对象管理”和“程序化网格”系列教程。
本教程是使用 Unity 2019.2.6f1 制作的。
另一个SRP(Scriptable Render Pipeline, 可编程渲染管线)系列是干嘛的?
我还有另一个教程,也涵盖了可编程渲染管线,但该教程系列使用实验性的 SRP API,该 API 仅适用于 Unity 2018。本系列适用于 Unity 2019 及更高版本。本系列采用不同的、更现代的方法,但将涵盖许多相同的主题。如果您不想追更这个系列,那么学习 2018 系列也很好。
使用自定义渲染管线进行渲染
1. 新的渲染管线
要渲染任何内容,Unity 必须确定在何处、何时以及使用什么设置绘制哪些形状。这可能会变得非常复杂,具体取决于涉及多少效果——灯光、阴影、透明度、图像效果、体积效果等都必须以正确的顺序处理才能获得最终的图像。这就是渲染管线的作用。
过去,Unity 仅支持几种内置的渲染方式。Unity 2018 引入了可编写脚本的渲染管线(简称 RP),让我们可以做任何我们想做的事情,同时仍然能够依赖 Unity 执行剔除等基本步骤。Unity 2018 还添加了两个使用这种新方法制作的实验性RP:Lightweight RP 和 High Definition RP。在 Unity 2019 中,Lightweight RP 不再是实验性的,并在 Unity 2019.3 中更名为 Universal RP,简称 URP。
Universal RP 注定要取代当前的旧版 RP 作为默认选项,因为它是一个适合大多数情况的 RP,也很容易对其进行自定义。本系列的内容不是自定义该 RP,而是从头开始创建整个 RP。
本教程会在基础阶段创建一个小型 RP,使用前向渲染绘制不发光的物体。完成后,我们会在后面的教程中扩展我们的管线,添加光照、阴影,使用不同的渲染方法和解锁更高级的功能。
1.1 项目设置
在 Unity 2019.2.6 或更高版本中创建新的 3D 项目。我们将创建自己的管线,因此不要选择 RP 项目模板。打开项目后,您可以使用包管理器(Package Manager)删除所有不需要的包。在本教程中,我们将仅使用 Unity UI 包来绘制 UI,因此您可以保留该包。
我们将在线性色彩空间下进行计算,但 Unity 2019.2 仍然使用伽玛空间作为默认值。通过“Edit/Project Settings/Player”,然后将“Other Settings”部分下的“Color Space”切换到“Linear”。
将色彩空间设置为线性
使用一些物体填充默认场景,给他们挂上标准无照明的不透明材质和透明材质。“Unlit/Transparent”着色器必须要有贴图才生效,因此这里提供了一张 UV 球形贴图。
球形alpha贴图
我在测试场景中放了几个立方体,它们都是不透明的。红色的使用带有标准着色器的材质,而绿色和黄色的则使用带有“Unlit/Color”着色器的材质。蓝色球体使用“Rendering Mode”设置为“Transparent”的标准着色器,而白色球体使用“Unlit/Transparent”着色器。
测试场景
1.2 管线资源(Asset)
目前,Unity 使用默认渲染管线。要将其替换为自定义渲染管线,我们首先必须为其创建资源(Asset)类型。我们将使用与 URP 大致相同的文件夹结构。创建“Assets/Custom RP/Runtime”文件夹,并新建一个新的 C# 脚本,命名为CustomRenderPipelineAsset。
文件结构
Asset 类型需要继承自“RenderPipelineAsset”,它在“UnityEngine.Rendering”命名空间下。
using UnityEngine;
using UnityEngine.Rendering;
public class CustomRenderPipelineAsset : RenderPipelineAsset {}
RP 资源的主要作用是为 Unity 提供一种方法来获取负责渲染的管线对象实例。资源本身只是一个句柄和存储设置的位置。我们目前还没有任何设置,因此我们所要做的就是为 Unity 提供一种获取管线对象实例的方法。这是通过重写抽象方法 CreatePipeline 来完成的,抽象方法应该返回一个 RenderPipeline实例。但是我们还没有定义管线类型,因此首先返回 null。
CreatePipeline 方法是使用 protected 访问修饰符定义的,这意味着只有定义该方法的类(即 RenderPipelineAsset )和扩展该方法的类才能访问它。
protected override RenderPipeline CreatePipeline () {
return null;
}
现在我们需要将这种类型的资源添加到我们的项目中。为此,请向 CustomRenderPipelineAsset添加一个特性 CreateAssetMenu。
[CreateAssetMenu]
public class CustomRenderPipelineAsset : RenderPipelineAsset { … }这会在“Asset/Create”菜单中新增一个条目。让我们将其放在“Rendering”子菜单中以保持菜单栏的清晰。为此,我们将其 menuName 属性设置为“Rendering/Custom Render Pipeline”。此属性可以直接在特性类型之后设置,放在圆括号内。
使用新菜单项将资源添加到项目中,重命名为 Custom RP,然后转到“Graphics”项目设置并在“Scriptable Render Pipeline Settings”下选择它。
选择 Custom RP
替换默认 RP 更改了一些内容。首先,我们在上图的感叹号后面被告知许多选项已从图形设置中消失。其次,我们在没有提供有效管线的情况下禁用了默认 RP,因此不再渲染任何内容。游戏窗口、场景窗口和材质预览不再起作用。如果您通过“Window / Analysis / Frame Debugger”打开帧调试器并启用它,您将看到游戏窗口中确实没有绘制任何内容。
1.3 渲染管线实例
创建一个 CustomRenderPipeline 类并将其脚本文件放在与 CustomRenderPipelineAsset 相同的文件夹中。这将是用于我们管线资源返回的 RP 实例的类型,因此它必须扩展 RenderPipeline .
using UnityEngine;
using UnityEngine.Rendering;
public class CustomRenderPipeline : RenderPipeline {}RenderPipeline定义一个 protected 的抽象方法 Render,我们必须重写它才能创建具体的管道。它有两个参数:ScriptableRenderContext 和一个 Camera 数组。暂时将该方法留空。
protected override void Render (
ScriptableRenderContext context, Camera[] cameras
) {}使 CustomRenderPipelineAsset.CreatePipeline 返回 CustomRenderPipeline 的新实例,这将为我们提供一个有效且功能强大的管道,尽管它还没有渲染任何东西。
2. 渲染
Unity 在 RP 实例上每帧调用 Render 方法。它传递一个上下文(ScriptableRenderContext)结构,该结构提供了我们与引擎本身的连接方式,我们使用它进行渲染。它还传递一个摄像机数组,因为场景中可以有多个摄像机。RP 负责按提供的顺序渲染所有这些摄像机。
2.1 相机渲染器
每个摄像机都是独立渲染的。因此,我们不在 CustomRenderPipeline 中直接渲染所有摄像机,而是将渲染工作交给专门用于渲染单个摄像机的新类 CameraRenderer。给这个新类提供一个带有 ScriptableRenderContext和 Camera 参数的 public 方法 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;
}
}在创建 CustomRenderPipeline 时创建 CameraRenderer 的实例,然后使用它循环渲染所有摄像机。
CameraRenderer renderer = new CameraRenderer();
protected override void Render (
ScriptableRenderContext context, Camera[] cameras
) {
foreach (Camera camera in cameras) {
renderer.Render(context, camera);
}
}我们的 CameraRenderer 大致相当于 URP 的 scriptable renderers。这种方法使得将来为每个摄像机支持不同的渲染方法变得简单,例如,一种用于第一人称视角,另一种用于 3D 地图叠加,又或前向渲染与延迟渲染。但现在我们将以相同的方式渲染所有摄像机。
2.2 绘制天空盒
CameraRenderer.Render 的工作是绘制相机可以看到的所有几何图形。为清楚起见,将该特定任务隔离在单独的 DrawVisibleGeometry 方法中。我们将首先让它绘制默认的天空盒,这可以通过调用 ScriptableRenderContext.DrawSkybox 来完成,参数是当前摄像机。
public void Render (ScriptableRenderContext context, Camera camera) {
this.context = context;
this.camera = camera;
DrawVisibleGeometry();
}
void DrawVisibleGeometry () {
context.DrawSkybox(camera);
}这时天空盒仍然没有出现。这是因为我们向上下文发出的命令是缓冲的。我们必须通过调用 Submit 来提交队列中的指令以供引擎去执行。让我们在一个单独的方法中执行 Submit 操作,在 DrawVisibleGeometry 之后调用。
public void Render (ScriptableRenderContext context, Camera camera) {
this.context = context;
this.camera = camera;
DrawVisibleGeometry();
Submit();
}
void Submit () {
context.Submit();
}天空盒终于出现在游戏和场景窗口中,还可以在帧调试器中看到它被绘制的条目。它被列为Camera.RenderSkybox,它下面有一个 Draw Mesh 条目,代表实际的绘制调用。这对应于游戏窗口的渲染,帧调试器不会显示其他窗口中的绘制。
天空盒绘制成功
请注意,目前摄像机的方向不会影响天空盒的渲染方式。我们将摄像机传递给 DrawSkybox 仅用于确定是否应该绘制天空盒。在摄像机的 clear flags 属性中可以选择该相机是否需要绘制天空盒。
为了正确渲染天空盒和整个场景,我们必须设置视图投影矩阵。此变换矩阵将摄像机的位置和方向(视图矩阵)与摄像机的透视或正交投影(投影矩阵)组合在一起。它在着色器中被称为 unity_MatrixVP,这是绘制几何图形时使用的着色器属性之一。在帧调试器中选中一个 draw call 时,可以在“ShaderProperties”部分中查看这个矩阵。
目前,unity_MatrixVP 矩阵始终不变。我们必须通过 SetupCameraProperties 方法将相机的属性应用于上下文,这将设置矩阵以及其他一些属性。我们在调用 DrawVisibleGeometry 之前,在单独的 Setup 方法中执行此操作。
public void Render (ScriptableRenderContext context, Camera camera) {
this.context = context;
this.camera = camera;
Setup();
DrawVisibleGeometry();
Submit();
}
void Setup () {
context.SetupCameraProperties(camera);
}
正确显示的天空盒
2.3 命令缓冲区(Command Buffers)
上下文会等到我们提交命令之后才会开始渲染。在此之前,我们对其进行配置并向其添加命令以供以后执行。某些任务(如绘制天空盒)可以通过专用方法发出,但其他命令必须通过单独的命令缓冲区间接发出。我们需要这样的缓冲区来绘制场景中的其他几何体。
要获得缓冲区,我们必须创建一个新的 CommandBuffer对象实例。在 CameraRenderer 中我们只需要一个缓冲区,因此直接在初始化的时候创建一个缓冲区,并在字段中存储对它的引用。还要给缓冲区一个名字,这里叫做“Render Camera”,以便我们可以在帧调试器中识别它。
const string bufferName = "Render Camera";
CommandBuffer buffer = new CommandBuffer {
name = bufferName
};我们可以使用命令缓冲区来创建 profiler 的采样点(sample),这些采样点在 profiler 和帧调试器中都可以看到。这是通过在适当的位置调用 BeginSample 和 EndSample 来完成的,在我们的例子中,就是 Setup 和 Submit 的开头来调用。必须为这两种方法提供相同的名称,所以我们都使用缓冲区的名称。
若要执行缓冲区,请使用上下文的 ExecuteCommandBuffer 方法,并将缓冲区 buffer 作为参数。这会从缓冲区复制命令但不会清除它,如果我们想重用它,我们必须在之后显式执行此操作。由于执行和清除始终是一起完成的,因此添加同时执行这两种操作的方法会比较方便。
void Setup () {
buffer.BeginSample(bufferName);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
void Submit () {
buffer.EndSample(bufferName);
ExecuteBuffer();
context.Submit();
}
void ExecuteBuffer () {
context.ExecuteCommandBuffer(buffer);
buffer.Clear();
}
Render Camera 的采样点
2.4 清除渲染目标
无论我们要渲染什么,最终都会渲染到摄像机的渲染目标(Render Target)中,默认情况下,该目标是帧缓冲区,但也可以是渲染纹理(RT,Render Texture)。之前绘制到该目标的内容不会被自动清除掉,这会干扰到我们现在正在渲染的图像。为了保证正确的渲染,我们必须清除渲染目标中残留的内容。这可以在 Setup 方法里通过命令缓冲区的 ClearRenderTargetSetup 方法来完成。
void Setup () {
buffer.BeginSample(bufferName);
buffer.ClearRenderTarget(true, true, Color.clear);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
清除命令,多了一层 Render Camera 的嵌套
帧调试器现在显示出了清除操作的 Draw GL 条目,但是该条目嵌套在 Render Camera 的第二层中。发生这种情况是因为 ClearRenderTarget 方法内部已经包含了一次以 command buffer 的名称命名的采样。我们可以通过在开始我们自己的采样之前执行清除指令来消除多余的嵌套。这样,清除指令和绘制天空盒的指令就显示在一起了。ClearRenderTarget
void Setup () {
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(bufferName);
//buffer.ClearRenderTarget(true, true, Color.clear);
ExecuteBuffer();
context.SetupCameraProperties(camera);
}
解决了嵌套
Draw GL 条目显示,它清除的方式是使用“Hidden/InternalClear”shader 画一个全屏的四边形到渲染目标中,这显然不是最有效的清除方式。它之所以使用此方法来清除,是因为我们在设置相机属性之前调用了清除命令。如果我们交换这两个步骤的顺序,我们就会得到快速清除的方法。
void Setup () {
context.SetupCameraProperties(camera);
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(bufferName);
ExecuteBuffer();
//context.SetupCameraProperties(camera);
}
正确的清除
现在我们看到 Clear (color+Z+stencil),这表示颜色和深度缓冲区都被清除了。Z 表示深度缓冲区,stencil 也是深度缓冲区的一部分。
2.5 裁剪
我们目前看到的是天空盒,但没有看到我们放入场景中的任何对象。我们不需要渲染每个对象,而是只渲染摄像机可见的对象。所以我们需要遍历场景中具有渲染器组件的所有对象,并剔除那些在摄像机视锥范围之外的对象。
要确定哪些是可以剔除的物体,我们需要跟踪多个相机设置和矩阵,这些信息可以从 ScriptableCullingParameters结构中获得。我们可以在相机上调用 TryGetCullingParameters 方法来填充这个结构。它返回参数是否设置成功,因为它可能会因老旧的相机设置而失败。为了获取参数数据,我们必须在它前面写入 out 来将其作为输出参数。在单独的 Cull 方法中做这些事情,并返回成功还是失败。
bool Cull () {
ScriptableCullingParameters p
if (camera.TryGetCullingParameters(out p)) {
return true;
}
return false;
}当用作输出参数时,可以在参数列表中内联变量声明,所以让我们这样做。
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();
}实际剔除是通过调用上下文的 Cull 来完成的,上下文会生成一个 CullingResults结构。如果成功,就执行 Cull 操作,并将结果存储在字段中。我们必须将剔除参数作为引用参数传递,方法是在它前面写 ref。
CullingResults cullingResults;
…
bool Cull () {
if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
cullingResults = context.Cull(ref p);
return true;
}
return false;
}2.6 渲染几何体
一旦我们知道了什么是可见的,我们就可以继续渲染这些可见的东西。这是通过在上下文中调用 DrawRenderers 方法来完成的,cullingResults 作为第一个参数告诉上下文哪些渲染器需要被渲染。除此之外,我们还必须提供一些渲染设置和过滤设置。这两个都用结构体表示 — DrawingSettings 和 FilteringSettings —我们现在只使用它们的默认构造函数。两者都必须通过引用传递。在绘制天空盒之前,在 DrawVisibleGeometry 方法中执行此操作。
void DrawVisibleGeometry () {
var drawingSettings = new DrawingSettings();
var filteringSettings = new FilteringSettings();
context.DrawRenderers(
cullingResults, ref drawingSettings, ref filteringSettings
);
context.DrawSkybox(camera);
}我们还是没有看到任何东西,因为我们还必须指示允许哪种着色器通道(Sader Pass)。由于我们在本教程中仅支持 unlit 着色器,因此我们必须获取 SRPDefaultUnlit 通道的着色器标记 ID,我们可以只获取一次并将其缓存在静态字段中。
static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");将其作为第一个参数传入 DrawingSettings 的构造器中,第二个参数是一个 SortingSettings 类型的结构体,用来确定各物体的渲染顺序,它的构造器需要一个相机作为参数。
void DrawVisibleGeometry () {
var sortingSettings = new SortingSettings(camera);
var drawingSettings = new DrawingSettings(
unlitShaderTagId, sortingSettings
);
…
}此外,我们还得指明哪些渲染队列(Render Queue)可以被渲染。将 RenderQueueRange.all 作为参数传给 FilteringSettings 的构造器以启用所有渲染队列。
var filteringSettings = new FilteringSettings(RenderQueueRange.all);
渲染不发光几何体
仅绘制使用未发光着色器的可见对象。所有绘制调用都列在帧调试器中,分组在RenderLoop.Draw下。透明对象有些奇怪,但让我们先看看对象的绘制顺序。这在帧调试器中显示,您可以通过一个一个地选择绘制命令或使用上下键来单步执行绘制调用。
单步执行帧调试器
渲染顺序是随机的,我们可以通过 sortingSettings 的 criteria 属性来设置特定的排序规则,让我们试试 SortingCriteria.CommonOpaque。
var sortingSettings = new SortingSettings(camera) {
criteria = SortingCriteria.CommonOpaque
};
Common opaque 排序
对象现在或多或少地从前到后绘制,这是不透明对象的理想选择。如果某些内容最终绘制在其他内容后面,则可以跳过其隐藏片段,从而加快渲染速度。Common opaque 选项还考虑了一些其他条件,包括渲染队列和材质。
2.7 分别渲染不透明和透明几何图形
在帧调试器中我们可以看到透明对象是被渲染出来的,但是最后天空盒渲染时又将所有透明物体覆盖了。这种情况发生的原因是透明物体用的 shader 不会写入深度缓冲区,因此我们需要将透明物体放到最后去渲染,以免被覆盖,也就是,先渲染不透明物体,再渲染天空盒,最后是透明物体。
我们可以将 FilteringSettings 的渲染队列范围参数换成 RenderQueueRange.opaque,这样就会不渲染透明物体。
var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);然后在绘制天空盒后再次调用 DrawRenderers。但在此之前,请将渲染队列范围更改为 RenderQueueRange.transparent。同时将排序条件更改为 SortingCriteria.CommonTransparent 并再次设置 drawingSettings 的排序。这会反转透明对象的绘制顺序,即让透明物体从远往近渲染。
不透明物体,天空盒,最后是透明物体
为什么渲染透明物体需要从远到近?
由于透明对象不写入深度缓冲区,因此前后排序对性能没有好处。但是当透明的物体在视觉上重合时,它们必须从后往前渲染才能正确地混合。
不幸的是,前后排序并不能保证正确的混合,因为排序是针对每个对象的,并且只基于对象的位置。交叉和大的透明对象仍然可能产生不正确的结果。这个问题有时可以通过将透明物体切割成较小的几个物体来解决。 3. 编辑器渲染
我们的 RP 可以正确绘制未发光的对象,但我们可以采取一些措施来改善在 Unity 编辑器中使用它的体验。
3.1 渲染旧版着色器
由于我们的管线仅支持未光照的着色器通道,因此不会渲染使用其他通道的对象。虽然这是正确的,但它隐藏了场景中使用错误着色器的某些对象。因此,无论如何,让我们分别渲染它们。
如果有人从默认的 Unity 项目开始,然后切换到我们的 RP,那么他们的场景中可能会有使用错误着色器的对象。为了覆盖所有 Unity 的默认着色器,我们必须对 Always、ForwardBase、PrepassBase、Vertex、VertexLMRGBM 和 VertexLM 通道使用着色器标签 ID。在静态数组中跟踪这些内容。
static ShaderTagId[] legacyShaderTagIds = {
new ShaderTagId("Always"),
new ShaderTagId("ForwardBase"),
new ShaderTagId("PrepassBase"),
new ShaderTagId("Vertex"),
new ShaderTagId("VertexLMRGBM"),
new ShaderTagId("VertexLM")
};渲染完可见的物体后,我们在单独的方法中渲染那些带有不支持的 shader 的物体。由于这些都是无效的通道,无论如何结果都是错的,所以我们无需理会各种设置,都用默认的就好。我们可以通过 FilteringSettings.defaultValue 获取默认的过滤设置。
public void Render (ScriptableRenderContext context, Camera camera) {
…
Setup();
DrawVisibleGeometry();
DrawUnsupportedShaders();
Submit();
}
…
void DrawUnsupportedShaders () {
var drawingSettings = new DrawingSettings(
legacyShaderTagIds[0], new SortingSettings(camera)
);
var filteringSettings = FilteringSettings.defaultValue;
context.DrawRenderers(
cullingResults, ref drawingSettings, ref filteringSettings
);
}我们可以通过 SetShaderPassName 方法来绘制多个通道,第一个参数是索引,关系到渲染顺序,第二个参数是着色器标签。对数组中的所有通道都这样做,从第二个开始,因为我们在构造 DrawingSettings 时已经设置了第一个通道。
standard shader 渲染黑色
由 standard shader 渲染的物体显示了出来,但是它们现在是黑色的,因为我们的渲染管线没有设置 shader 所需要的各种属性。
3.2 错误的材质
为了清楚地显示出哪些对象使用不受支持的着色器,我们将使用 Unity 的错误着色器绘制它们。使用该着色器作为参数构造一个新材质,我们可以通过使用 Hidden/InternalErrorShader 字符串作为参数调用 Shader.Find 来找到它。通过静态变量缓存材质,这样我们就不会每帧创建一个新材质。然后将其指定给图形设置的属性 overrideMaterial。
static Material errorMaterial;
…
void DrawUnsupportedShaders () {
if (errorMaterial == null) {
errorMaterial =
new Material(Shader.Find("Hidden/InternalErrorShader"));
}
var drawingSettings = new DrawingSettings(
legacyShaderTagIds[0], new SortingSettings(camera)
) {
overrideMaterial = errorMaterial
};
…
}
使用洋红色错误着色器渲染
现在所有无效对象都是可见的,而且显然是错误的。
3.3 局部类
绘制无效对象对开发很有用,但不适用于已发布的应用程序。因此,让我们将所有 CameraRenderer 中仅限编辑器的代码放在一个单独的局部类文件中。首先复制原始的 CameraRenderer 脚本资源,并将其重命名为CameraRenderer.Editor。
一个类,两个脚本资源
然后将原始 CameraRenderer 类转换为局部类,并从中删除标签数组、错误材质和 DrawUnsupportedShaders 方法。
public partial class CameraRenderer { … }清理另一个分部类文件,使其仅包含我们从另一个分部类文件中删除的内容。
using UnityEngine;
using UnityEngine.Rendering;
partial class CameraRenderer {
static ShaderTagId[] legacyShaderTagIds = { … };
static Material errorMaterial;
void DrawUnsupportedShaders () { … }
}编辑器部分的内容只需要存在于编辑器中,因此请使其以 UNITY_EDITOR 为条件。
partial class CameraRenderer {
#if UNITY_EDITOR
static ShaderTagId[] legacyShaderTagIds = { … }
};
static Material errorMaterial;
void DrawUnsupportedShaders () { … }
#endif
}但是,此时进行构建将失败,因为另一部分始终包含 DrawUnsupportedShaders 方法的调用,但是该方法现在又仅在编辑器中才存在。为了解决这个问题,我们也使该方法部分化。我们通过始终在其前面声明 partial 方法签名来做到这一点,类似于抽象方法声明。我们可以在类定义的任何部分执行此操作,因此让我们将其放在编辑器部分中。完整方法声明也必须用 partial 标记。
partial void DrawUnsupportedShaders ();
#if UNITY_EDITOR
…
partial void DrawUnsupportedShaders () { … }
#endif现在可以成功编译了。编译器将删除所有未以完整声明结束的局部方法的调用。
3.4 渲染小控件
目前,我们的 RP 不会绘制小控件,无论是在场景窗口中还是在游戏窗口中(如果启用了它们)。
没有小控件的场景
我们可以通过调用 UnityEditor.Handles.ShouldRenderGizmos 来检查是否应该绘制小控件。如果是,我们必须调用上下文的 DrawGizmosDrawGizmos 方法,将相机作为第一个参数,第二个参数表示应该绘制哪个小控件子集。有两个子集,用于之前和之后的图像效果。由于我们目前不支持图像效果,因此我们将同时调用两者。使用新的仅编辑器生效的方法执行此操作。
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();
}
有小控件的场景
3.5 渲染 Unity UI
需要我们注意的另一件事是 Unity 的游戏内UI。例如,通过 GameObject / UI / Button 添加按钮来创建简单的 UI。它将显示在游戏窗口中,但不会显示在场景窗口中。
游戏窗口中的UI按钮
帧调试器向我们显示 UI 是单独呈现的,而不是由我们的 RP 呈现的。
帧调试器中的UI
至少在画布组件的渲染模式(Render Mode)设置为默认的 Screen Space - Overlay 时,Unity 会单独渲染 UI。将其更改为creen Space - Camera Screen Space - Camera 并使用主相机作为其渲染相机,此时UI成为了透明物体的一部分。
相机渲染下的UI
UI在场景窗口中渲染时始终使用世界空间模式,所以它通常看起来会非常大。虽然我们可以通过场景窗口编辑 UI,但它不会被绘制。
UI在场景窗口中不可见
在渲染场景窗口时,我们必须显式地将UI物体添加到世界几何体中,通过调用 ScriptableRenderContext.EmitWorldGeometryForSceneView 方法可以做到这一点,它接受一个相机作为参数。这次渲染只在场景窗口中进行,因此只有当相机的 cameraType 属性等于 CameraType.SceneView 时,我们才进行渲染。
partial void PrepareForSceneWindow ();
#if UNITY_EDITOR
…
partial void PrepareForSceneWindow () {
if (camera.cameraType == CameraType.SceneView) {
ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
}
}由于这可能会为场景添加几何体,因此必须在剔除之前完成。
PrepareForSceneWindow();
if (!Cull()) {
return;
}
场景窗口中UI也可见了
4. 多个相机
场景中可以有多个启用的摄像机。如果是这样,我们必须确保它们协同工作。
4.1 两个相机
每个摄像机都有一个深度值(Depth),默认主摄像机的深度值为 −1。它们以递增的深度顺序渲染。为了看到这一点,请复制主摄像机,将其重命名为 Secondary Camera,并将其深度设置为 0。最好给它另一个标签,因为MainCamera 应该只由单个相机使用。
两个相机都包含在 Render Camera 的条目中
场景现在渲染两次,且生成的图像相同,因为渲染目标在两次渲染之间被清除掉了,在帧调试器中可以看出这一点。由于具有相同名称的相邻 Sample 会被合并,因此我们最终会只看到一个 Render Camera 的 Sample。
如果每个摄像机都有自己的 Sample,那就更清楚了。若要实现此目的,请添加一个仅编辑器的方法 PrepareBuffer,使缓冲区的名称与摄像机的名称相等。
partial void PrepareBuffer ();
#if UNITY_EDITOR
…
partial void PrepareBuffer () {
buffer.name = camera.name;
}
#endif在 PrepareForSceneWindow 之前调用它。
PrepareBuffer();
PrepareForSceneWindow();
每个相机单独采样
4.2 处理由更改 sample 名称而产生的问题
尽管帧调试器现在为每个摄像机显示单独的 sample 层次结构,但当我们进入播放模式时,Unity 的控制台将充满消息,警告我们 BeginSample 和 EndSample 计数必须匹配,这是因为我们对 sample 及其缓冲区使用了不同的名称。除此之外,我们最终还会在每次访问相机的 name 属性时分配内存,所以我们不想在构建好的程序中也用相机名称来设置 sample。
为了解决这两个问题,我们将添加一个 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在 Setup 和 Submit 方法中使用 SampleName。
void Setup () {
context.SetupCameraProperties(camera);
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(SampleName);
ExecuteBuffer();
}
void Submit () {
buffer.EndSample(SampleName);
ExecuteBuffer();
context.Submit();
}我们可以通过查看 profiler(通过 Window / Analysis / Profiler 打开)来查看差异。并首先在编辑器中点击播放,切换到 Hierarchy 模式并按 GC Alloc 排序,你会看到 GC.Alloc 的两个调用条目,总共分配 100 个字节,这是由检索摄像机名称引起的。再往下看,你会看到这些 sample 名称:Main Camera 和 Secondary Camera。
每个相机单独的 sample,分配了 100B 内存
然后,在 Development Build 和 Autoconnect Profiler 启用的情况下打包程序。运行程序并确保 profiler 已连接并录制。在本例中,我们没有那多余的 100 字节的内存分配,而且只有 Render Camera sample。
打包程序的 profiler
分配的其他 48 个字节用在哪儿了?
用在摄像机数组,这个数组我们无法控制,它的大小取决于渲染了多少个摄像机。 通过将相机名称检索包装在名为 Editor Only 的 profiler sample 中,我们可以清楚地表明,我们仅在编辑器中分配内存,而不是在打包的程序中分配内存。在这种情况下,我们需要从命名空间 UnityEngine.Profiling 调用 Profiler.BeginSample 和 Profiler.EndSample 方法,其参数为 sample 的名称。
using UnityEditor;
using UnityEngine;
using UnityEngine.Profiling;
using UnityEngine.Rendering;
partial class CameraRenderer {
…
#if UNITY_EDITOR
…
partial void PrepareBuffer () {
Profiler.BeginSample("Editor Only");
buffer.name = SampleName = camera.name;
Profiler.EndSample();
}
#else
string SampleName => bufferName;
#endif
}
profiler 中的 Editor Only
4.3 图层
摄像机也可以配置为仅查看某些图层上的内容。这是通过调整他们的 Culling Mask 来完成的。为了了解这一点,让我们将所有使用标准着色器的对象移动到 Ignore Raycast 图层。
将层级调整到 Ignore Raycast
从主摄像机的 Culling Mask 中排除该图层。
剔除 ignore raycast 层
并使其成为辅助相机看到的唯一图层。
剔除除 Ignore Raycast 层的所有层
由于辅助相机后渲染,我们最终只能看到无效对象。
仅 Ignore Raycast 层的物体可见
4.4 Clear Flags
我们可以通过调整渲染的第二个摄像机的 Clear Flags 来组合两个摄像机的渲染结果。它们由一个枚举 CameraClearFlags定义,我们可以通过相机的 clearFlagsSetup 属性检索该枚举。在 Setup 中清除缓冲区之前执行此操作。
void Setup () {
context.SetupCameraProperties(camera);
CameraClearFlags flags = camera.clearFlags;
buffer.ClearRenderTarget(true, true, Color.clear);
buffer.BeginSample(SampleName);
ExecuteBuffer();
}CameraClearFlags枚举定义四个值。从 1 到 4 分别是 Skybox, Color, Depth, 和 Nothing。这些实际上不是独立的标志值,一定程度上表示清除量的减少。除最后一种情况外,在所有情况下都必须清除深度缓冲区,因此当标志值最大到 Depth 时,都要清除深度缓冲区。
buffer.ClearRenderTarget(
flags <= CameraClearFlags.Depth, true, Color.clear
);我们只需要在标志设置为 Color 时清除颜色缓冲区,因为在 Skybox 的情况下,我们最终会替换所有以前的颜色数据。
buffer.ClearRenderTarget(
flags <= CameraClearFlags.Depth,
flags == CameraClearFlags.Color,
Color.clear
);如果我们要清除为纯色,则需要使用相机的背景颜色。但是因为我们是线性颜色空间渲染的,我们必须将该颜色转换为线性空间,所以我们最终需要通过 camera.backgroundColor.linear 来获取。在其他情况下,颜色无关紧要,因此使用 Color.clear 即可。
buffer.ClearRenderTarget(
flags <= CameraClearFlags.Depth,
flags == CameraClearFlags.Color,
flags == CameraClearFlags.Color ?
camera.backgroundColor.linear : Color.clear
);由于主摄像机是先渲染,因此其“Clear Flag”应设置为 Skybox 或 Color。在帧调试器中可以看到,我们始终从清除缓冲区开始,但通常不能保证会始终以清除缓冲区开始。
辅助摄像机的 Clear Flag 决定了两个相机的渲染组合方式。在 Skybox 或 Color 的情况下,之前的结果被完全替换。当仅清除深度时,辅助相机将正常渲染,只是它不绘制天空盒,因此之前的结果显示为背景。当未清除任何内容时,深度缓冲区将被保留,因此不发光的对象最终会遮挡无效对象,就好像它们是由同一摄像机绘制的一样。但是,前一个相机绘制的透明物体没有深度信息,因此可能会被后面的渲染覆盖掉。
Color,Depth Only 和 Don&#39;t Clear
通过调整相机的 Viewport Rect,还可以将渲染区域减少为渲染目标的一小部分。渲染目标的其余部分不受影响。在这种情况下,清除发生在 Hidden/InternalClear 着色器中。stencil 缓冲区用于将渲染限制为视口区域。
减少副摄像头的视口大小,清除颜色
请注意,每帧渲染多个摄像机意味着剔除、设置、排序等也必须多次完成。每个独特视角使用一个相机通常是最有效的方法。 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|