• 创建一个管线资源和实例
  • 剔除、过滤、排序、渲染
  • 保证内存干净
  • 提供一个好的编辑实践

这是关于unity的可编程渲染管线的第一个教程。这里假定你看过了Basics系列和Procedural Grid系列的教程。Rendering系列的前面一部分也很有用。

这个教程是基于Unity2018.3.0f2的

unity tailrender做管道 unity自定义管线_Unity


1. 创建一个管线

为了渲染一些东西出来,Unity必须决定在什么地方,什么时间使用什么设置来绘制什么形状的物体。这个过程根据要包含哪些效果可能非常复杂。光照、阴影、透明、后处理、体积光或雾等都需要以一个合适的顺序来出现在最终的图像内。这个过程就叫做渲染管线。

Unity2017支持两个预定义的渲染管线,一个用于前向渲染一个用于延迟渲染。同时也支持一个更早前 Unity5中引入的延迟渲染方法。这些都是固定管线,你可以通过开关或者重写部分管线功能,但是不能完全脱离这些管线的设置。

Unity2018加入了对可编程渲染管线的支持,使得从无到有设计一个管线成为可能,尽管依然需要在一些独立的步骤上依赖Unity,例如视锥体裁剪(因为这些功能使用C++实现会更加高效,而且几乎是固定的模式)。Unity2018引入了两个使用这个新的可编程渲染管线方式实现的管线,轻量管线和高清管线(LWRP 和HDRP)。这两个管线依然在预览阶段,而且可编程管线(SRP)的API依然标记为实验技术。但是在目前的时间节点上,SRP已经足够稳定到我们可以使用基于SRP创建自己的管线。

在这个教程中,我们将配置一个最小的管线,这个管线会绘制不受光的物体。一旦我们的管线可以工作,我们就能够在后面的教程中进行扩展,添加光照、阴影等高级效果。

1.1 项目设置

打开Unity 2018 ,创建一个标准3D项目,关闭统计服务。我们将创建自己的管线,因此不要选择LWRP或HDRP这两个管线。

工程打开之后,打开包管理器(Window/Package Manger)并移除所有默认包含进来的package。最终只留下Package Manager UI,这个包我们没法移除。

unity tailrender做管道 unity自定义管线_渲染管线_02

我们将使用线性颜色空间,到那时Unity2018默认使用的时伽马空间。在player setting面板(Editor/Project Setting/Player) 把颜色空间切换为Linear。

unity tailrender做管道 unity自定义管线_命名空间_03

我们将需要几个简单的材质来测试我们的管线。创建4个材质,第一个是反照率(Albedo)设置为红色的默认标准非透明材质。第二个是反照率设置为蓝色的并且有一定透明度的标准透明材质。第三个是使用Unlit/Color这个shader的设置为黄色的不受光材质。最后一个是使用Unlit/Transparnet 这个shader并且不做任何修改的材质,因此这个材质显示一个没有透明度的白色。

unity tailrender做管道 unity自定义管线_unity tailrender做管道_04

使用这个四个材质,创建一些物体放到到场景内。

unity tailrender做管道 unity自定义管线_渲染管线_05


1.2 Pipeline Asset

当前unity使用的是默认的前向渲染管线。为了使用自定义管线,我们需要在grpahics settings(Edit->Project Settings -> Graphics)窗口选择一个PipelineAsset

为了设置我们自己的管线,我们必须给Scriptable Render Pipeline Settings 这里添加一个管线资产。这种资产必须继承自RenerPipelineAsset,是一个ScriptableObject类型的对象。

为我们的自定义管线资产创建一个新的脚本,我们把自己的管线称为MyPiepline,因此它的资产叫做MyPipelineAsset,而且必须派生自RenderPipelineAsset,这个类型定义在UnityEngine.Experimental.Rendering 这个命名空间下。

using UnityEngine;
using UnityEngine.Experimental.Rendering;
public class MyPipelineAsset:RenderPipelineAsset{}

从命名空间可以看到这里的RenderPipelineAsset还是位于实验阶段,但是可以预见的是,在以后的某个时间结点,从会实验阶段转到一个正式的命名空间下,如果到时候没有API的修改,我们只需要修改下引用的命名空间即可。

这里设置PipelineAsset的主要目的就是以一种方式把一个进行渲染工作的管线对象实例给到Unity。这个Asset本身只是一个句柄和存储管线设置的地方。当然我们现在还没有任何的设置,因此我们需要做的就是给Unity一个能够拿到我们的管线对象实例的方法。这是通过重写InternalCreatePipeline方法来完成的。但是现在我们还没有定义自己的管线对象类型,因此我们先返回一个空值。

InternalCreatePipeline的返回类型是IRenderPipeline。这里的前缀I代表这是一个接口类型。

public class MyPipelineAsset : RenderPipelineAsset {
	protected override IRenderPipeline InternalCreatePipeline () {
		return null;
	}
}

接口就像一个类(Class),区别就是接口只提供了类的一个框架而没有对应的实现。接口内只能定义属性、事件、索引器和方法签名,而且都默认使用public修饰。任何从接口扩展出的类型都需要实现接口定义的方法。习惯上使用I作为接口类型名称的前缀。
因为接口不包含具体的实现,因此类或结构体可以继承自多个接口。如果多个接口碰巧定义了同样的方法,只需要存在这个方法的实现即可。而这对于类(即使是抽线类)是不可能的,因为这样会导致冲突。

现在我们需要添加一个这种类型的资产到我们的项目下,为了实现这一目的,我们为我们的MyPipelineAsset类添加一个CreateAssetMenu属性。

[CreateAssetMenu]
public class MyPipelineAsset : RenderPipelineAsset {}

这会为Asset/Create菜单添加一个新的选项。我们稍作修改,把这个操作放在Rendering子菜单下。我们通过设置这个属性的menuName参数为"“Rendering/MyPipeline”。

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

使用这个新的菜单来添加一个MyPieplineAsset到我们的项目下,命名为MyPipeline。

unity tailrender做管道 unity自定义管线_SRP_06

然后把它赋值给ScriptableRenderPipelineSettings。

unity tailrender做管道 unity自定义管线_命名空间_07

我们现在已经替换了默认的渲染管线,这个操作会改变一些内容。首先,graphics settings面板上的很多选项都消失了,unity会在一个消息面板上做出提示。其次,因为我们用一个无效的空值替换了默认的渲染管线,什么都不会渲染,包括game、scene、材质球预览等窗口,尽管scene窗口依然显示有一个skybox。如果此时打开frame debugger(window/Analysis/Frame Debugger)并且开启它,你会看到没有任何的绘制命令。


1.3 管线实例

为了创建一个有效的渲染管线,我们需要提供一个实现了IRenderPipeline接口的对象实例,就是对象实例去执行事实上的渲染过程。因此创建一个命名为MyPipeline的类:

using UnityEngine;
using UnityEngine.Experimental.Rendering;
public class MyPipeline : IRenderPipeline {}

尽管我们可以自己实现IRenderPiepline接口,大师直接从抽象类RenderPipeline进行派生会更加方便。这个抽象类已经提供了一个IRenderPipeline接口的基本实现:

public class MyPipeline : RenderPipeline {}

现在我们可以子InternalCreatePipeline方法里返回一个MyPipeline类型的实例对象。此时我们就有了一个有效的渲染管线,尽管它依然什么都没做。

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


2 渲染

管线对象进行每一帧的渲染操作。Unity做的事情就是调用这个管线的Render方法,同时会传入当前的context和相机。game 窗口是如此逻辑,编辑器下的Scene窗口和材质预览窗口同样如此。如何配置这些内容,并决定渲染哪些对象以及以怎样的顺序进行渲染就全部是我们的工作了。

2.1 Context

RenderPipeline已经有了一个IRenderPipeline中接口定义的Render方法的实现。它的第一个参数是render context ,是一个ScriptableRenderContext类型的结构体,是一个和底层C++代码沟通的桥梁。它的第二个参数是一个包含了所有待渲染相机的数组。

RenderPipeline.Render并不会绘制任何内容,但是会检测这个用于渲染的管线对象是否有效。如果无效,会抛出一个一场。我们将重写该方法,并调用基类实现来保证这个检测的逻辑依然存在:

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

我们就是通过这个renderContext来发送渲染命令到Unity来渲染物体以及控制渲染状态。一个最简单的实践就是绘制一个天空盒,可以通过调用DrawSkyBox方法实现:

base.Render(renderContext, cameras);
renderContext.DrawSkybox();

DrawSkyBox方法需要一个相机作为参数,因此我们简单的使用相机列表的第一个元素。

renderContext.DrawSkybox(cameras[0]);

但是我们依然没有在game窗口下看到天空盒。这是因为我们发起给当前context的命令被缓存下来,只有我们提交了当前的命令缓冲区,命令才会真正执行,这可以通过Submit方法实现:

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

最终在Game窗口下我们看到了天空盒,而且可以在帧调试器(Frame debugger)里看到绘制命令的调用:

unity tailrender做管道 unity自定义管线_命名空间_08

2.2 Cameras

注意到Render函数传入的参数里有一个相机数组,这是因为场景内可能存在多个需要渲染的相机。多相机的例子有,分屏多用户应用,小地图,镜子等。每一个相机都需要分开操作。

我们暂时不考虑多相机的问题。我们简单的创建另一个Render方法,这个方法只对单个相机进行操作。目前这个方法会绘制天空盒并且提交绘制命令。因此我们是每个相机提交一次绘制命令。

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

遍历相机数组,对每一个相机都调用一次这个新的Rener方法。

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);
		}
	}

运行我们的代码会发现,相机当前的朝向不会影响到天空盒的渲染。我们在调用DrawSkybox方法时传入了相机对象,但是那只是用来决定天空盒时候应该被绘制,这是通过相机的clear flags进行控制的。

为了正确的渲染天空盒以及整个场景,我们需要设置观察投影矩阵(view-projection matrix)。这个转换矩阵是用相机的位置和朝向生成的观察矩阵(view matrix)和相机的透视投影或者正交投影生成的投影矩阵(projection matrix)构成的。可以在帧调试器里看到这个矩阵,就是unity_MatrixVP,着色器(shader)用来绘制物体时的其中一个参数。

此时,这个unity_MatrixVP矩阵总是一样的。我们必须通过SetupCameraProperties方法来把相机属性应用到当前的渲染环境(context)下。这个方法会设置这个矩阵以及其他一些参数。

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

现在当我们把相机参数考虑在内之后,天空盒的绘制就是正确的了,不止在Game视图,Scene视图同样如此。

2.3 Command Buffer 命令缓冲

这个context会延迟执行真正的渲染操作直到我们提交了它。在那之前,我们都是配置和往context里添加命令以备之后的执行。一些任务,例如绘制天空盒可以通过一个特定的方法进行调用,但是其他一些命令必须间接通过一个单独的命令缓冲才能发起。

一个命令缓冲可以通过实例化一个新的CommandBuffer对象进行创建,这个对象定义在UnityEngine.Rendering命名空间。在可编程渲染管线出现之前命令缓冲就已经出现了,因此他们不是实验阶段的API。在我们绘制天空盒之前创建一个命令缓冲:

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方法通知context来执行这个命令缓冲。再次声明,这个操作并不是立即执行命令,只是把命令拷贝到context的内部缓冲区。

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

CommandBuffer会从Unity引擎的底层请求资源来存储他们的命令,因此如果我们不再需要谢谢资源之后,最好立即释放他们。这个可以通过调用Release方法来实现,通常是直接在调用ExecuteCommandBuffer之后就执行释放操作。

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

执行一个空的命令缓冲不会做任何事情。我们的目的是为了清空当前的渲染目标(render target),来确保当前的渲染不会受之前的渲染结果影响。这可以通过命令缓冲来完成,而不是直接通过context进行。

可以通过调用ClearRenderTarget方法来添加一个清空命令。它需要三个参数,分别是是否清空深度信息的布尔值,是否清空颜色信息的布尔值和清空所使用的颜色。例如我们清空深度数据,而忽略颜色数据,使用Color.clear来作为清空颜色。

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

帧调试器现在会显示一个命令缓冲被执行,它完成的操作是清空渲染目标。在我们的例子中,显示的是Z和stencil被空了。Z就是深度缓冲,stencil就是模板缓冲(stencil buffer)。

unity tailrender做管道 unity自定义管线_unity tailrender做管道_09

更明确的做法是,通过每个相机对clear falgs的配置执行这个清空操作。我们可以使用这些设置而不是硬编码来清空渲染目标。

CameraClearFlags clearFlags = camera.clearFlags;
buffer.ClearRenderTarget(
	(clearFlags & CameraClearFlags.Depth) != 0,
	(clearFlags & CameraClearFlags.Color) != 0,
	camera.backgroundColor
);

我们可以看到在帧调试器里看到的是一个Unnamed command buffer,这是因为我们没有给command buffer以指定的名字,因此就会默认显示我们的看到的名字。我们使用相机的迷城作为command buffer的名字,把相机名字赋值给缓冲的name字段即可。我们使用对象初始化器语法来完成这个操作:

var buffer = new CommandBuffer {
		name = camera.name
	};

unity tailrender做管道 unity自定义管线_Unity_10

对象初始化语法(objectinitializer syntax):注意当使用了对象初始化语法时,我们不需要调用空参数列表的构造函数(new 对象时后面跟的圆括号"()")。

2.4 Culling 裁剪

我们现在能够渲染天空盒但是还不能渲染场景内的物体。我们只会渲染哪些相机能够看到的物体而不是所有的物体。我们从场景内的所有渲染器开始,然后裁剪掉那些落在相机视锥体外部的渲染器。

什么是渲染器(Renderer)?渲染器是一个附加在game object上的一个组件,用于把他们转换为可以渲染的物体。通常是一个MensRenderer组件

为了知道哪些可以被裁剪掉,我们需要知道许多相机设置和矩阵,我们会使用ScriptableCullingParameters结构体来管理这些餐宿。我们可以把填充这个结构体的任务委托给静态方法CullResults.GetCullingParameters。这个方法使用一个相机作为输入参数,以裁剪参数作为输出。然而,不是通过返回值,而是通过标记为 out 的函数参数来进行输出:

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

这里使用out的意义?cullingParameters是一个结构体,结构体作为函数参数并被out 标记为输出参数时,结构体的行为就类似于一个对象引用,函数内操作的就是位于堆栈上的这个参数,而不是其拷贝。

除了这个输出参数,GetCullingParameters同样会返回是否成功创建了有效的裁剪参数。并不是所有的相机设置都是有效的。因此如果返回结果表明创建失败,我们就没有需要渲染的对象,可以直接退出Render方法。

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

有了裁剪参数之后,我们就可以执行裁剪操作了。通过调用静态方法CullResults.Cull并使用裁剪参数和context作为函数参数。函数返回值时一个CullResult结构,它包含了场景内的可见性信息。
在这里,我们必须通过使用ref关键字设置裁剪参数作为一个引用参数。

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

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

因为ScriptableCullingParameters是一个很大的结构体,而结构体作为函数参数会进行数据拷贝,因此这里使用ref关键字,避免掉内存拷贝,就是因为性能的原因。可能这个结构在开始是小的,只是随着时间推移越来越大。可重用的对象实例可能更适合,但是我们只能使用Unity Technologiest提供的方式。

2.5 Drawing

知道哪些内容是可见的之后,我们接下来可以渲染这些几何体了。通过调用context的DrawRenderers方法并使用cull.visibleRenderers作为参数来告诉它使用哪些渲染器(renderers)来进行渲染。除此之外,我们必须设置绘制设置(draw settings)参数和过滤设置(filter settings)。这两个设置都是结构体:DrawRendererSettings和FilterRenderersSettings,我们将使用他们的默认值进行初始化。绘制设置必须以引用的形式进行传递:

buffer.Release();
		var drawSettings = new DrawRendererSettings();
		var filterSettings = new FilterRenderersSettings();
		context.DrawRenderers(
			cull.visibleRenderers, ref drawSettings, filterSettings
		);
		context.DrawSkybox(camera);

为什么是FilterRenderersSettings而不是FilterRendererSettings?可能只是一个代码编辑错误。
我们依然什么都没看到,因为默认的过滤设置什么都没有包含。我们可以通过提供true作为FilterRenderersSettings的构造函数的参数来设置包含所有物体。

var filterSettings = new FilterRenderersSettings(true);

同样,我们必须通过提供相机和一个shader pass 作为draw setting的构造函数的参数。这个相机用来设置排序和裁剪层级(culling layers),而shader pass 控制使用那个shader pass进行渲染。
因为我们的管线只支持不受光的材质,我们使用unity的默认unlit材质,标记为"SRPDefaultUnlit"

var drawSettings = new DrawRendererSettings(
			camera, new ShaderPassName("SRPDefaultUnlit")
		);

unity tailrender做管道 unity自定义管线_unity tailrender做管道_11

我们能够看到不透明的不受光几何体出现了,但是没有透明的物体。然而帧调试器显示透明物体也进行了渲染。

unity tailrender做管道 unity自定义管线_命名空间_12

他们确实绘制了,但是因为透明着色器没有写入深度缓冲,他们最终被天空盒遮挡住了。解决方案就是在天空盒绘制之后绘制透明物体。

首先,限制天空盒前面的绘制只对不透明渲染器有效。通过设置filter setting 的 renderQueueRange参数为RenderQueueRange.opaque来达到这一目的。这会绘制把渲染队列位于0-2500之间的物体。

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

unity tailrender做管道 unity自定义管线_SRP_13

然后,在渲染天空盒之后,再次渲染一次,只是此次我们设置队列范围为RenderQueueRnage.transparnet来渲染2501-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
		);

unity tailrender做管道 unity自定义管线_Unity_14


unity tailrender做管道 unity自定义管线_SRP_15

我们在天空盒之前渲染非透明物体来避免overdraw。因为非透明物体会写入深度缓冲,可以用来跳过哪些在相比非透明物体离相机更远的像素(这里是天空盒和其他非透明物体)的绘制。

因此为了尽可能的降低overdraw,我们需要从近到远绘制非透明物体。而这可以通过设置sorting flags来进行控制。

draw settings包含一个类型为DrawRendererSortSettings的名为sorting结构体包含有sort flags。在绘制非透明物体之前,设置这个参数为SortFlags.CommonOpaque。这回告诉Unity对渲染器按从近到远进行排序。

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

然而,透明物体使用不同的策略。因为透明物体会混合已经填入颜色缓冲区的颜色和当前颜色来生成半透明效果。而这需要和非透明物体相反的顺序,从后往前或从远到近。我们可以使用SortFlags.CommonTransparnet来进行设置。

context.DrawSkybox(camera);

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

我们的渲染管线现在就可以正确渲染透明和非透明的不受光物体了。

3 优化

能够正确的进行渲染只是管线功能的一部分。其他包括,这个管线是否足够快,是否分配了不必要的临时对象,以及和Unity编辑器的集成情况。

3.1 内存分配

通过Profile窗口查看我们的管线在内存管理或者每一帧的内存分配问题。每一帧内的内存分配会触发频繁的GC。我们有几个可以修改的地方进行内存优化:
第一点, CullResult虽然是一个结构类型,但是其内部有三个List类型,这个是一个对象,每一次我们new 一个CullResults对象都会为新的list分配内存空间。因此CullResult作为一个结构体并没有什么优势。幸运的是CullResults有另一个Cull方法可以接受一个引用类型参数作为输出,这样我们就可以重用这个lists。

CullResults cull;
	…
	void Render (ScriptableRenderContext context, Camera camera) {
		…
		//CullResults cull = CullResults.Cull(ref cullingParameters, context);
		CullResults.Cull(ref cullingParameters, context, ref cull);
		…
	}
var buffer = new CommandBuffer() {
			name = "Render Camera"
		};

unity tailrender做管道 unity自定义管线_Unity_16

最后CommandBuffer自己本身就是一个对象类型。我们可以复用我们创建的CommandBuffer,每次渲染结束之后调用Clear方法而不是Release方法。

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 Sampling

我们要做的另一件事是优化在frame debugger里显示的内容。通过调用BeginSample和EndSample来设置sample的名称,开始和结束的名称必须一样,而且最好和定义这个sampleing的CommandBuffer保持一致。

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();

unity tailrender做管道 unity自定义管线_Unity_17

这里会发现这个clear指令嵌入在一个冗余的RenderCamera层级下,而其他所有的指令都直径二位于根层级下。我不确定为什么会这样,但是可以通过把BeginSample放在clear之后可以避免这个问题:

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

unity tailrender做管道 unity自定义管线_渲染管线_18

3.3 Rendering the Default Pipeline

因为我们的管线目前只支持不受光的着色器,哪些使用其他着色器的物体不会被渲染,他们会看不到。尽管这符合我们的预期,但是隐藏了场景内有物体使用了错误的着色器的事实。如果我们能够用unity的error shader来显示这些看不见的物体,就可以明显的提示我们场景内有材质使用了错误的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的默认表面着色器有一个ForwardBase pass。我们可以使用这个来识别那些使用了unity默认管线下的shader的物体。在DrawSetting里设置pass的名称,并且在filter setting里设置渲染所有物体。我们不关心出错的物体的排序和渲染顺序。

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
		);
	}

unity tailrender做管道 unity自定义管线_Unity_19

那些使用了默认的shader的物体现在可以看到了,而且可以从帧调试器中看到对他们的调用:

unity tailrender做管道 unity自定义管线_渲染管线_20

因为我们的管线不支持forward base pass,因此他们都渲染的不正确。一些shader内需要的数据都没有设置,导致依赖于光照数据的对象都显示为黑色。我们真正要做的是使用一个标志出错的材质渲染这些对象。

Material errorMaterial;

	…

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

draw settings 有一个方法SetOverrideMaterial,可以使用一个材质覆盖当前渲染所使用的材质。

var drawSettings = new DrawRendererSettings(
			camera, new ShaderPassName("ForwardBase")
		);
		drawSettings.SetOverrideMaterial(errorMaterial, 0);

unity tailrender做管道 unity自定义管线_unity tailrender做管道_21

现在我们可以对使用了ForwardBase pass的shader使用errorMaterial进行绘制,接下来我们添加对其他Unity内置管线的shader使用errorMaterial进行绘制的配置:

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);

我们显示粉色警告的做法只需要在编辑器下执行即可,因此我们添加一个Conditional属性到DrawDefaultPipeline方法来进行这样的一个限制。

3.4 Conditional Code Execution
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Experimental.Rendering;
using Conditional = System.Diagnostics.ConditionalAttribute;
[Conditional("DEVELOPMENT_BUILD"), Conditional("UNITY_EDITOR")]
	void DrawDefaultPipeline(ScriptableRenderContext context, Camera camera) {
		…
	}
3.5 UI in Scene Window

还有一个我们没有考虑到的点是Unity内置的UI。我们添加一个UI元素到场景中,例如一个Button,会创建一个画布和一个位于画布上的Button以及一个event system。
结果显示game窗口下UI可以正常显示,我们不需要做任何事情。帧调试器显示使用Overlay模式时, UI和其他3D几何体是分开渲染的。

unity tailrender做管道 unity自定义管线_命名空间_22

当使用worldSpace模式时,UI会和其他透明3D几何体一块渲染。

unity tailrender做管道 unity自定义管线_Unity_23

尽管UI在Game窗口下正常工作了,但是却没有在Scene窗口显示出来。UI一直存在于scene窗口下的世界空间内,只是我们必须手动把他们注入这个场景内。通过调用静态方法ScriptableRenderContext.EmitWorldGeometryForSceneView可以把UI加入指定相机绘制的内容里。必须在视锥体裁剪之前进行调用。

if (!CullResults.GetCullingParameters(camera, out cullingParameters)) {
			return;
		}
		ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
		CullResults.Cull(ref cullingParameters, context, ref cull);

上面的代码会把UI在game窗口里显示两次。为了避免这一问题,我们设置只在scene场景下加入这个UI 几何体。通过判断相机类型可以实现这一目标,因为scene场景的相机的类型就是SceneView:

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

这种方式在编辑器下工作良好,但是如果我们尝试打包build我们的场景,就会有一个编译错误,因为EmitWorldGeometryForSceneView这个API只在Editor模式下工作,因此我们通过宏控进行预编译设置。

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);

		…
	}