Unity3D CustomSRP[译].1.自定义渲染管线[Custom Render Pipeline]

Custom Render Pipeline(自定义渲染管线)

——控制渲染


本节内容

  • 创建一个渲染管线资源和实例
  • 渲染相机的视图
  • 执行裁剪、过滤及排序
  • 分离非透明、透明、以及无效的通道
  • 使用多个相机



这是一个关于如何创建一个Custom SRP的系列教程的第一个部分,它包含了一个最基本的渲染管线的创建,我们会在之后的章节中陆续扩展它。

这个教程使用的Unity版本是2019.2.6f1.



使用自定义渲染管线进行渲染

(ps:文章总被吞…最后偶然看到可能会被吞的一些词儿…尝试改了点但有些意思感觉不到位~)


其他的SRP系列呢?
.
我有另一个涵盖了可编程渲染管线的教程,但它使用的SRP的API只适用于Unity2018版本,而这个系列则为Unity2019及之后的版本。本系列采用一种不同的、更贴近现在技术潮流的方法,但有许多相同的主题都会被涵盖到。如果你等不及本系列教程的更新节奏的话,Unity2018系列教程仍旧对工作很有帮助,直到这个系列教程的更新能够赶上进度。


1. 一个新的渲染管线(A new Render Pipeline)

为了渲染任何东西,Unity需要去决定绘制什么形状,在什么地方,什么时候,使用了什么设置——这使得整个渲染过程变得非常复杂,其复杂程度主要取决于有多少东西产生了影响:灯光、阴影、透明度、图像效果、体积效果……这些都必须按照正确的顺序处理,才能得到最终的图像——这就是渲染管线所做的。

在过去,Unity只支持一些内置的渲染方式(built-in RP)。在Unity2018版本中则引入了可编程渲染管线(SRP)——它使得我们实现自己想要的一切成为了一种可能,在此同时却仍然能够依赖于Unity本身的一些基础功能例如裁剪。在Unity2018中还基于这种新的方式添加了两种试验性质的渲染管线:轻量级渲染管线(LWRP,Unity 2019.3 后变更为URP)和高清渲染管线(HDRP)。而在Unity2019.3LWRP就不再是试验性质的了,更名为通用渲染管线(URP)。

URP注定要取代当前遗留的管线从而作为默认选项。原因是,它是一个能适应大多数选择的渲染管线,而且能很轻松的客制化定制。本系列教程将从头开始创建一个完整的渲染通道,而不是自定义一个如上所述的渲染通道。

这个教程将以最简单的渲染管线为基础,使用正向渲染绘制无光照的几何形状。如果这一步完成了,我们就可以在之后的教程中逐渐扩展我们的渲染管线,例如添加照明、阴影、采用不同的渲染方法、和更高级的功能和特性。

1.1 工程设置(Project Setup)

在Unity 2019.2.6或更高版本中创建一个新的3D项目。我们将创建自己的管线,所以不要选择任何一个项目模板。当项目打开后,您可以到包管理器里删除不需要的所有包。在本教程中,我们将只使用Unity UI包来尝试绘制UI,所以你可以保留那个包。

我们将专门在线性颜色空间中工作,但Unity 2019.2仍然使用伽马空间作为默认值。通过Edit/Project Settings/Player/Other Settings进入设置,切换Color Spacelinear

颜色空间设置为线性空间

创建一些不同的对象来填充默认场景,例如使用标准的、无光照的不透明的或者透明的材质。Unlit/Transparent着色器需要一个纹理才生效,所以这里提供一个UV球面映射图

UV球面映射透明贴图

我在测试场景中放置了几个立方体,它们都是不透明的。红色的使用标准着色器(Standard shader),而绿色和黄色的使用了采用Unlit/Color着色器的材质。蓝色的球体使用标准着色器,渲染模式设置为透明(Transparent),而白色的球体使用Unlit/Transparent着色器。

测试场景


1.2 管线资源(Pipeline Asset)

目前,Unity使用了默认的渲染管线,我们需要用自定义渲染管线来替换它,但我们首先要为它创建一个资源。我们将采用Unity为了URP使用的大致相同的文件夹结构。创建如下图所示的文件夹结构,并在Runtime文件夹下创建一个名为CustomRenderPipelineAsset的c#脚本。

文件夹结构

这个脚本必须继承于UnityEngine.Rendering命名空间下的RenderPipelineAsset对象。

using UnityEngine;
using UnityEngine.Rendering;

public class CustomRenderPipelineAsset : RenderPipelineAsset {}

RenderPipelineAsset的主要作用是让Unity能够获得一个负责渲染的管线对象实例。CustomRenderPipelineAsset本身只是一个句柄和存储设置的工具。我们还没有进行任何的设置,所以我们接下来要做的就是给Unity一个获取渲染管线对象实例的方法。这就需要通过重写RenderPipelineAsset里定义的抽象方法CreatePipeline(),该方法应该返回一个RenderPipeline实例。但是我们现在还没有定义自定义的RenderPipeline类,所以先返回一个null

CreatePipeline()是用受保护的访问修饰符protected定义的,这意味着只有定义和扩展RenderPipelineAsset类才能访问它。

protected override RenderPipeline CreatePipeline () {
    return null;
}

现在我们需要将CustomRenderPipelineAsset添加到项目中。要做到这一点,可以在CustomRenderPipelineAsset开头添加一个CreateAssetMenu属性。

[CreateAssetMenu]
public class CustomRenderPipelineAsset : RenderPipelineAsset { … }

这将在Asset/Create菜单中添加一个选项, 让我们把它放到Rendering子菜单中。我们将CreateAssetMenumenuName属性设置为Rendering/Custom Render Pipeline。可以在CreateAssetMenu之后的圆括号内直接设置这个属性。

[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset { … }

使用新的菜单项将CustomRenderPipelineAsset添加到项目中,然后转到Edit->Project settings->Graphics设置中,并在Scriptable Render Pipeline settings下选择它。

Custom RP被选中

替换默认的渲染管线改变了一些东西。首先,许多选项从Graphics设置中消失了,这在信息面板中也提示了。其次,我们禁用了默认的渲染管线,而没有提供一个有效的替换选择,因此它不再渲染任何内容。游戏窗口、场景窗口和材质预览不再具备任何功能。如果你打开帧调试器(Window/Analysis/Frame Debugger)并启用它,你会发现在游戏窗口中没有绘制任何内容。


1.3 渲染管线实例(Render Pipeline Instance)

创建一个CustomRenderPipeline类,并将其脚本文件放在与CustomRenderPipelineAsset相同的文件夹中。这将是我们的CustomRenderPipelineAsset返回的渲染管线实例类型,因此它必须继承自RenderPipeline

using UnityEngine;
using UnityEngine.Rendering;

public class CustomRenderPipeline : RenderPipeline {}

RenderPipeline定义了一个受保护的抽象方法Render,我们必须重写它来创建一个具体的管线实例。它有两个参数:ScriptableRenderContextCamera[],我们暂时将该方法内部保留为空的。

protected override void Render (ScriptableRenderContext context, Camera[] cameras) {}

使CustomRenderPipelineAsset.CreatePipeline()返回一个新的CustomRenderPipeline实例。这将为我们提供一个有效且实用的管线,尽管它到现在为止还没有渲染任何东西。

protected override RenderPipeline CreatePipeline () {
    return new CustomRenderPipeline();
}


2. 渲染(Rendering)

Unity会在每一帧中调用一次渲染管线实例中的Render()方法。它传递一个ScriptableRenderContext结构,提供到本地引擎的链接,我们可以使用这个接口进行渲染。它还会传递一个相机数组,因为场景中可能有多个激活的相机。它是RenderPipeline提供的负责按顺序渲染所有相机的一个接口。

2.1 相机渲染(Camera Renderer)

每个相机都是独立渲染的, 因此,我们不会让CustomRenderPipeline渲染所有的相机,而是把这个职责交给单独提供提供相机渲染功能的一个新类。我们将它命名为CameraRenderer,并给它提供一个带有ScriptableRenderContextCamera类型参数的公共的Rendert()方法。为了方便起见,我们将这些参数存储在字段中。

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 实例,然后使用Render()方法来循环渲染所有的相机。

CameraRenderer renderer = new CameraRenderer();

protected override void Render (ScriptableRenderContext context, Camera[] cameras){
    foreach (Camera camera in cameras) {
        renderer.Render(context, camera);
    }
}

我们的相机渲染器大致相当于URP的可编程渲染器。这种方法将使未来支持每个相机的不同的渲染方法变得简单,例如一个用于第一人称视角,一个用于3D地图叠加,或者正向渲染和延迟渲染。但是仅现在而言,我们将以相同的方式渲染所有的相机。

2.2 绘制天空盒(Drawing the Skybox)

CameraRenderer.Render()的工作是绘制相机可以看到的所有几何图形。提供一个单独的DrawVisibleGeometry()方法使该目标和代码干净整洁。首先,我们将让这个方法绘制默认的天空盒(SkyBox),这可以通过调用ScriptableRenderContext中提供了Camera作为参数的DrawSkybox()方法。

public void Render (ScriptableRenderContext context, Camera camera) {
    this.context = context;
    this.camera = camera;

    DrawVisibleGeometry();
}

void DrawVisibleGeometry () {
    context.DrawSkybox(camera);
}

这些操作还不会使天空盒出现,这是因为我们向context发出的命令被缓冲了。我们必须通过在context上调用submit()来提交排序好的指令用以执行。让我们在一个单独的Submit()方法中做这件事,在DrawVisibleGeometry()之后调用它。

public void Render (ScriptableRenderContext context, Camera camera) {
    this.context = context;
    this.camera = camera;

    DrawVisibleGeometry();
    Submit();
}

void Submit () {
    context.Submit();
}

天空盒最终出现在游戏(Game)和场景(Scene)窗口中。当启用天空盒时,你还可以在帧调试器(Frame Debugger)中看到有关它的条目, 它被列为Camera.RenderSkybox,在它下面有一个单独的Draw Mesh项,它代表实际的绘制调用(DrawCall)。这对应于游戏窗口的渲染。帧调试器不显示其他窗口中的绘制项。

天空盒被绘制

注意,相机的朝向目前并不影响天空盒的渲染方式。我们将相机传递给DrawSkybox,但它只决定是否应该绘制天空盒,这是通过相机的clear flags来控制的。

为了正确地渲染天空盒和整个场景,我们必须建立视图投影矩阵(view-projection matrix)。这个变换矩阵将相机的位置和方向(视图矩阵)与相机的透视或投影矩阵结合在一起。它在着色器(Shader)中被称为unity_MatrixVP,是绘制几何图形时使用的着色器属性之一。当绘制调用被选中时,你可以在帧调试器的ShaderProperties部分中查看到这个矩阵。

此时,unity_MatrixVP矩阵总是不变的。我们必须通过SetupCameraProperties方法,将相机的属性应用到ScriptableRenderContext中。这个步骤装配了这个矩阵和其他一些属性。在调用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)

ScriptableRenderContext延迟了实际的渲染,直到我们提交它。在此之前,我们需要对它进行配置,并向它添加命令以供以后执行。有些指令(如绘制天空盒)可以通过专用接口执行,但其他指令必须通过单独的命令缓冲区间接发出。我们需要这样的缓冲来绘制场景中的其他几何体。

为了获得一个缓冲区,我们必须创建一个新的CommandBuffer实例对象。我们只需要一个缓冲区,所以为CameraRenderer创建一个默认的缓冲区,并在字段中存储对它的引用。然后为这个缓冲区命名,这样我们就可以在帧调试器中识别它。这样就可以实现相机渲染功能了。

const string bufferName = "Render Camera";

CommandBuffer buffer = new CommandBuffer {
    name = bufferName
};


对象初始化器语法是如何工作的?
.
这就好像我们写了buffer.name = bufferName;作为调用构造函数后的单独语句。但在创建一个新的对象时,可以将一个代码块附加到构造函数的调用中。然后,你可以在这个代码块中设置对象的字段和属性,而不必显式地引用对象的实例。它明确指出,实例应该在设置了这些字段和属性之后才被使用。除此之外,它还允许只允许一条语句的情况下进行初始化,例如,我们在这里使用的字段初始化,而不需要带有许多参数变体的构造函数。
.
请注意,我们省略了调用构造函数时的空的形参列表,在使用对象初始化器语法时这样做是可以的。



我们可以使用命令缓冲区来注入分析器采样,这些采样将同时显示在分析器(Profiler)和帧调试器(Frame Debugger)中。这是通过在适合的地方调用BeginSampleEndSample来完成的,在我们的例子中,这是在Setup()和`Submit()方法的开始位置。必须为这两个方法提供相同的采样名,我们将使用缓冲区的名字。

void Setup () {
    buffer.BeginSample(bufferName);
    context.SetupCameraProperties(camera);
}

void Submit () {
    buffer.EndSample(bufferName);
    context.Submit();
}

要执行缓冲区,需要使用缓冲区作为参数在context中调用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();
}

Camera.RenderSkyBox采样现在被嵌套在Render Camera中。

Render Camera 采样

2.4 清除渲染目标(Clearing the Render Target)

无论我们绘制什么,最终都会渲染到相机的渲染目标(RenderTarget)中,它可以是默认的帧缓冲,但也可以是渲染纹理。上一帧被绘制到目标的图像仍然存储在那里,这可能会干扰我们现在这一帧要渲染的图像。为了保证正确的渲染,我们必须清除渲染目标以去除它的旧内容。这是通过在命令缓冲区上调用ClearRenderTarget()方法来完成的,该一步应该放到Setup()方法中。

CommandBuffer.ClearRenderTarget()方法至少需要三个参数。前两个标志着是否应该清除深度和颜色数据,这两个都被设为true。第三个参数是用作清除的颜色,我们将使用Color.clear

void Setup () {
    buffer.BeginSample(bufferName);
    buffer.ClearRenderTarget(true, true, Color.clear);
    ExecuteBuffer();
    context.SetupCameraProperties(camera);
}

清理操作-嵌套的采样

帧调试器中现在出现了一个Draw GL项用于显示清除操作,它嵌套在一个额外的Render Camera项中。发生这种情况是因为ClearRenderTarget用命令缓冲区的名称囊括了示例中的清除操作。在开始我们自己的采样之前,我们可以通过清理操作来清除多余的嵌套。这将使两个相邻的Render Camera采样被合并。

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着色器绘制一个全屏四边形,该着色器被写入渲染目标,这不是清除操作最有效的方式。使用这种方法是因为我们需要在设置相机属性之前执行清理操作。如果我们交换这两个步骤的顺序,我们就得到了快速清除的方法。

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 裁剪(Culling)

我们现在可以看到天空盒,但看不到我们放入场景中的任何物体。我们将只渲染那些对相机可见的物体,而不是绘制每个对象。我们从场景中所有带有Renderer组件的对象开始,然后剔除那些处于相机视图锥体(view frustum)外的对象。

想要知道哪些东西可以被剔除,我们跟踪多个相机的设置和矩阵,我们可以使用ScriptableCullingParameters结构。我们可以调用cameraTryGetCullingParameters()方法,而不是自己填充这个结构的数据。它返回参数是否可以成功裁剪,如果失败的话可以避免相机设置后边的步骤(return)。为了获得参数数据,我们必须把它作为输出参数提供,在前面添加out。在一个单独的Cull()方法中执行这些操作,该方法返回true或'false'。

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(),如果Cull()返回false则中止它(return)。

public void Render (ScriptableRenderContext context, Camera camera) {
    this.context = context;
    this.camera = camera;

    if (!Cull()) {
        return;
    }

    Setup();
    DrawVisibleGeometry();
    Submit();
}

实际的剔除操作是通过调用ScriptableRenderContext中的Cull()方法来完成的,它会产生一个CullingResults结构体。在Cull()中,如果'camera.TryGetCullingParameters()'返回true,则执行上述裁剪操作,并将裁剪结果存储在一个字段中。在这种情况下,我们必须将剔除参数p作为引用参数传递,方法是在参数前面写上ref

CullingResults cullingResults;
…
bool Cull () {
    if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
        cullingResults = context.Cull(ref p);
        return true;
    }
    return false;
}


我们为什么要用ref关键字?
.
ref关键字的工作原理与out类似,只是在方法中不需要为参数赋值。调用含有ref参数方法的地方需要初始化ref参数的值。因此,它可以用于输入,也可以用于输出。
.
在这种情况下,ref关键字被用作优化手段,以防止传递相当大的ScriptableCullingParameters结构体的副本拷贝。它是一个结构体而不是一个对象,这就是另一个优化,以防止内存分配。


2.6 绘制几何体(Drawing Geometry)

一旦我们知道哪些东西是可见的,我们就可以继续渲染这些东西。这是通过调用context中的DrawRenderers()方法实现的。将筛选结果cullingResults作为参数,告诉它使用了哪个渲染器。除此之外,我们还必须提供绘制设置和过滤设置。这两个都是结构体——DrawingSettingsFilteringSettings
——我们将首先使用它们的默认构造函数,两者都必须通过引用(ref)传递。在绘制天空盒之前,在DrawVisibleGeometry()方法中做这些。

void DrawVisibleGeometry () {
    var drawingSettings = new DrawingSettings();
    var filteringSettings = new FilteringSettings();

    context.DrawRenderers(
        cullingResults, ref drawingSettings, ref filteringSettings
    );

    context.DrawSkybox(camera);
}

我们现在看不到任何东西,因为我们还必须指出哪种着色器通道(shader pass)是允许的。因为我们在本章节中只支持无光照着色器,我们必须为SRPDefaultUnlit Pass获取着色器标签ID,让我们这样做并将其缓存到一个静态字段中:

static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");

将它作为DrawingSettings构造函数的第一个参数,同时提供一个新的SortingSettings结构体的值。将相机传递给SortingSettings的构造函数,它将用于确定是采用正交排序还是基于距离的排序。

void DrawVisibleGeometry () {
    var sortingSettings = new SortingSettings(camera);
    var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);
    …
}

此外,我们还必须指出哪些渲染队列是允许的。将RenderQueueRange.all传递到FilteringSettings构造函数中。这样我们就包含了所有的队列。

var filteringSettings = new FilteringSettings(RenderQueueRange.all);

绘制无光照集合体

只有使用无光照着色器的可见对象会被绘制。所有的绘制调用都在帧调试器中列出,并在RenderLoop.Draw下分组。透明对象上发生了一些看起来很奇怪的事情,但先让我们看看对象的绘制顺序。下边这个由帧调试器显示的,您可以通过一个接一个地选择或使用箭头键来逐步执行绘制调用。

逐步查看帧调试器列表

绘制顺序看起来很杂乱。我们可以通过设置SortingSettingscriteria属性强制指定绘制顺序,让我们使用SortingCriteria.CommonOpaquecriteria赋值。

var sortingSettings = new SortingSettings(camera) {
    criteria = SortingCriteria.CommonOpaque
};

CommonOpaque排序.1

CommonOpaque排序.2

对象现在可以基本上从前到后绘制,这对于不透明对象非常理想。如果某些东西被绘制在其他东西的后面,它的隐藏片元可以被跳过,这可以加快渲染速度。CommonOpaque排序选项还考虑到了其他一些标准,包括渲染队列(RenderQueue)和材质(Materials)。

2.7 分别绘制不透明和透明几何体(Drawing Opaque and Transparent Geometry Separately)

帧调试器向我们展示了绘制透明对象的过程,但是天空盒的绘制会覆盖掉所有绘制在不透明对象后边的内容。天空盒被绘制在不透明的几何之后,所以它的所有被覆盖的片元可以被跳过绘制,但它同时也覆盖了透明几何体。这是因为透明着色器不写入深度缓冲区(depth buffer)。它们不会覆盖掉它们后边的内容,因为我们可以透过它们看到后边的内容。解决方法是先画不透明的物体,然后画天空盒,然后再画透明的物体。

通过将FilteringSettingsRenderQueueRange设置RenderQueueRange.opaque,我们可以将其作为DrawRenderers方法的的一项参数剔除透明对象。

var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);

然后,在绘制天空盒后再次调用DrawRenderers()方法。但在这样做之前,改变renderQueueRangeRenderQueueRange.transparent。还要将criteria更改为SortingCriteria.CommonTransparent并再次设置drawingSettings.sortingSettings。这改变了透明对象的绘制顺序。

context.DrawSkybox(camera);

sortingSettings.criteria = SortingCriteria.CommonTransparent;
drawingSettings.sortingSettings = sortingSettings;
filteringSettings.renderQueueRange = RenderQueueRange.transparent;

context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);

非透明-天空盒-透明.1

非透明-天空盒-透明.2

为什么渲染顺序改变了?
.
因为透明对象不会被写入深度缓冲区,所以对它们进行前后排序不会带来性能上的好处。但是当透明物体在视觉上互相交叉时,它们必须被从后往前绘制以正确地进行混合。
.
不幸的是,从后往前排序不能保证正确的混合,因为排序是针对每个对象的,并且只基于对象的位置。交叉现象和大型透明对象仍然可能产生不正确的结果。这个时候可以通过将几何图形切割成更小的多个部件来解决。


3. 编辑器下的渲染(Editor Rendering)

我们的渲染管线现在可以正确地绘制了无光照的物体,但我们可以做一些事情来改善在Unity编辑器中使用它的体验。

3.1 绘制老旧的着色器(Drawing Legacy Shaders)

因为我们的管道只支持无光照的pass,使用不同pass的对象不会被渲染,它们将不可见。虽然这种现象是正确的,但它隐藏了场景中一些对象使用了错误的着色器的真相。所以我们还是单独的渲染它们吧。

如果有人从默认的Unity项目开始,然后切换到我们的渲染管线,那么他们可能会在他们的项目中使用错误的shader。为了覆盖所有Unity的默认shader,我们必须为Always, ForwardBase, PrepassBase, Vertex, VertexLMRGBMVertexLMpass使用着色器标签id(shaders tag id)。在静态数组中跟踪这些数据。

static ShaderTagId[] legacyShaderTagIds = {
    new ShaderTagId("Always"),
    new ShaderTagId("ForwardBase"),
    new ShaderTagId("PrepassBase"),
    new ShaderTagId("Vertex"),
    new ShaderTagId("VertexLMRGBM"),
    new ShaderTagId("VertexLM")
};

在绘制可见的几何图形后边增加一个单独的方法,用于绘制所有不支持的shader,只是使用第一次pass通道。因为这些都是无效的pass,结果无论如何将是错误的,所以我们不关心其他的一些设置项。我们可以通过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
    );
}

我们可以通过调用drawingSettingsSetShaderPassName方法并使用绘制顺序索引和标签作为参数来绘制多个pass。从第二个pass开始,对数组中的所有pass都这样做,因为我们在构造SortingSettings时已经设置了第一个pass。

var drawingSettings = new DrawingSettings(
        legacyShaderTagIds[0], new SortingSettings(camera)
);
for (int i = 1; i < legacyShaderTagIds.Length; i++) {
    drawingSettings.SetShaderPassName(i, legacyShaderTagIds[i]);
}

标准着色器-渲染黑色

使用标准着色器的物体最终渲染出来了,但它们现在是纯黑色的,因为我们的渲染管线还没有为它们设置所需的shader属性。

3.2 错误材质(Error Material)

为了清楚地指出哪些对象使用了不支持的shader,我们将使用Unity的error shader绘制它们。用这个shader作为参数创建一个新材质,我们可以通过调用Shader.Find()方法并将Hidden/InternalErrorShader字符串作为参数来找到它。通过静态字段来缓存这个材质,这样我们就不会每帧都创建一个新的材质。然后将其分配到drawingSettingsoverrideMaterial属性。

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

使用洋红色渲染错误的shader

现在所有的无效对象都是可见的,而且显而易见这是错误的。

3.3 局部类(Partial Class)

在开发中绘制无效对象很有帮助,但这不适用于已发布的应用。所以让我们把所有CameraRenderer的仅编辑器有用的代码放在一个单独的partial类文件中。首先复制原来的CameraRenderer.cs脚本资源并将其重命名为CameraRenderer.editor

一个类-两个脚本资源

然后将原始的CameraRenderer转换为一个partial类,并从其中移除标签数组、错误材质和DrawUnsupportedShaders()方法。

public partial class CameraRenderer { … }


什么是局部类?
.
这是一种将类或结构定义分割为多个部分、存储在不同文件中的方法。唯一的目的是用于组织代码结构。典型的用例是将自动生成的代码与手工编写的代码分开。就编译器而言,它们都是同一个类定义一部分。



清理另一个partial类文件,使它只包含我们从另一个类中删除的内容。

using UnityEngine;
using UnityEngine.Rendering;

partial class CameraRenderer {
    static ShaderTagId[] legacyShaderTagIds = { … };
    static Material errorMaterial;
    void DrawUnsupportedShaders () { … }
}

编辑器的内容只需要存在于CameraRender.editor部分中,所以让它以UNITY_EDITOR宏为条件。

partial class CameraRenderer {
#if UNITY_EDITOR
    static ShaderTagId[] legacyShaderTagIds = { … };
    static Material errorMaterial;
    void DrawUnsupportedShaders () { … };
#endif
}

然而,在这里编译将失败,因为另一个partial脚本包含了调用DrawUnsupportedShaders()方法,但这个方法现在只存在于编辑器中。为了解决这个问题,我们让这个方法也变为局部的方法。为此,我们需要在方法签名前面加上partial关键字,这与抽象方法声明类似。我们可以在类定义的任何部分中这样做,所以让我们把它放在editor部分。完整的方法声明也必须用partial关键字进行标记。

partial void DrawUnsupportedShaders ();
#if UNITY_EDITOR
…
partial void DrawUnsupportedShaders () { … }
#endif

编译现在成功了,编译器将剔除所有没有完整声明的partial方法的调用。

我们可以让无效对象出现在开发版本中吗?
.
是的,你可以把条件编译宏更改为UNITY_EDITOR || DEVELOPMENT_BUILD。那么DrawUnsupportedShaders()也存在于开发版本中,但还是不会出现于发布版本中。
.
在本系列中,我将始终分离所有开发相关与仅编辑器相关的内容。


3.4 绘制线框(Drawing Gizmos)

目前,无论是在场景窗口还是在游戏窗口中,我们的RP都不会绘制线框,哪怕他是激活的。

没有gizmos 的scene窗口

我们可以通过调用UnityEditor.Handles.ShouldRenderGizmos来检查gizmos是否应该被绘制。如果是的话,我们必须调用contextDrawGizmos方法,将camera作为它的参数,第二个参数来指示应该绘制哪个GizmoSubset线框类型。GizmoSubset有两个子类型,分别用于前后图像效果。由于我们暂时还不支持图像效果,我们将两者都调用。声明一个新的仅编辑器生效的DrawGizmos()方法,并执行此操作。

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

scene窗口中绘制了gizmos


3.4 绘制Unity用户界面(Drawing Unity UI)

我们需要注意的另一件事是Unity的游戏内的用户界面(UI)。例如,通过GameObject/UI/Button添加一个按钮来创建一个简单的UI。它将显示在游戏窗口,而不是场景窗口。

Game窗口中的UI-Button


为什么我不能创建一个UI按钮?
.
你需要在你的项目中安装Unity UI包。


帧调试器告诉我们,UI是单独渲染的,而不是由我们的自定义RP渲染的。

frame debugger中的UI项

最起码,当画布(canvas)组件的渲染模式(Render Mode)设置为Screen Space - Overlay时是这样子的,这是默认设置。将其更改为Screen Space - Camera,并将它的Render Camera属性设置为主相机(main camera),这将使它成为透明几何渲染下的一部分。

在frame debugger中,Screen-space-camera UI 的显示

当UI在场景窗口中渲染时,它总是使用World Space模式,这就是为什么它通常会显得非常大。而且,虽然我们可以通过在scene窗口中编辑UI,但它并不会被绘制。

在scene窗口中的UI始终不可见.png

当为scene窗口渲染UI时,我们必须将UI渲染添加到世界中几何体的渲染中。我们使用camera作为参数,通过调用ScriptableRenderContext.EmitWorldGeometryForSceneView()来实现这一步。在一个新的仅editor生效的PrepareForSceneWindow()方法中执行这个操作。当scene cameracameraType属性等于CameraType.Sceneview时,我们执行上述函数。

partial void PrepareForSceneWindow ();
#if UNITY_EDITOR
…
partial void PrepareForSceneWindow () {
    if (camera.cameraType == CameraType.SceneView) {
        ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
    }
}

因为这可能会给scene添加几何体,所以必须在剔除操作之前完成。

PrepareForSceneWindow();
if (!Cull()) {
    return;
}

scene窗口中UI可见


4. 多相机(Multiple Cameras)

在场景中可能存在多个激活的相机。如果是这样的话,我们需要确保他们可以共同工作。

4.1 两个相机(Two Cameras)

每个相机都有一个Depth属性,默认主相机为−1。它们按照深度递增的顺序进行渲染。想要看到这个,可以复制Main Camera,重命名为Secondary Camera,并设置其Depth为0。给它设置另一个不同的标签(tag)也是一个好想法,因为MainCamera标签应该只被一个相机使用。

两个相机的采样被划分在一个采样区内

场景现在被渲染两次。但最终显示的图像仍然是一样的,因为渲染目标(render target)在这之间被清除了。frame debugger中显示了这个,但因为具有相同名称的相邻采样被合并了,导致我们最终只得到一个Render Camera采样区间。

如果每个相机都有自己的采样区间,那渲染过程就更清晰可见了。为了实现这个,添加一个仅editor生效的PrepareBuffer()方法,使缓冲区的名称等于相机的名称。

partial void PrepareBuffer ();
#if UNITY_EDITOR
…
partial void PrepareBuffer () {
    buffer.name = camera.name;
}
#endif

在我们为scene窗口做准备之前调用它。

PrepareBuffer();
PrepareForSceneWindow();

每个相机各自的采样区


4.2 处理缓冲区名称的变化(Dealing with Changing Buffer Names)

虽然帧调试器现在为每个相机单独显示了一个的采样结构列表,但当我们进入游戏模式(play mode)时,Unity的控制台将被填充警告(warning)消息,它警告我们BeginSampleEndSample的计数必须匹配。因为我们对采样和缓冲区使用了不同的名称,使它变得混乱。除此之外,每次访问cameraname属性时,我们都要分配内存,所以我们不应该在构建项目中这样做。

为了解决这两个问题,我们将添加一个SampleName字符串属性。如果我们在editor中,我们在PrepareBuffer()方法中一起设置它和缓冲区的名称,否则它只是常量bufferName的字符串的一个别名常量。

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

首先运行游戏,我们可以通过检查分析器(Window/Analysis/Profiler)来看到区别。切换到Hierarchy模式并按GC Alloc列排序。你将看到两个GC Alloc的项。总共分配100个字节(bytes),这是由检索相机名称引起的。再往下看,你会看到这些名称作为采样项出现:Main CameraSecondary Camera

采样器中分开的采样和100B的内存分配

接下来,选择File-Build Settings,勾选Development BuildAutoconnect Profiler,点击Build And Run并确保Profiler连接并录制。在这种情况下,我们没有看到之前100字节的GC ALLOC,只有一个简单的的Render Camera采样。

分析器采样结果


其余的48个字节分配给什么?
.
这是给相机数组用的,我们无法控制这个。它的大小取决于有多少相机被渲染。


通过将相机名包装在名为Editor Only的分析器采样中,我们可以更清晰地表示出我们只在编辑器中分配内存,而不是在构建模式中。要实现这个,我们需要调用来自UnityEngine.Profiling命名空间的Profiler.BeginSample()Profiler.EndSample()方法。只有BeginSample()方法需要传递采样器名。

…
using UnityEngine.Profiling;
…
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
}

清晰的Editor-Only的GC Alloc


4.3 层(Layer)

相机也可以配置为只看到特定层上的东西。这是通过调整他们的剔除遮罩(Culling Mask)来完成的。为了看到实际的效果,让我们把所有使用了标准着色器(standard shader)的对象移动到Ignore Raycast层。

切换到`Ignore Raycast`层

将该层从Main CameraCulling Mask中剔除。

剔除`Ignore Raycast`层

并使Ignore Raycast成为Secondary Camera唯一可以看到的层。

剔除`Ignore Raycast`之外的所有层

因为Secondary Camera最后渲染,我们最终只看到无效的对象。

游戏窗口中只有`Ignore Raycast`层的对象可见


4.4 清除标记(Clear Flags)

我们可以通过调整第二个被渲染的相机的清除标记来合并两个相机最终的结果。相机的清除标记由CameraClearFlags枚举定义,我们可以通过相机的clearFlags属性来获取。在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, DepthNothing。这些实际上不是单独的标记的值,但代表着清除量的减少。除了Nothing值,在flags的值不大于Depth的所有情况下都必须清除深度缓冲区(depth buffer)。

buffer.ClearRenderTarget(
    flags <= CameraClearFlags.Depth, true, Color.clear
);

我们只需要在flags设置为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
);

因为Main Camera是第一个渲染的,它的Clear Flags应该设置为SkyboxColor。当frame debugger启用时,我们总是从一个清除的缓冲区开始,但通常情况下这是不能保证的。

Secondary CameraClear Flags决定了两个相机的渲染如何合并。在设置为Sky BoxColor的情况下,之前的渲染结果完全被取代。当只有Depth被清除,Secondary Camera渲染正常,除了它不绘制一个skybox,所以以前的结果显示为相机背景。当什么都没有被清除时,depth buffer就会被保留,所以无光照的对象最终会遮挡住无效的对象,就好像它们是由同一个相机绘制的一样。然而,由前一个相机绘制的透明物体没有深度信息,所以它们被覆盖了,就像skybox之前做的那样。

清除-Color

清除-Depth

清除-Nothing

通过调整相机的Viewport Rect,也可以将渲染区域减少到整个渲染目标的一小部分,而渲染目标的其余部分则不受影响。在这种情况下,清除操作是使用Hidden/InternalClear着色器进行的。模板缓冲区(stencil buffer)被用于限制渲染到视口区域。

缩小Secondary Camera的视口,清除Color

需要注意的是,每帧渲染多个相机意味着需要多次进行剔除、设置、排序等操作。为每个独特的视图使用一个相机通常是最有效的方式。


下一个章节是 绘制调用(Draw Calls)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容