Unity自定义SRP(一):构建渲染框架

https://catlikecoding.com/unity/tutorials/custom-srp/

介绍

为了进行渲染,Unity会决定哪些图形会被绘制,会被绘制在何处,在什么时候绘制,以及用什么设置来绘制。这很复杂,包含许多效果,如灯光、阴影、透明效果、基于图片的效果、体积效果等。Unity除自带的内置渲染管线外,还提供了SRP,可让用户自定义渲染管线,官方据此给出了两种预制管线,URP和HDRP。这里会模仿URP制定一个渲染管线。

渲染管线

新建项目后先将颜色空间修改为Linear(Edit/Project Settings/Player/Other Settings),这是我们之后会使用的颜色空间。

管线资产

首先模仿URP的结构创建项目,新建一个Runtime文件夹,并新建一个CustomRenderPipelineAsset类。RP资产的目的是给让Unity得到一个pipeline对象实例,负责渲染。在类中我们需要重写抽象方法CreatePipeline,用于得到我们的渲染管线对象:

using UnityEngine;
using UnityEngine.Rendering;


[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset
{
    protected override RenderPipeline CreatePipeline()
    {
        return null;
    }
}

使用该脚本创建一个pipeline asset后,拖到项目设置的Graphic对应位置即可:


渲染管线实例

接着创建一个CustomRenderPipeline类,这是CreatePipeline会返回的实例类型。由于继承自RenderPipeline,我们需要重写Render方法:

using UnityEngine;
using UnityEngine.Rendering;

public class CustomRenderPipeline : RenderPipeline
{
    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {   
    }
}

同时修改CreatePipeline返回值:

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

渲染

Unity每帧都会在RP实例上调用Render方法,它有一个context结构体参数,其提供与原生引擎之间的连接,我们可以用来进行渲染,还有一个摄像机队列参数,我们可以按顺序渲染多个摄像机。

摄像机渲染器

每个摄像机独立渲染,因此这里建立一个CameraRenderer类,用于渲染单个摄像机:

using UnityEngine;
using UnityEngine.Rendering;

public class CameraRenderer
{
    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, useDynamicBatching, useGPUInstancing, shadowSettings);
        }
    }

绘制天空盒

CameraRenderer.Render的任务是绘制所有当前摄像机能看见的物体,我们将其归类到DrawVisibleGeometry方法中,这里首先绘制一个天空盒:

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

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

这样子还不能显示出天空盒,因为我们发布给context的命令是缓冲的,我们需要提交队列任务来执行,调用Submit方法:

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

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

这样的话就可以绘制天空盒了。如果我们打开frame debugger的话,会发现其名字是Camera.RenderSkybox,其子列表下有一个Draw Mesh命令。

此时摄像机的朝向还不能影响天空盒会被如何绘制。我们将camera传递到DrawSkyBox,但只决定了天空和是否被绘制,即清楚标志。为了正确的绘制天空盒,我们需要设置view-projection矩阵。我们通过SetUpCameraPeoperties方法将矩阵传递给context:

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

        Setup();
        DrawVisibleGeometry();
        Submit();
    }
    void Setup()
    {
        context.SetupCameraProperties(camera);
    }

命令缓冲

context会在我们提交时才会进行真正的渲染,在此之前,我们可以进行相关配置,添加一些待执行的命令。一些任务(如绘制天空和)可以直接通过相应的方法发布,但其它的命令需要通过单独的命令缓冲发布。

这里首先创建一个命令缓冲对象,并赋予一个名字,以便在frame debugger中看到:

    const string bufferName = "Render Camera";

    CommandBuffer buffer = new CommandBuffer
    {
        name = bufferName
    };

我们使用命令缓冲来注入profiler样本,这样就可以同时在profiler和frame debugger中看到:

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

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

为了执行缓冲,我们使用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();
    }

打开frame debugger就会看到Camera.RenderSkyBox内嵌在Render Camera中。

清除渲染目标

我们要渲染的东西会渲染到摄像机的渲染目标中,可以是帧缓冲也可以是渲染纹理。如果我们要渲染到的目标上有之前绘制的东西,我们当前要绘制的东西就会被干扰,因此我们需要去除旧的渲染内容,使用ClearRenderTarget方法。该方法要求至少三个参数,前两个标识是否清除深度和颜色数据,第三个是要用什么颜色来清除:

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

此时在frame debugger中就会看到一个内嵌的Draw GL,即清除这一行为的入口点,不过它内嵌在一个额外的Render Camera中,因为我们在一个命令缓冲的样本下清除的。我们可以先清除再开始我们的样本:

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

Draw GL本身是使用一个Hidden/InternalClear着色器来绘制一个屏幕四边形来完成的,其会写入到一个渲染目标中,这样的效率并不太高。之所以如此是因为我们是在设置摄像机前进行清除的,如果交换顺序就能高效完成:

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

此时查看frame debugger会发现Clear(color+Z+stencil),即清除颜色和深度缓冲。

剔除

为保证效率,我们只绘制摄像机看的到的物体。在绘制前,我们会剔除掉所有在视锥体外的物体。

我们通过跟踪多个摄像机的设置和矩阵来找到要剔除的物体,这里使用ScriptableCullingParameters结构体,我们可以使用摄像机的TryGetCullingParameters方法来填充该结构体:

    bool Cull()
    {
        if (camera.TryGetCullingParameters(out ScriptableCullingParameters p))
        {
            return true;
        }
        return false;
    }

Setup前调用:

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

        if (!Cull())
        {
            return;
        }

        Setup();
        DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
        Submit();
    }

Cull方法中,我们调用context的Cull方法:

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

绘制几何体

我们调用context的DrawRenderers方法来渲染一些东西,它将剔除的结果作为参数,告诉其要使用那个渲染器。除此之外,我们还要提供drawing settingsfiltering settings,对应DrawingSettingsFilteringSettings结构体:

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

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

        context.DrawSkybox(camera);
    }

仅仅如此还不能看见要绘制的集合体,因为我们还需要指示可以使用哪些shader pass。这里使用默认的:

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

该参数作为DrawingSettings结构体构造函数的参数,同时提供一个SortingSettings结构体变量,传入一个摄像机参数,其可以决定是使用正交还是基于距离的排序:

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

同时我们指示使用什么渲染队列:

        var filteringSettings = new FilteringSettings(RenderQueueRange.all);

我们可以创建多个物体来测试,部分物体应用带透明通道的纹理。打开frame debugger我们可以看到这些Draw Mesh命令内嵌在RenderLoop.Draw中。仔细看渲染顺序,会发现透明物体的显示有问题,这是因为目前场景中物体的渲染顺序是随机的,如果想正确显示透明物体,我们需要在所有不透明物体之后渲染。我们可以在sortingSettings的criteria属性中进行设置:

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

此时会发现物体或多或少地会根据从前往后的顺序绘制,但也只是针对不透明物体,透明物体仍不会正常显示。

单独绘制不透明和透明几何体

观察frame debugger的话,会发现透明物体其实绘制了,但天空盒会进行覆盖,所有不在不透明物体前的片段都会被剔除,这是因为透明物体的shader不会写入深度缓冲。因此我们手动先绘制不透明物体,然后是透明物体。

首先将初始的渲染队列调整为opaque:

        var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);

在绘制天空盒后再次调用DrawRenderers,在此之前调整渲染队列为transparent,同时调整渲染顺序,从后往前渲染:

        context.DrawSkybox(camera);

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

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

编辑器渲染

绘制Legacy Shader

目前的渲染管线只支持unlit shader pass。为了方便进行管线的转换,我们将Legacy Shader包含在内:

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

我们在绘制完所有的可见几何体后绘制所有的不支持的shader,并且只使用默认的渲染设置:

    public void Render(ScriptableRenderContext context, Camera camera)
    {
        ...
        Setup();
        DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
        DrawUnsupportedShaders();
        Submit();
    }

    public void DrawUnsupportedShaders()
    {
        var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera));
        var filteringSettings = FilteringSettings.defaultValue;
        context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
    }

我们在drawing settings上调用SetShaderPassName来绘制多个pass:

        for (int i = 1; i < legacyShaderTagIds.Length; i++)
        {
            drawingSettings.SetShaderPassName(i, legacyShaderTagIds[i]);
        }

错误警示材质

当物体上的材质错误时,会显示一个警示颜色:

    static Material errorMaterial;
    ...
    public void DrawUnsupportedShaders()
    {
        if (errorMaterial == null)
        {
            errorMaterial = new Material(Shader.Find("Hidden/InternalErrorShader"));
        }
        var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera))
        {
            overrideMaterial = errorMaterial
        };

Partial类

绘制非法物体只在编辑器中进行,因此我们将这些代码放到一个partial类中,创建一个CameraRenderer.Editor类:

using UnityEngine;
using UnityEngine.Rendering;

partial class CameraRenderer
{
    partial void DrawUnsupportedShaders();

#if UNITY_EDITOR

    static ShaderTagId[] legacyShaderTagIds = {...};

    static Material errorMaterial;

    partial void DrawUnsupportedShaders()
    {...}

#endif
}

绘制Gizmo

我们通过调用UnityEditor.Handles.ShouldRenderGizmos来判断是否绘制gizmo。我们调用DrawGizmos来绘制Gizmo,第一个参数为摄像机,第二个参数为绘制哪个gizmo子集:

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

绘制Unity UI

如果此时我们创建一个UI组件,会发现它只在Game窗口显示,不在Scene窗口显示。在frame debugger中会发现其被单独绘制,不由我们自己的RP绘制。

原因是此时画布组件的Render Mode被设置为Screen Space - Overlay,如果我们将其改为Screen Space Camera,并使用主摄像机为Render Camera,即可将其作为透明物体的一部分。

UI在设置为World Space模式时可以在场景窗口中显示。为了渲染UI,我们需要将其添加到世界物体中,通过调用ScriptableRenderContext.EmitWorldGeometryForSceneView,将摄像机作为参数:

    partial void PrepareForSceneWindow();
#if UNITY_EDITOR

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

在剔除操作前将其加入到场景的几何体中:

        PrepareForSceneWindow();
        if (!Cull(shadowSettings.maxDistance))
        {
            return;
        }

多个摄像机

两个摄像机

每个摄像机有一个深度值,默认的主摄像机的深度值为-1,它们按照深度值逐渐递增的顺序渲染。如果我们复制main camera,命名为Secondary Camera,将其深度值设为0,接着就会发现frame debugger中两个摄像机在同一个采样范围内,即场景渲染了两次。为了让每个摄像机有自己的采样范围,我们创建一个PrepareBuffer方法,其缓冲名为摄像机名:

    partial void PrepareBuffer();
#if UNITY_EDITOR
    ...
    partial void PrepareBuffer()
    {
        Profiler.BeginSample("Editor Only");
        buffer.name = SampleName = camera.name;
        Profiler.EndSample();
    }
#endif

处理变化缓冲名的问题

使用上述的方法后,的确每个摄像机都有了自己的采样层级,不过进入Unity的Play模式的话,Unity会报错,警告我们BeginSampleEndSample的数量必须匹配。

为了解决这一问题,我们添加一个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

使用SampleNameSetupSubmit中采样:

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

    void Submit()
    {
        buffer.EndSample(SampleName);
        ExecuteBuffer();
        context.Submit();
    }

我们可以在profiler中查看编辑模式和其他模式的区别。按照GC Alloc排序的话,会发现在编辑模式下GC.Alloc总共分配了100字节,Build模式下则没有这个100字节。

我们可以加入一个新的样本Editor Only来标识清楚:

using UnityEngine.Profiling;

#if UNITY_EDITOR
    string SampleName { get; set; }
    ...
    partial void PrepareBuffer()
    {
        Profiler.BeginSample("Editor Only");
        buffer.name = SampleName = camera.name;
        Profiler.EndSample();
    }

#else
    const string SampleName = bufferName;
#endif

清除标志

我们可以将两个摄像机的结果组合在一起,将清除标志与另一个进行比较:

    void Setup()
    {
        context.SetupCameraProperties(camera);
        CameraClearFlags flags = camera.clearFlags;
        buffer.ClearRenderTarget(true, true, Color.clear);
        buffer.BeginSample(SampleName);
        ExecuteBuffer();
    }

CameraClearFlags枚举定义了四个值:Skybox,Color,Depth和Nothing。深度缓冲除了最后一个都要被清除:

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

只有标志被设为Color时我们才清除颜色缓冲:

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

清除的颜色我们使用线性空间的摄像机背景颜色:

        buffer.ClearRenderTarget(flags <= CameraClearFlags.Depth, flags == CameraClearFlags.Color,
                flags == CameraClearFlags.Color ? camera.backgroundColor.linear : Color.clear);

由于Main Camera是首先被渲染的,因此它的Clear Flags应为Skybox或Color。Secondary Camera的Clear Flags决定了两个摄像机会如何结合。

你可能感兴趣的:(Unity自定义SRP(一):构建渲染框架)