一起来写Unity渲染管线吧!一 搭建最基本的管线

从本章开始,我们要正式进入可编程渲染管线的学习和设计了。在这一节,我们会讲解以下内容:


  • Unity 可编程渲染管线的基础代码结构
  • Command Buffer
  • Render Texture和Render Target
  • 相机和渲染目标的关系

本小节包含以下案例:

  • Kata 1:蓝屏!

1.2.1 初始Unity可编程渲染管线

在之前的小节中,我们介绍了渲染管线的基本概念,以及可编程管线的优势所在。但是我们并没有告诉读者到底怎么对渲染管线进行编程。事实上,如果没有Unity这种封装好的API可供调用的话,编写渲染管线需要学习相当多的图形API知识,比如目前流行的Direct3D 11,以及未来的Direct3D 12和Vulkan。而Unity帮我们做好了大量的封装工作,因此我们只需要自己实现一个函数:Render(),就可以使用实现自己的渲染管线了,简单吧!

虽然如此,但是如果读者有精力去学习底层的图形API的话,对渲染管线的编程是有很大的帮助的。毕竟所有的图形绘制最终都会转化成对底层API的调用,如果读者能够从实现角度理解渲染管线的话,在性能达到瓶颈的时候也容易了解应该从哪里入手进行优化。

在Unity中,我们使用一个RenderPipeline来代表自己的渲染管线,并使用RenderPipeline.Render方法来代表绘制一帧的操作。一旦我们将自己的RenderPipeline对象注册到Unity中,Unity就会每一帧都调用我们自己的RenderPipeline来绘制整个画面。

接下来让我们用一个案例来搭建我们最初的渲染管线架构吧!

Kata 1:蓝屏!

蓝屏不仅是Windows用户的噩梦,也是我们渲染管线之旅的开始(笑)。蓝屏的管线只干了一件事:将屏幕清空成蓝色。但是为了让管线正常运转起来,我们必须要设置完所有的内容。这个有点像Hello World的程序可以在之后用于测试Unity是否支持我们的管线。

目标:搭建渲染管线,将屏幕清空为蓝色。

在Unity中新建两个脚本文件,命名为Kata01.cs和Kata01Asset.cs,输入以下内容:

Kata01.cs:

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

namespace Kata01
{
    public class CustomRenderPipeline : RenderPipeline
    {
        CommandBuffer _cb;

        //这个函数在管线被销毁的时候调用。
        public override void Dispose()
        {
            base.Dispose();
            if(_cb != null)
            {
                _cb.Dispose();
                _cb = null;
            }
        }

        //这个函数在需要绘制管线的时候调用。
        public override void Render(ScriptableRenderContext renderContext, Camera[] cameras)
        {
            base.Render(renderContext, cameras);

            if (_cb == null) _cb = new CommandBuffer();

            //对于每一个相机执行操作。
            foreach (var camera in cameras)
            {
                //将上下文设置为当前相机的上下文。
                renderContext.SetupCameraProperties(camera);
                //设置渲染目标的颜色为蓝色。
                _cb.ClearRenderTarget(true, true, Color.blue);
                //提交指令队列至当前context处理。
                renderContext.ExecuteCommandBuffer(_cb);
                //清空当前指令队列。
                _cb.Clear();
                //开始执行上下文
                renderContext.Submit();
            }
        }
    }
}

Kata01Asset.cs

using UnityEngine.Experimental.Rendering;

//在编辑器环境下,加载编辑器所需的资源操作
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.ProjectWindowCallback;
#endif

namespace Kata01
{
    public class CustomRenderPipelineAsset : RenderPipelineAsset
    {
#if UNITY_EDITOR
        [MenuItem("Assets/Create/Render Pipeline/Kata01/Pipeline Asset")]
        static void CreateKata01Pipeline()
        {
            ProjectWindowUtil.StartNameEditingIfProjectWindowExists(
                0, CreateInstance<CreateKata01PipelineAsset>(),
                "Kata01 Pipeline.asset", null, null);
        }

        class CreateKata01PipelineAsset : EndNameEditAction
        {
            public override void Action(int instanceId, string pathName, string resourceFile)
            {
                var instance = CreateInstance<CustomRenderPipelineAsset>();
                AssetDatabase.CreateAsset(instance, pathName);
            }
        }
#endif
        protected override IRenderPipeline InternalCreatePipeline()
        {
            return new CustomRenderPipeline();
        }
    }
}

将两个文件保存并编译以后,回到Unity,在项目文件窗口中空白位置单击右键->Create->Render Pipeline->Kata01->Pipeline
Asset,使用任意名称保存。

在Edit->Project
Settings->Graphics中找到Scriptable Render Pipeline Settings。如果没有找到,检查您的Unity是否为2018.1以上的版本。然后将刚刚创建的Asset拖进下面的插槽里。

如果以上步骤没有出错,您应该可以看到一个蓝屏出现:

如果看到了蓝屏,恭喜你,你成功让电脑蓝屏了,呸,成功搭建了你的第一条自己的Unity渲染管线!

我们做了什么?

到了分析源码的时候了!我建议读者先不要管Kata01Asset.cs里面的代码,在后面的每一个案例中,只要将Kata之后的数字改为相应的数字,就可以一直使用这个文件,直到我们在进阶教程中需要提供自定义渲染参数为止。现在,让我们把注意力放在Kata01.cs文件下。

首先注意到,我们声明了一个类CustomRenderPipeline,继承于RenderPipeline类。这是我们编写自己的渲染管线的起点,每一次需要自定义渲染管线的时候都需要继承于这个RenderPipeline类。在一个游戏里,可以写多条渲染管线,并且按照需要在它们之间切换。

其次,我们定义了一个CommandBuffer,将其作为指令的记录表。Unity使用“先记录,后执行”的策略实现渲染管线,就好比我们去餐馆吃饭,可能花了好长时间才把菜点完(比如选择困难症),然后一旦提交给厨房,会一次性把菜做好。Unity的延迟执行体现在CommandBuffer和ScriptableRenderContext(很快就会讲到)的设计中,这两个对象都充当我们的“菜单”。将需要执行的执行记录在菜单上以后,可以使用ScriptableRenderContext.ExecuteCommandBuffer和ScriptableRenderContext.Submit来提交CommandBuffer和ScriptableRenderContext。

然后我们声明了两个函数:Render和Dispose。Render函数用于每一帧执行所有的渲染,Dispose函数用于在不继续渲染的时候(例如我们切换到了另一个渲染管线)对当前管线进行现场清理。在Dispose函数里,我们简单地释放CommandBuffer(使用CommandBuffer的Dispose);而在Render函数里,我们执行所有的渲染。

Render函数接受两个参数:第一个是被称为ScriptableRenderContext的新概念,我们会在后面介绍,第二个是一个相机数组,包含了所有需要渲染的相机列表。我们一般需要针对列表里每一个相机运行一次管线,但是针对相机种类的不同,可能会使用不同的渲染流程。


Command Buffer和ScriptableRenderContext

大多数我们需要使用的渲染指令都必须记录在CommandBuffer中,包括上面的代码使用的ClearRenderTarget指令,这条指令的意思是清空当前设定的渲染目标(Render Target)。小部分代码需要直接在ScriptableRenderContext里执行,比如绘制天空球的操作。

CommandBuffer和ScriptableRenderContext都用于记录我们的指令,我们可以创建多个CommandBuffer用于记录不同的指令,但是ScriptableRenderContext只有一个,并且是Unity为我们提供好的。每一个CommandBuffer可以有自己的名称(使用name属性设置),可以记录各种命令,可以使用Clear方法被清空。所有的CommandBuffer最终都需要合并到ScriptableRenderContext中,并且在调用Submit方法的时候被一次性执行。

我们使用ScriptableRenderContext的ExecuteCommandBuffer方法将CommandBuffer中记录的命令“倒”进最终的渲染指令队列里,多个CommandBuffer会按照Execute的顺序被执行。CommandBuffer被执行以后命令不会被清空,需要手动调用Clear清空,这允许我们将一系列执行特定任务的指令都写在CommandBuffer里,然后重复使用这个Buffer来提交渲染指令。与此不同的是,ScriptableRenderContext会在每一次调用Submit以后都被清空。


Render Target和Render Texture

如果读者之前没有研究过图形管线,对渲染目标和渲染纹理的概念可能会有点陌生。在GPU管线中,除了Compute Shader之外,通常的流水线最终输出的一定是一张纹理(Texture),其中每一个像素(纹素)对应一个Pixel Shader或者Fragment
Shader的运算结果。这个纹理不能输出到空气里,因此我们必须要指定一张纹理(一块显存空间),然后通知GPU管线:“嘿,把最终的运算结果输出到这个纹理中!”。在这种情况下,我们就称目标纹理是当前GPU的渲染目标(Render Target)。

一个可以被用作是Render Target的纹理必须是一张Render Texture。事实上,不是所有的纹理都可以作为渲染目标的,由于将纹理设置为“可被写入”需要额外的GPU维护开销,因此我们必须在创建纹理缓存的时候就指定其是否可以被用于当作渲染目标。能够被当作渲染目标的纹理在Unity中被称为Render Texture,最常见的Render Texture就是我们的屏幕自带的Backbuffer(后台缓冲区),所有绘制在Backbuffer上的颜色信息都会被显示在显示器上;除此之外,我们也可以通过在Unity中选择Create->Render Texture来创建可以被用于渲染的纹理。在后面的章节中,我们也会教读者使用GetTemporaryRT创建临时的Render Texture。

一个Render Texture一般会有两种格式:Color和Depth。Color用于保存任何指定类型的数据,我们可以用Shader代码精确指定如何绘制Color信息,而Depth的用处则十分单一:只用于保存场景的深度信息和执行深度测试。如果使用类似D3D11这种原生API编写渲染管线的话,我们需要同时创建用于保存Color和Depth的Render Texture,然后分别将其绑定到渲染管线上作为渲染目标。但是在Unity中,只要我们创建了一个Render Texture,Unity就自动帮我们创建好了Color和Depth两张渲染纹理,因此只需要使用SetRenderTarget,一次就可以将两张纹理一起绑定到渲染管线上。

当然,Unity也为我们提供了自己管理Color和Depth的方法:我们可以在创建Render Texture时指定depthBufferBits为0,避免自动创建Depth纹理。在SetRenderTarget时,Unity为其提供了一个重载版的函数,可以分别设定Color和Depth使用的渲染目标纹理。

有细心的读者可能会发现,我们在调用ClearRenderTarget之前并没有设置渲染目标呀!说的很对,事实上,如果我们使用了SetupCameraProperties指令,则当前相机会被自动设置为渲染目标,而当前相机如果正好又用于显示给玩家看的话,那么渲染目标自然就是最终的屏幕Backbuffer了。


Render函数

我们一步步来分析Render函数的执行过程。整个Render函数结构可以拆分成以下的步骤:

  1. 准备渲染环境
  2. 针对每一个相机执行一次渲染。
  3. (可选的)清理现场。

在这个最简单的代码案例里,我们没有清理现场的需要,但是其它几个步骤都已经完整地体现了出来。

首先是准备环境,在这个案例里,准备环境只有判断Command Buffer是否有效,如果无效则创建一个新的。接着就进入了相机循环。

相机循环是整个渲染代码的核心。在调用渲染函数的时候,Unity就会把场景里所有激活的相机组成一个数组传入函数。通常情况下,我们需要为每一个相机的RenderTarget进行一次渲染。相机的RenderTarget有可能是直接渲染在屏幕上,也有可能是渲染一个离屏表面(off-screen surface)。在后期的章节中,我们将学会如何判断相机的类型,以执行不同的操作。但是现在,我们只有一个相机,这个相机直接将内容绘制在显示器上。

我们使用一个foreach循环处理所有的相机,对于每一个相机,一般有如下处理步骤:

  • 在渲染开始的时候使用SetupCameraProperties将RenderContext设置为当前处理的相机。
  • 在渲染结束的时候调用Submit,绘制所有的图形。

使用SetupCameraProperties会执行一系列步骤,比如将相机的RenderTarget设置为当前的渲染目标,设置相机的参数(FOV、远近裁剪平面等),设置Shader里常用的Model-View-Proj变换矩阵等。当然我们也可以不使用这个函数而手动设置参数,但是一般情况下,使用这个函数进行前期的准备工作可以为我们节省很多代码量。

在相机循环的最后调用Submit是必需的,否则这个相机的画面就不会被渲染。

接下来就只剩三行代码了,这三行代码十分容易理解:首先将渲染目标清空成纯蓝色,然后将CommandBuffer里的指令倒进渲染环境里(ExecuteCommandBuffer),最后清空CommandBuffer,因为它不会自动清空。在ClearRenderTarget中,前两个变量代表是否清空Depth和Color通道,我们可以单独指定清空哪个通道;第三个参数代表用于清空Color通道的默认颜色,第四个可选参数代表用于清空Depth通道的默认值(float),如果留空则为1.0f。

至此,我们就完成了第一个案例的全部代码分析。读者可以自己尝试使用另外的颜色清空渲染目标,并且尝试使用相机的背景色(Camera.backgroundColor)来清空RenderTarget。在下一节里,我们将实际开始绘制物体,并且编写自己的shader来配合管线工作了。

下一篇文章:一起来写Unity渲染管线吧!二 绘制没有光照的物体

编辑于 2018-04-20

文章被以下专栏收录