Unity可编程渲染管线(SRP)教程:一、自定义管线

Unity可编程渲染管线(SRP)教程:一、自定义管线

本文翻译自Catlike Coding,原作者:Jasper Flick。

本文经原作者授权,转载请说明出处。

原文链接在下:


自定义管线 控制渲染

创建pipeline asset 和 instance.

剔除、过滤、排序、渲染.

保持内存清洁.

提供良好的编辑体验.


这是Unity scriptable render pipeline系列教程的第一部分。本教程假设你首先通过了Basics系列教程和Procedural Grid教程。Rendering系列的头几部分也很有帮助。

本教程使用Unity 2018.3.0f2完成。

  1. 创建一个Pipeline

渲染任何东西Unity都需要明确哪些形状、何处、何时以及使用什么设置。这些可以变得非常复杂取决于涉及到多少效果。光源、阴影、半透明、图像效果、体积效果以及等等都需要用正确的顺序处理,以获得最终的图像。这个过程被称之为渲染管线。

Unity 2017 支持两种预定义渲染管线,一种是前向渲染,另一种是延迟渲染。也仍然支持Unity 5中引入的旧延迟渲染方法。这种管线是固定的。你可以开启、关闭或者覆盖管线中某些部分,但不可以彻底偏离它的设计。

Unity 2018 添加了可编程渲染管线的支持,使得从头设计管线成为可能,尽管你仍然需要依赖Unity来完成许多单独的步骤,例如剔除。Unity 2018引入了两种使用这种新方法的管线,轻量级渲染管线和高分辨率渲染管线。两种管线仍处于预览阶段并且可编程渲染管线API依然被标记为实验技术。但是在这一点上对我们来说足够稳定可以继续创建并创建我们自己的管线。

在这个教程中我们会设置一个绘制Unlit图形的最小渲染管线。一旦起作用了,我们可以在之后的教程中扩展我们的管线,添加光照、阴影、以及更多的高级特性。

1.1 项目设置

打开Unity 2018并创建一个新项目。我使用的是Unity 2018.2.9f1,但是任何2018.2版本或者更新版应该也可以使用。创建一个Standard 3D项目并禁用analytics。我们会创建我们自己的管线,所以不选择任何管线选项。

项目打开后,由Window/Pakage Manager打开包管理器,并且移除所有默认包含的包,因为我们不需要它们。只保留不可以被移除的Package Manager UI。

初始的项目

我们要在线性色彩空间下工作,但是Unity 2018依然默认使用gamma空间。所以由Edit/Project Setting/Player到player setting中,然后将Other Setting部分中的色彩空间切换到Linear。

线性色彩空间

我们需要几个简单的材质去测试我们的管线。我创建了四种材质。第一种,默认的standard opaque材质带有红色albedo。第二种,一样的材质但是Rendering Mode设置为Transparent以及蓝色的albedo带有减少的alpha。第三种,使用Unlit/Color shader并且颜色设置为黄色的材质。最后一种使用Unlit/Transparent shader并不做任何改变,因此显示为纯白色。

测试材质

使用一些object填充场景,让它们用完所有的四种材质。

场景显示四种材质

1.2 Pipeline Asset

现在,Unity使用默认的前向渲染管线。要使用自定义管线,我们需要在grahics settings中选择,可以由Edit/Project Setting/Graphics找到。

使用默认管线

要设置我们自己的管线,我们需要分配一个pipeline asset到Scriptable Render Pipeline Settings字段。这类asset需要扩展RenderPipelineAsset,这是一种ScriptableObject类型。

为我们的自定义pipeline asset创建一个新脚本。我们将简单地命名我们的管线为My Pipeline。这个asset类型将因此成为MyPipelineAsset并且需要扩展RenderPipelineAsset,它被定义在UnityEngine.Experimental.Rendering命名空间中。

using UnityEngine;
using UnityEngine.Experimental.Rendering;

public class MyPipelineAsset : RenderPipelineAsset {}
它会一直在experimental命名空间中吗?
它会在某个时刻被移出experimental命名空间,到UnityEngine.Rendering或者其他命名空间。当这种情况发生时,只需要更新using语句,除非API也发生了改变。

pipeline asset的主要目的是给Unity一个途径去获取负责渲染的管线对象实例。asset它本身只是一个句柄和存放管线设置的地方。我们目前还没有任何设置,所以我们要做的是给Unity一个途径去获取我们的管线对象实例。这个通过重载InternalCreatePipeline方法来实现。但是我们还没有定义我们的管线对象,所以此时我们将仅返回null。

InternalCreatePipeline返回值类型是IRenderPipeline。类型名的I前缀表示它是接口类型。

public class MyPipelineAsset : RenderPipelineAsset {

	protected override IRenderPipeline InternalCreatePipeline () {
		return null;
	}
}
什么是接口?
接口就像一个类,除了定义一个功能合同且不提供它的实现。它只定义属性、事件、索引器和方法签名,它们都是被定义公开的。任何扩展接口的类型都要求包含接口定义的实现。惯例是在接口名前加个I前缀。
因为接口不包含具体的实现,所以类甚至结构体可以扩展多个接口。如果多个接口碰巧定义了相同的东西,它们只是同意功能应该存在。在类中是不可以的即使是抽象类,因为这个可能导致实现冲突。

现在我们需要为我们的项目添加一个这种类型的asset。要实现这点,添加CreateAssetMenu特性到MyPipelineAsset。

[CreateAssetMenu]
public class MyPipelineAsset : RenderPipelineAsset {}

这在Asset/Create菜单中添加了个入口。让我们整理一下,把他放到Rendering的子菜单中。我们通过设置特性的menuName属性为Rendering/My Pipeline来完成。这个属性在可以被直接设置在特性类型之后的圆括号中。

[CreateAssetMenu(menuName = "Rendering/My Pipeline")]
public class MyPipelineAsset : RenderPipelineAsset {}

使用新菜单项添加这个asset到项目,命名为My Pipeline。

Pipeline asset及其脚本

然后把它分配到Scriptable Render Pipeline Settings。

使用中的Asset

我们现在已经替换了默认的管线,更改了一些东西。首先,graphics setting中的大量选项不见了,Unity也在信息面板中提到了这些设置。其次,我们绕过了默认管线并不提供有效的替换,因此不会进行任何渲染。尽管,场景窗口仍然显示天空盒,但是游戏窗口、场景窗口和材质预览不再起作用,如果你由Window/Analysis/Frame Debugger打开帧调试器,并且启用它,你会看见确实没有在游戏窗口中绘制任何东西。

1.3 管线实例

创建一个有效的管线,我们需要提供一个实现IRenderPipeline并负责渲染流程的对象实例。所以为此创建一个类将其命名为MyPipeline。

using UnityEngine;
using UnityEngine.Experimental.Rendering;

public class MyPipeline : IRenderPipeline {}

尽管我们可以实现由自己IRenderPipeline,但是更方便的是扩展RenderPipeline类。这个类型已经提供一个IRenderPipeline我们可以自己构建的实现。

public class MyPipeline : RenderPipeline {}

现在我们可以在InternalCreatePipeline中返回一个MyPipeline的新实例。这意味着我们在技术上有了一个有效的管线,尽管这依然不能渲染任何东西。

	protected override IRenderPipeline InternalCreatePipeline () {
		return new MyPipeline();
	}

2. 渲染

管线对象负责渲染每一帧。Unity会根据上下文以及激活的相机调用管线的Render方法。这是针对游戏窗口的,也适用于场景窗口和编辑器中的材质预览。这需要我们适当地配置,找到需要渲染的内容,并且按正确的顺序处理每个操作。

2.1 上下文

RenderPipeline包含了定义在IRenderPipeline接口中的Render方法的实现。它第一个参数是渲染上下文,这是ScriptableRenderContext结构,作为native code的外在表现。这第二个参数是一个包含所有需要渲染的相机的数组。

RenderPipeline.Render不绘制任何东西,但是会检查管线对象是否有效用于渲染。如果无效,则会引发一个异常。我们会重载这个方法并调用基类的实现,来保证这个检测。

public class MyPipeline : RenderPipeline {

	public override void Render (
		ScriptableRenderContext renderContext, Camera[] cameras
	) {
		base.Render(renderContext, cameras);
	}
}

通过渲染上下文我们可以向Unity引擎发出指令去渲染和控制渲染状态。一个最简单的例子是绘制天空盒,它可以通过调用DrawSkybox方法来完成。

		base.Render(renderContext, cameras);

		renderContext.DrawSkybox();

DrawSkybox要求相机作为参数,我们会简单的使用cameras中的第一个元素。

		renderContext.DrawSkybox(cameras[0]);

我们在游戏窗口中依然看不见天空盒显示。这是因为我们发到上下文的指令在缓冲。实际的工作发生在我们由Submit方法提交执行之后。

		renderContext.DrawSkybox(cameras[0]);

		renderContext.Submit();

天空盒最终呈现在游戏窗口,你也可以看见它出现在frame debugger中。

帧调试器显示天空盒被绘制

2.2 相机

我们提供了一系列相机,因为场景中会可以存在多个相机,它们都会被渲染。例如利用多相机设置多人分屏,小地图以及后视镜。每个相机都需要单独处理。

现在我们不用担心多个相机支持我们的管线。我们将简单地创建一个另一种渲染方法其作用于单个相机。用它来绘制天空盒并提交。所以我们要提交每个相机。

	void Render (ScriptableRenderContext context, Camera camera) {
		context.DrawSkybox(camera);

		context.Submit();
	}

为camers数组的每个元素调用新方法。这个例子中我使用一个foreach循环,因为Unity的管线也是用这个方法来循环相机数组。

	public override void Render (
		ScriptableRenderContext renderContext, Camera[] cameras
	) {
		base.Render(renderContext, cameras);

		//renderContext.DrawSkybox(cameras[0]);

		//renderContext.Submit();

		foreach (var camera in cameras) {
			Render(renderContext, camera);
		}
	}
foreach循环是是如何工作的?
foreach (var e in a) { ... }相当于
for (int i = 0; i < a.Length; a++) { var e = a[i]; ... }假设a是一个数组。唯一的功能性区别在于我们不可以获取迭代器变量i。
当a不是一个数组而是其他可枚举类型,迭代器会起作用并且最后会创建临时对象,这最好是要避免的。但是使用foreach对数组是安全的。
使用var定义元素变量是常见的,所以我使用它。它的类型是a的元素类型。

注意当前相机朝向不影响天空盒的渲染。我们传递相机到DrawSkybox,但是它只决定天空盒是否会被渲染,这是由相机的clear flag控制的。

要正确渲染天空盒以及整个场景,我们需要设置view-projection矩阵。这个变换矩阵组合了相机的位置和朝向的view矩阵,以及相机是透视或正交投影的projection矩阵。你可以在frame debugger中看见这个矩阵。它就是unity_MatrixVP,当有东西被绘制时所使用的shader属性之一。

目前,unity_MatrixVP矩阵总是相同的。我们需要使用SetupCameraProperties方法设置相机的属性到上下文中。这会设置矩阵以及其他的属性。

	void Render (ScriptableRenderContext context, Camera camera) {
		context.SetupCameraProperties(camera);

		context.DrawSkybox(camera);

		context.Submit();
	}

现在天空盒被正确的渲染了,在游戏窗口和场景窗口中都考虑到了相机属性。

2.3 命令缓存

上下文会推迟实际的渲染直到我们提交它。在这之前,我们对其配置并添加命令以供之后执行。有些任务像是绘制天空盒可以由专用的方法发出,但是其他的指令必须要由独立的命令缓存间接地发送。

命令缓冲可以被实例化一个新的CommandBuffer对象所创建,这个被定义在UnityEngine.Rendering名称空间中。在可编程渲染管线加入之前命令缓存就已经存在了,所以它不是实验性的。在我们绘制天空盒之前创建这样的一个缓存。

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Experimental.Rendering;

public class MyPipeline : RenderPipeline {

	

	void Render (ScriptableRenderContext context, Camera camera) {
		context.SetupCameraProperties(camera);

		var buffer = new CommandBuffer();

		context.DrawSkybox(camera);

		context.Submit();
	}
}

我们可以使用ExecuteCommandBuffer方法命令上下文执行缓存。再说明一下,上下文不会立即执行命令,而是复制他们到上下文的内部缓存中。

		var buffer = new CommandBuffer();
		context.ExecuteCommandBuffer(buffer);

在Unity引擎的native level中,命令缓存声明了资源用于储存它们的命令。如果我们不再需要这些资源,那最好立即释放他们。这个可以通过在调用ExecuteCommandBuffer之后直接调用缓存的Release方法完成。

		var buffer = new CommandBuffer();
		context.ExecuteCommandBuffer(buffer);
		buffer.Release();

执行一个空命令缓存没有任何作用。我们添加它以便我们清除render target,以确保渲染没有被之前的绘制所影响。这可以由命令缓存执行,但不可以直接由上下文执行。

清除命令可以调用ClearRenderTarget来添加到缓存中。它要求三个参数:两个booleans和一个color,并且如果使用了第三个参数的话,它是清除的颜色。举例,让我们清除清除深度数据,忽略颜色数据,并使用Color.clear作为清除颜色。

		var buffer = new CommandBuffer();
		buffer.ClearRenderTarget(true, false, Color.clear);
		context.ExecuteCommandBuffer(buffer);
		buffer.Release();

frame debugger现在会向我们显示清除render target的命令缓存被执行了。在这个案例中,它指定Z和stencil被清除了。Z指的是depth buffer,而stencil buffer始终被清除的。

清除depth和stencil缓冲

每个相机通过它们的clear flags和background color配置决定被清除的内容。我们可以使用这些来代替硬编码指定清除render target。

		CameraClearFlags clearFlags = camera.clearFlags;
		buffer.ClearRenderTarget(
			(clearFlags & CameraClearFlags.Depth) != 0,
			(clearFlags & CameraClearFlags.Color) != 0,
			camera.backgroundColor
		);
clear flag是如果工作的?
CameraClearFlags是一个枚举类型,它可以被用来设置bit flags。它的值的每一个比特位被用来指定某些特性被开启或是关闭。
要从整个值中提取bit flag,值与所需标志位通过按位与(操作符 &)来组合。如果结果不为零,则设置这个标志。

因为我们没有给命令缓存命名,调试器显示的是默认名字,即Unamed command buffer。让我们相机名来代替,就是将它分配给缓存的name属性。我们将使用对象初始化器语法来完成。

		var buffer = new CommandBuffer {
			name = camera.name
		};
给命令缓存使用相机名
对象初始化器语法是如何工作的?
我们不用再写buffer.name = camera.name;作为独立的语句在调用构造函数之后。但是当创建新的对象时,你可以在构造函数指令中加一个代码块。然后你可以在块中设置对象的字段和属性,不用再显式地引用对象实例。并且,它明确地指出只有这些字段和域被设置后实例才可用。除此之外,它可以只有一个单独的语句的初始化,不再需要有许多参数变体的构造函数。
注意,我们省略了构造函数的空参数列表,这在使用对象初始化器语法时是被允许的。

2.4 剔除

我们可以渲染天空盒了,但是还不能渲染任何我们放在场景中的对象。我们只需要渲染相机能看见的而不是所有对象。从场景中的所有渲染器开始,然后剔除那些落在相机视锥体的之外的渲染器。

什么是渲染器?
它是一个组件附加在游戏对象上,将他们转变为可以贝渲染的东西。比较典型的是MeshRenderer组件。

找出可以被剔除的渲染器需要我们追踪多个相机的设置和矩阵,对于这个我们使用ScriptableCullingParameters结构体。可以用委托静态CullResults.GetCullingParameters方法来替代自己填充结构体。它需要一个相机作为输入并生成剔除参数作为输出。然而,他并不能返回参数结构体。相反,我们需要提供结构体作为第二个输出参数,在结构体前加上out。

	void Render (ScriptableRenderContext context, Camera camera) {
		ScriptableCullingParameters cullingParameters;
		CullResults.GetCullingParameters(camera, out cullingParameters);

		
	}
我们为什么要写out?
结构体是值类型,所以他们被作为普通的值来对待。他们不是拥有身份的对象,带有变量和仅保存对他们在内存位置的引用的字段。所以参数传递结构体会提供一个带有值的拷贝的方法。这个方法可以改变拷贝,但是对被拷贝的值没有影响。
当结构体参数被定义为输出参数,他就扮演一个类似于对象的引用,但是指向的是参数所在的内存栈。当方法改变参数时,它影响的是值,而不是拷贝。
out关键字告诉我们这个方法负责给参数设置正确的值,代替以前的值。

除了输出参数,GetCullingParameters也会返回它是否可以创建有效的参数。不是所有的相机设置都是有效的,结果就是退化,它不可以被用于剔除。所以如果它失败了,我们没有东西可以渲染并从Render中退出。

		if (!CullResults.GetCullingParameters(camera, out cullingParameters)) {
			return;
		}

一旦我们有剔除参数了,就可以用于剔除。通过调用同时带有剔除参数和上下文作为参数的静态CullResults.Cull方法来完成。结果就是一个包含了可见内容的信息的CullResults结构体。

在这个案例中,我们需要通过在它前面添加ref提供剔除参数作为引用参数。

                if (!CullResults.GetCullingParameters(camera, out cullingParameters)) {
			return;
		}

		CullResults cull = CullResults.Cull(ref cullingParameters, context);
为什么我们要写ref?
它就像out一样,除了在这个案例中,这个方法不要求给值赋值。并且无论谁调用这个方法都负责首先正确初始化这个值。所以它可以被用来树叶也可以用于输出。
为什么ScriptableCullingParameters是结构体?
这可能是一种优化尝试,这个想法是你可以创建多个参数结构体而不必担心内存分配。但是,ScriptableCullingParameters是一个非常大的结构体,这也是为什么引用参数被用在这里,再次出于性能的考虑。也许它开始时很小但是随着时间膨胀成大的结构体。现在,可复用的结构体实例可能是更好的方法,但是我们只能用Unity技术所决定的。

2.5 绘制

一旦我们知道什么是可见的,我们就可以继续渲染这些形状。这是通过在上下文调用DrawRenderers,以cull.visibleRenderers作为参数,告诉它使用哪些渲染器来完成的。除此之外,我们还必须提供绘制设置和过滤设置。这者都是结构体- DrawRendererSettingsFilterRenderersSettings确切来说,我们初始时使用它们的默认值。必须将绘图设置作为引用传递。

		buffer.Release();

		var drawSettings = new DrawRendererSettings();

		var filterSettings = new FilterRenderersSettings();

		context.DrawRenderers(
			cull.visibleRenderers, ref drawSettings, filterSettings
		);

		context.DrawSkybox(camera);
为什么是FilterRenderersSettings而不是FilterRendererSettings呢?
不知道。也许是打错了。

我们还没有看到任何对象,因为默认的过滤设置不包含任何内容。我们可以通过给FilterRenderersSettings的构造函数提供true参数来包含所有内容。这告诉它初始化自己,所以它包括所有内容。

var filterSettings = new FilterRenderersSettings(true);

此外,我们必须通过为其构造函数提供相机和shader pass作为参数来配置绘制设置。相机用于设置排序和剔除图层,而pass参数控制哪些shader pass用于渲染。

shader pass通过字符串来标识,该字符串必须封装在ShaderPassName结构体中。由于在我们的管线中只支持unlit材料,因此我们将使用Unity默认的unlit材质,通过SRPDefaultUnlit字符串来识别。

		var drawSettings = new DrawRendererSettings(
			camera, new ShaderPassName("SRPDefaultUnlit")
		);
不透明球体现在可见了。

我们只看到不透明的unlit形状出现,但是没有透明的unlit。但是,帧调试器显示unlit的形状都被绘制了。

所有unlit渲染器都被绘制出来。

它们确实被绘制,但由于透明shader pass不会写入深度缓冲区,因此它们最终会被天空盒覆盖。解决方案是延迟绘制透明渲染器,直到天空盒被绘制之后。

首先,将天空盒之前的绘制限制为仅使用不透明的渲染器。这通过将过滤设置中的renderQueueRange设置为RenderQueueRange.opaque来完成的,该设置涵盖了从0到2500(含2500)的渲染队列。

		var filterSettings = new FilterRenderersSettings(true) {
			renderQueueRange = RenderQueueRange.opaque
		};
只有不透明渲染器被绘制了。

接下来,改变队列范围至RenderQueueRange.transparent- 从2501到5000(含5000)-在渲染天空盒之后,然后再次渲染。

		var filterSettings = new FilterRenderersSettings(true) {
			renderQueueRange = RenderQueueRange.opaque
		};

		context.DrawRenderers(
			cull.visibleRenderers, ref drawSettings, filterSettings
		);

		context.DrawSkybox(camera);

		filterSettings.renderQueueRange = RenderQueueRange.transparent;
		context.DrawRenderers(
			cull.visibleRenderers, ref drawSettings, filterSettings
		);
不透明,天空盒然后透明。

我们在天空盒之前绘制不透明的渲染器以防止重复绘制。由于形状绘制总是在天空盒之前,所以我们先渲染形状就避免多余的工作了。那是因为不透明的shader pass会写入深度缓冲区,该缓冲区用于跳过稍后绘制的任何东西。

除了覆盖部分天空,不透明的渲染器最终也会相互遮挡。理想情况下,对帧缓冲区中的每个fragment,只有最靠近摄像机的那个会被绘制。因此,为了尽可能减少重复绘制,我们应该先绘制最近的形状。这可以通过在绘制之前对渲染器进行排序来完成,这是通过sorting flags来控制的。

绘制设置包含一个DrawRendererSortSettings类型的sorting结构体,其中包含sorting flags。在绘制不透明形状之前将它设置为SortFlags.CommonOpaque。这指示Unity按距离,从前到后以及其他一些标准对渲染器进行排序。

		var drawSettings = new DrawRendererSettings(
			camera, new ShaderPassName("SRPDefaultUnlit")
		);
		drawSettings.sorting.flags = SortFlags.CommonOpaque;

然而,透明渲染的工作方式不同。它将绘制的颜色与已经绘制的颜色相结合,因此结果显得透明。这需要从后到前的反转绘制顺序。对于这种情况我们可以使用SortFlags.CommonTransparent。

		context.DrawSkybox(camera);

		drawSettings.sorting.flags = SortFlags.CommonTransparent;
		filterSettings.renderQueueRange = RenderQueueRange.transparent;
		context.DrawRenderers(
			cull.visibleRenderers, ref drawSettings, filterSettings
		);

我们的管线现在能够正确地同时渲染不透明和透明的unlit物体。

3. 完善

能够正确地渲染只是拥有功能性管线的一部分。还有其他的东西需要去考虑,例如它是否足够快,不分配不必要的临时对象,以及与Unity编辑器更好的集成。

3.1 内存分配

让我们检查管线在内存管理方面是否表现良好,或者它在每一帧分配内存,是否频繁地触发内存GC。通过Window/Analysis/Profiler打开分析器并在Hierarchy模式下检查CPU占用数据。虽然你可以在编辑器中在play模式下打开分析器,但是最好是分析构建后的项目,通过创建一个development build并使它自动附加到分析器中,但是在这种情况下没法深度分析。

选择GC Alloc排序,你会发现每帧确实都分配了内存。其中有些不属于我们的控制范围,但是在管线的Render方法中分配了不少的字节。

最后的结果是剔除分配的内存最多。这个原因是CullResults虽然是个结构体,但是它包含了三个列表,它们都是对象。每次我们申请新的cull result时,最后都会为新的列表分配内存。所以CullResults是个结构体也没多大的好处。

幸好,CullResults有另外的Cull方法,它接受一个结构体作为引用参数,而不是返回个新的。这样列表就可以重复使用。我们需要做的就是把cull变成字段并把它提供给CullResults.Cull作为增加的参数,而不是将返回值分配给他。

	CullResults cull;

	

	void Render (ScriptableRenderContext context, Camera camera) {
		

		//CullResults cull = CullResults.Cull(ref cullingParameters, context);
		CullResults.Cull(ref cullingParameters, context, ref cull);
		
		
	}

另一个持续的内存分配原因是我们使用的相机的名字属性。每次我们给它值时,它从native code中获取名字数据,它必须要创建一个字符串,而这是一个对象。所以让我们将命令缓冲命名为Render Camera。

		var buffer = new CommandBuffer() {
			name = "Render Camera"
		};
使用常量缓冲名。

最后,命令缓冲它自己也是一个对象。幸好,我们可以创建一次命令缓存并复用它。使用cameraBuffer字段来替换局部变量。感谢对象初始化器语法,我们可以创建一个命名了的命令缓冲作为默认值。其他的改变就是我们需要清空命令缓冲而不是释放它,这个可以使用它的Clear方法。

	CommandBuffer cameraBuffer = new CommandBuffer {
		name = "Render Camera"
	};

	

	void Render (ScriptableRenderContext context, Camera camera) {
		

		//var buffer = new CommandBuffer() {
		//	name = "Render Camera"
		//};
		cameraBuffer.ClearRenderTarget(true, false, Color.clear);
		context.ExecuteCommandBuffer(cameraBuffer);
		//buffer.Release();
		cameraBuffer.Clear();

		
	}

完成这些更改后,我们的管线不再每帧创建临时对象了。

3.2 帧调试器采样

还有一件我们可以做的事是改进frame debugger显示的数据。Unity的管线显示事件的嵌套层次结构,但是我们的管线都在根一级。可以使用命令缓冲去开始和结束采样来构建一个层次结构。

让我们在ClearRenderTarget之前调用BeginSample,立即在它之后调用EndSample。每次采样必须都要包含开始和结束,而且都要提供完全相同的名字。除此之外,我发现最好使用和命令缓冲相同的名字来定义采样。总之,命令缓冲的名字经常被使用。

		cameraBuffer.BeginSample("Render Camera");
		cameraBuffer.ClearRenderTarget(true, false, Color.clear);
		cameraBuffer.EndSample("Render Camera");
		context.ExecuteCommandBuffer(cameraBuffer);
		cameraBuffer.Clear();
采样创建了一个层次结构。

我们现在看见一个Render Camera层次内嵌在命令缓冲的原生Render Camera中,而它又包含清空操作。但是可以更进一步,内嵌所有其他与相机有关的动作。这要求我们延后调用结束采样直到我们提交上下文之前。所以我们必须在此插入一个附加的ExecuteCommandBuffer,仅包含结束采样的指令。为此使用相同的命令缓冲,并在完成后再次清空它。

		cameraBuffer.BeginSample("Render Camera");
		cameraBuffer.ClearRenderTarget(true, false, Color.clear);
		//cameraBuffer.EndSample("Render Camera");
		context.ExecuteCommandBuffer(cameraBuffer);
		cameraBuffer.Clear();

		

		cameraBuffer.EndSample("Render Camera");
		context.ExecuteCommandBuffer(cameraBuffer);
		cameraBuffer.Clear();

		context.Submit();
嵌套的采样。

这看起来不错,除了清空动作内嵌在多余的Render Camera层级中,而所有其他的动作都直接处于根层级之下。我不确定为什么会发生这种事,但是可以通过在清除之后开始采样来避免它。

		//cameraBuffer.BeginSample("Render Camera");
		cameraBuffer.ClearRenderTarget(true, false, Color.clear);
		cameraBuffer.BeginSample("Render Camera");
		context.ExecuteCommandBuffer(cameraBuffer);
		cameraBuffer.Clear();
没有多余的的嵌套。

3.3 渲染默认管线。

因为我们的管线只支持unlit shaders,使用其他着色器的对象就不会被渲染,进而它们就不可见。虽然这是正确的,但是它隐藏了一些使用错误着色器的对象。如果我们使用Unity的error shader让它们可见的话,这会更好些。因此它们会显示为明显不正常的洋红色形状。为此,让我们添加一个专门的DrawDefaultPipeline方法,带有一个上下文和一个相机参数。我们会在最后调用它,在绘制了透明形状之后。

	void Render (ScriptableRenderContext context, Camera camera) {
		

		drawSettings.sorting.flags = SortFlags.CommonTransparent;
		filterSettings.renderQueueRange = RenderQueueRange.transparent;
		context.DrawRenderers(
			cull.visibleRenderers, ref drawSettings, filterSettings
		);

		DrawDefaultPipeline(context, camera);

		cameraBuffer.EndSample("Render Camera");
		context.ExecuteCommandBuffer(cameraBuffer);
		cameraBuffer.Clear();

		context.Submit();
	}

	void DrawDefaultPipeline(ScriptableRenderContext context, Camera camera) {}

Unity的默认surface shader有一个ForwardBase pass,它用来作为第一个前向渲染pass。我们可以使用它来识别那些拥有工作在默认管线下的材质的对象。通过新建绘制设置来选择这个pass以及和新建的过滤设置一起作用于渲染。我们不关心排序或者分离不透明与透明渲染器,因为反正它们都是无效的。

	void DrawDefaultPipeline(ScriptableRenderContext context, Camera camera) {
		var drawSettings = new DrawRendererSettings(
			camera, new ShaderPassName("ForwardBase")
		);
		
		var filterSettings = new FilterRenderersSettings(true);
		
		context.DrawRenderers(
			cull.visibleRenderers, ref drawSettings, filterSettings
		);
	}
渲染前向pass。

现在使用默认着色器的对象显示出来了。它们在frame debugger中也是可见的。

所有的对象都被渲染了。

因为我们的管线不支持forward base pass它们不会被正确地渲染。必要的数据没有被设置,因此所有那些依赖光线的对象最后都是黑色的。然而,我们应该用error shader来渲染它们。为此,需要一个error材质。为它添加一个字段。然后,在DrawDefaultPipeline开始时,创建error材质(如果它不存在的话)。这可以通过Shader.Find遍历Hidden/InternalErrorShader来完成,然后创建一个新材质使用此着色器。此外,将材质的hide flags设置为HideFlags.HideAndDonSave让它不会显示在project窗口中并且也不会和其他的asset一起被保存。

	Material errorMaterial;

	

	void DrawDefaultPipeline(ScriptableRenderContext context, Camera camera) {
		if (errorMaterial == null) {
			Shader errorShader = Shader.Find("Hidden/InternalErrorShader");
			errorMaterial = new Material(errorShader) {
				hideFlags = HideFlags.HideAndDontSave
			};
		}
		
		
	}

绘制设置的一个选项是覆盖渲染时使用的材质,通过调用SetOverrideMaterial。它的第一个参数是要使用的材质。第二个参数是要用于渲染的材质着色器的pass的索引。由于error shader只有一个pass,所以索引为0;

		var drawSettings = new DrawRendererSettings(
			camera, new ShaderPassName("ForwardBase")
		);
		drawSettings.SetOverrideMaterial(errorMaterial, 0);
使用error shader。

使用不被支持的材质的对象现在清楚地显示为错误的。但是这个只适用于Unity默认管线的材质,其着色器具有ForwardBase pass。我们可以使用不同的pass识别其他的内置着色器,特别是PrepassBase,Always,Vertex,VertexLMRGBM和VertexLM。

还好,可以通过调用SetShaderPassName来添加多个pass到绘制设置中。这个方法的第二个参数是名字。它的第一个参数是一个索引用于控制pass被绘制的顺序索引。我们不用关心这个,所以什么顺序都是可以的。pass由构造函数提供,它的索引总是为零,所以为添加的pass递增索引值。

		var drawSettings = new DrawRendererSettings(
			camera, new ShaderPassName("ForwardBase")
		);
		drawSettings.SetShaderPassName(1, new ShaderPassName("PrepassBase"));
		drawSettings.SetShaderPassName(2, new ShaderPassName("Always"));
		drawSettings.SetShaderPassName(3, new ShaderPassName("Vertex"));
		drawSettings.SetShaderPassName(4, new ShaderPassName("VertexLMRGBM"));
		drawSettings.SetShaderPassName(5, new ShaderPassName("VertexLM"));
		drawSettings.SetOverrideMaterial(errorMaterial, 0);

这覆盖了Unity到现在提供的所有着色器,它们应该满足在创建场景时指出使用不正确材质了。但是我们只需要在开发期间这样做,不是在构建中。所以让我们只在编辑器中调用DrawDefaultPipeline。一个办法是给方法添加Conditional特性。

3.4 条件代码执行

Conditional特性定义在System.Diagnostics名称空间中。我们可以使用这个名称空间,但不幸的是它还包含一个Debug类型,与UnityEngine.Debug冲突。由于我们只需要这个特性,可以使用别名来避免冲突。不使用完整的名称空间,我们使用指定的类型并分配一个有效的类型名。在这个情况下,我们将定义Conditional作为System.Diagnostics.ConditionalAttribute的别名。

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Experimental.Rendering;
using Conditional = System.Diagnostics.ConditionalAttribute;

添加特性给我们的方法。它要求一个字符串参数来指定符号。如果在编译期间定义了这个符号,那么这个方法调用将被正常地包含。但是如果符号没有定义,则这个方法的调用(包括所有的参数)都被忽略。就好像DrawDefaultPipelint(context, camera);代码好像不存在于编译期一样。

要仅在Unity编辑器编译时包含调用,需要依赖UNITY_EDITOR符号。

	[Conditional("UNITY_EDITOR")]
	void DrawDefaultPipeline(ScriptableRenderContext context, Camera camera) {
		
	}

我们可以再进一步,还在development builds中包含调用,仅排除release builds。为此添加DEVELOPMENT_BUILD作为额外的条件。

	[Conditional("DEVELOPMENT_BUILD"), Conditional("UNITY_EDITOR")]
	void DrawDefaultPipeline(ScriptableRenderContext context, Camera camera) {
		
	}

3.5 场景窗口里的UI

到目前为止,还有一件我们没有考虑到的事是Unity的游戏内UI。要测试它,添加一个UI元素到场景中,例如一个按键,通过GameObject/UI/Button。它创建一个带有button的canvas和一个event system。

结果是我们什么都不用做,UI就被渲染在游戏窗口。Unity替我们考虑到了。Frame debugger显示UI被独立地渲染了,以overlay的方式。

UI被绘制在屏幕空间中。

至少,当canvas被设置渲染在屏幕空间中就是这样的情况。当设置渲染到世界空间中时,UI和其他的透明对象一起被渲染。

UI在世界空间中。

尽管UI在游戏窗口中起作用,但是它在场景窗口中不显示。在场景窗口中UI总是存在于世界空间,但是我们需要手动将它注入场景中。通过调用静态ScriptableRenderContext.EmitWorldGeometruForSceneView方法来添加UI,当前的相机作为一个参数。这必须在剔除前调用。

		if (!CullResults.GetCullingParameters(camera, out cullingParameters)) {
			return;
		}

		ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);

		CullResults.Cull(ref cullingParameters, context, ref cull);

但是它也会在游戏窗口中再次添加UI。为了避免这种情况,我们只在渲染场景窗口时发出UI几何体。这种情况就是当相机的cameraType等于CameraType.SceneView时。

		if (camera.cameraType == CameraType.SceneView) {
			ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
		}

这仅在编辑器下起作用。条件编译确保EmitWorldGeometryForSceneView不会在构建编译期存在,这意味着现在我们尝试构建的话会得到一个编译错误。为了使它再次起作用,我们必须让代码有条件地调用EmitWorldGeometryForSceneView。把代码放在#if和#endif语句之间来实现。#if语句需要一个符号,就像Conditional特性。使用UNITY_EDITOER,代码仅在编辑器的编译期被包含。

	void Render (ScriptableRenderContext context, Camera camera) {
		ScriptableCullingParameters cullingParameters;
		if (!CullResults.GetCullingParameters(camera, out cullingParameters)) {
			return;
		}

#if UNITY_EDITOR
		if (camera.cameraType == CameraType.SceneView) {
			ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
		}
#endif

		CullResults.Cull(ref cullingParameters, context, ref cull);

		
	}

下个教程是自定义着色器

本章教程项目仓库

编辑于 2019-09-16 10:31