Unity Custom SRP

Custom Render Pipeline

A new Render Pipeline

Project Setup

我们需要在线性空间计算光照,所以设置为Linear。

Pipeline Asset

  • Unity默认使用默认渲染管线,而在这里,我们通过Pipeline Asset来管理自定义管线。进一步的,我们将资产的文件格式写成默认渲染管线的路径格式。
  • 默认的Csharp文件是游戏逻辑的文件格式,我们引入命名空间UnityEngine.Renderring,并且把类实现部分删掉,继承RenderPipelineAsset类来实现我们的自定义管线资产类CustomRenderPipelineAsset
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CustomRenderPipelineAsset : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public class CustomRenderPipelineAsset: RenderPipelineAsset
{
    
}
  • RenderPipelineAsset渲染管线资产类的作用是,指定一个渲染管线RenderPipeline实例,这个指定是需要重写CreatePipeline来实现的。
protected override RenderPipeline CreatePipeline()
{
   return null;
}
  • 最终的,我们需要实现能够在界面菜单中选中一个渲染管线资产。这个通过CreateAssetMenu来实现。
[CreateAssetMenu(menuName ="Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset

这样一来,通过Create就可以创建渲染管线资产。

创建资产之后,我们就可以修改Scriptable Render Pipeline Settings中的渲染管线资产。这样一来,我们就实现了Unity项目使用我们自己创建的渲染管线资产,接下来就需要去实现我们自己的渲染管线实例。

此时,我们的scene变成黑色,这是因为我们还没有去实现渲染管线实例。

Render Pipeline Instance

  • 我们创建一个CustomRenderPipeline脚本,来实现渲染管线实例类。

    相似的,我们修改命名空间,此外我们需要继承的是RenderPipeline。此外,需要注意的是,RenderPipeline提供了渲染入口的抽象接口,即Render函数。Render函数有两个参数,一个是上下文,另一个是摄像机。Render函数是抽象函数必须要定义。该函数有两种重载,一个是需要Camera数组,另一个需要List,推荐使用后者,但是作为抽象函数前者也必须重写,因此重写为空函数。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

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

    protected override void Render(ScriptableRenderContext context, List<Camera> cameras)
    {
    }
}
  • 在创建了渲染管线实例类之后,我们就可以指定渲染管线资产类的返回实例了。
public class CustomRenderPipelineAsset : RenderPipelineAsset
{
    protected override RenderPipeline CreatePipeline()
    {
        return new CustomRenderPipeline();
    }
}

Rendering

Camera Render

渲染管线实例类的Render函数,是对每一个相机分别渲染,因此,我们创建一个相机渲染的类,来处理每一个相机的渲染过程。进一步的,我们需要将Render的上下文和某一个Camera设置在该类内部。

using UnityEngine;
using UnityEngine.Rendering;

public class CameraRenderer
{
    Camera camera;
    ScriptableRenderContext context;

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

这样一来,我们就可以简单地处理渲染管线类的Render函数。显然的是,CameraRenderer类的Render函数还没有实现渲染效果,这就是接下来的工作。

    protected override void Render(ScriptableRenderContext context, List<Camera> cameras)
    {
        CameraRenderer renderer = new CameraRenderer();
        
        for(int i = 0; i < cameras.Count; i++)
        {
            renderer.Render(context, cameras[i]);
        }
    }

Drawing the Skybox

此时我们的Render还没有实现。我们将CameraRender类实例修改两大参数之后,就可以就这两个参数执行渲染了。

  • 首先,我们实现一个渲染可见图形的函数,DrawVisiableGeometry,在这里我们简单的渲染一个默认天空盒,这是通过上下文的绘制天空和函数来实现的contex.DrawSkybox(camera)
  • 接着,需要注意的是,我们的渲染函数,实际上是将渲染指令添加到缓冲中去,然后由第三方执行缓冲中的内容;而在此处,我们的操作是将绘制指令添加到了上下文中,因此,我们需要将上下文提交给缓冲。这个类似于UE的渲染队列。因此,在后面我们利用了context.Submit(),并封装成了Submit()
  • 在这里,我们虽然绘制出了天空盒,但是我们的外设操作不能影响观察结果,这是因为我们没有设置camera的View矩阵。这个同样需要使用context的函数context.SetupCameraProperties(camera)来将View矩阵参数设置加入到上下文中,并封装为Setup()
    可见,到目前为止,我们实现了上下文的三个环节:camera信息渲染指令提交上下文
public class CameraRenderer
{
    Camera camera;
    ScriptableRenderContext context;

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

        DrawVisiableGeometry();
    }

    void DrawVisiableGeometry()
    {
        Setup();
        context.DrawSkybox(camera);
        Submit();
    }

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

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

Command Buffers

  • 已知的是,我们的渲染管线的执行,需要在buffer和context之间互动,像是渲染天空盒这类具有既定函数的渲染流程,是已经实现了这些交互的,但是我们的更多的任务需要我们自己来实现交互。首先,我们实现了一个一个CommandBuffer
    const string bufferName = "Renmder Camera";
    CommandBuffer buffer = new CommandBuffer { name = bufferName };
  • 首先,我们使用buffer.BeginSample(bufferName);buffer.EndSample(bufferName);,来将这些buffer提交给探查器profiler,以便观察。
  • 然后,需要注意的是,我们的buffer与context的互动是这样的:context绑定一个buffer,并从buffer中拷贝命令,这个可以通过context.ExecuteCommandBuffer(buffer)来实现。但是这一个过程并不会删除buffer中的命令,因此会对后续的submit有影响,所以我们需要用buffer.Clear()清理buffer。这个指令在Setup与Submit两个过程都需要执行,目的就是为了从buffer中拷贝命令到context中,并清理buffer,可见在Setup中实现之后便可以写入context,而在Submit中实现之后便可以submit给buffer。
    void Submit()
    {
        buffer.BeginSample(bufferName);
        ExecuteBuffer();
        context.Submit();
    }

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

    void ExecuteBuffer()
    {
        context.ExecuteCommandBuffer(buffer);
        buffer.Clear();
    }

可见,绘制天空盒的指令被显示在了我们的帧缓冲"Render Camera"中了。

Clearing the Render Target

  • 使用buffer.ClearRenderTarget(true, true, Color.clear);清理缓冲。将其放在设置camera属性之后可以快速清除,而不是通过绘制来清除。
  • 在开始采样之后,如果使用了buffer操作,比如清理缓冲,那么在profier中就会出现嵌套。因此,为了减少嵌套,将其放在sample之前。
    void Setup()
    {
        context.SetupCameraProperties(camera);
        buffer.ClearRenderTarget(true, true, Color.clear);
        buffer.BeginSample(bufferName);
        ExecuteBuffer();
    }

Culling

  • 在绘制物体时,我们需要剔除,这里的剔除指的是距离剔除、视锥体粗粒度剔除、遮挡剔除等应用程序阶段的剔除。
  • 进行剔除需要用到两个关键函数:首先使用camera.TryGetCullingParameters(out ScriptableCullingParameters p)获得camera剔除相关的参数,这里有可能会因为camera的退化而拿不到提出参数,对于这类情况我们选择退出该camera的渲染。接着,拿到渲染参数之后,我们使用context.Cull(ref p)来获得剔除结果的结构体,这是一个CullingResults类型的结构体。
  • 最终,在渲染时,我们需要使用剔除结果,如下一节所述。
    CullingResults cullingResults;

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

        if (!Cull()) return;

        DrawVisiableGeometry();
    }

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

Drawing Geometry

  • 上面只实现了绘制天空盒,接下来绘制几何。绘制几何需要这样几个关键步骤:
  • SortingSettings:该结构体用于描述集合体排序方式,可以按照下面的方式定义,以图按照特定的camera来进行排序(通过传入参数camera实现),并按照某种特定的排序方式来排序(通过构造函数赋值crite来实现,这个可以不实现那么便是未定义的顺序,此处使用了不透明物体的排序方式,即由近及远)。
  • ShaderTagId:该结构体用于指定渲染shader,此处创建了一个静态对象作为shaderid,并且选用了unity的Unlit shader,这是通过参数名“SRPDefaultUnllit”来实现的。
  • DrawingSettings:该结构体描述了绘制相关的属性,其需要2个参数:ShaderTagId,SortingSettings。
  • FilteringSettings:该结构体描述了滤波方式,此处就是指如何按照几何体的Material属性来实现有选择的滤波,这个可通过RenderQueueRange的成员来指定,如此处便是all
  • context.DrawRenderers:该函数最终实现绘制一次渲染循环的几何体,其需要三个参数:cullingResults, ref drawingSettings, ref filteringSettings,都是我们之前的实现内容。
    static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");

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

        var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);

        var filteringSettings = new FilteringSettings(RenderQueueRange.all);

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

        context.DrawSkybox(camera);
        
    }


  • 经过调试发现,当几何体不在camera视锥体范围内时,包括距离之外与NDC之外,剔除就会发挥作用,渲染循环中不包含该几何体。但是当机合体被遮挡时,仍然会进行绘制,可见,在此处,剔除只实现了视锥体剔除和距离剔除,而没有遮挡剔除

Drawing Opaque and Transparent Geometry Separately

如果将天空盒看作不透明物体,那么它便是最远处的不透明物体,所以绘制顺序是由近及远绘制不透明物体,然后绘制天空盒。接下来,由远及近绘制透明物体。这个步骤可以很简单的利用上述的几个结构体来实现,具体来说就是修改sortingSettings的criteria(相应的修改DrawSettings)与filteringSettings的renderQueueRange。

    void DrawVisiableGeometry()
    {
        //Opaque
        var sortingSettings = new SortingSettings(camera)
        {
            criteria = SortingCriteria.CommonOpaque
        };

        var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);

        var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);

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

        //Skybox
        context.DrawSkybox(camera);

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

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


Editor Rendering

Drawing Legacy Shaders

  • 从上面可以看出,我们的渲染管线目前只能支持一个ShaderTagId,暂时的,我们选用的是Unity的默认Unlit shader。因此,我们的管线目前没有能力支持其他默认ShaderTagId的渲染。如下,我们修改Material,则我们选中的“DefaultUnlitShader”就不能发挥作用了。
  • 为此,我们需要能够支持默认渲染管线,至少是不要忽视这些几何体而发生位置的问题。
  • 首先,我们定义一个静态数组,保存我们需要支持的默认管线。
  • 然后,我们定义一个函数,该函数中进行完整的渲染流程,但是DrawingSettings需要使用上面数组中的shader,进一步的可以使用drawingSettings.SetShaderPassName将数组中的所有shader都加入到各自的一个Pass中,如此一来,数组中的shader也都会被支持。
  • 另外,我们对filter没有要求,使用DefaultValue即可。
    static ShaderTagId[] legacyShaderTagIds =
    {
        new ShaderTagId("Always"),
        new ShaderTagId("ForwardBase"),
        new ShaderTagId("PrepassBase"),
        new ShaderTagId("Vertex"),
        new ShaderTagId("VertexLMRGBM"),
        new ShaderTagId("VertexLM")
    };

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

        if (!Cull()) return;

        Setup();
        DrawVisiableGeometry();
        DrawUnsupportedShaders();
        Submit();
    }

    void DrawUnsupportedShaders()
    {
        var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera));
        var filteringSettings = FilteringSettings.defaultValue;

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

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

如下,我们绘制出了旧版管线的几何体。

Error Material

我们期待旧版管线的几何体被以特殊的形式绘制出来。为了实现这个,我们只需要定义一个静态Material对象,并且将其指定为特定的Material(此处通过Shader.Find("Hidden/InternalErrorShader")指定为紫色)。进一步的,我们在drawingSettings的构造函数中,赋值overrideMaterial,即可将旧版管线的几何体绘制为errorMaterial。

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

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

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


Partial Class

  • 我们不希望当程序发布之后还会显示不正常的几何体,为了实现这个需求,我们可以将这一部分使用预处理指令#if UNITY_EDITOR包含起来,进一步的为了更好地处理代码结构,我们使用分布类。
  • 需要注意的是,因为我们将DrawUnsupportedShaders()包含在了分布类的条件中,但是该函数始终会被调用,因此我们将其声明为partial,使其分部化。
public partial class CameraRenderer{...}
using UnityEngine;
using UnityEngine.Rendering;
partial class CameraRenderer
{
    partial void DrawUnsupportedShaders();
#if UNITY_EDITOR
    static ShaderTagId[] legacyShaderTagIds =
    {
        new ShaderTagId("Always"),
        new ShaderTagId("ForwardBase"),
        new ShaderTagId("PrepassBase"),
        new ShaderTagId("Vertex"),
        new ShaderTagId("VertexLMRGBM"),
        new ShaderTagId("VertexLM")
    };

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

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

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

#endif
}

Drawing Gizmos

进一步的,我们绘制出一些可视控件。同样的,这些可视控件只会在编辑时显示,因此与旧版管线一样使用partial。

using UnityEditor;
  ...
  partial void DrawGizmos()
#if UNITY_EDITOR
    ...
    partial void DrawGizmos()
    {
        if(Handles.ShouldRenderGizmos()) {
            context.DrawGizmos(camera, GizmoSubset.PreImageEffects);
            context.DrawGizmos(camera, GizmoSubset.PostImageEffects);
        }
    }
#endif

Drawing Unity UI

Multiple Cameras

当有多个camera时,他们会被放在同一个buffer的sample之下,这是显然的。

为了能够分开显示,我们对CameraRenderer的实例,在传入每个camera时,修改其buffer的name,这样一来我们就获得了多个buffer。

    partial void PrepareBuffer();
    ...
    partial void PrepareBuffer()
    {
        buffer.name = camera.name;
    }
    public void Render(ScriptableRenderContext context, Camera camera)
    {
        this.context = context;
        this.camera = camera;

        if (!Cull()) return;

        PrepareBuffer();

        Setup();
        DrawVisiableGeometry();
        DrawUnsupportedShaders();
        DrawGizmos();
        Submit();
    }


但是,问题在于,我们修改了buffer的那么,而在sample中传入的名字仍然是bufferName,这就会使的profier的bufferName和buffer的name不匹配,就会报错。要解决这个问题,我们只需要同时修改sample的name即可。即,在编辑模式下,我们设置string SampleName,并将其与buffer.name赋值为camera.name,那么二者就会同步;而在其他模式下,我们设置string SampleName便是bufferName,也保证了二者一致。

#if UNITY_EDITOR
    string SampleName { get; set; }

    partial void PrepareBuffer()
    {
        buffer.name = SampleName = camera.name;
    }
#else
    
    const string SampleName = bufferName;
#endif
    void Setup()
    {
        context.SetupCameraProperties(camera);
        buffer.ClearRenderTarget(true, true, Color.clear);
        buffer.BeginSample(SampleName);
        ExecuteBuffer();
    }

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

然而,我们获取camera的name,需要分配内存。

我们在profier中打上标记,就会发现这两次的malloc只会发生在编辑模式下。

using UnityEngine.Profiling;
    ...
    partial void PrepareBuffer()
    {
        Profiler.BeginSample("Editor Only");
        buffer.name = SampleName = camera.name;
        Profiler.EndSample();
    }

Layers

我们可以将几何体分层,然后指定每一个camera只去观察某些层。这样,我们就可以用main camera渲染可视几何体,而用second camera渲染旧管线几何体。




Clear Flags

在之前的渲染中,我们设置每次camera渲染都会clear color bufffer与depth buffer,这显然会将main camera的缓冲给清除,而只显示second camera,若为了显示多个camera的情况,可以通过修改camera的clearFlags来实现。clearFlags有四个值:

  • 1:skyBox
  • 2:color
  • 3:depth
  • 4:nothing
    我们利用这四个值来实现secnond camera的不同clear程度,具体来说是获得控制面板设置的clearFlags参数,然后利用buffer.ClearRenderTarget来设置清除。
    void Setup()
    {
        context.SetupCameraProperties(camera);
        CameraClearFlags flags = camera.clearFlags;
        buffer.ClearRenderTarget(
            flags <= CameraClearFlags.Depth,
            flags == CameraClearFlags.Color,
            flags == CameraClearFlags.Color ? camera.backgroundColor : Color.clear
        );
        buffer.BeginSample(SampleName);
        ExecuteBuffer();
    }

【skybox】

【depth】

【nothing】

Draw Calls

Shaders

Unlit Shader

下面我们开始实现自定义的shader。暂时的,我们只需要实现Unlit的shader,因此我们创建一个UnlitShader,并将其内容清空,保留基本结构。

  • 第一行的字符串是创建shader的目录索引。
  • 另外的,基本的结构就是PropertiesSubShaderPass
Shader "Custom RP/Unlit"
{
    Properties
    {
    }
    SubShader
    {
        Pass
        {
        }
    }
}
  • 这样一来,Unlit Shader便只有最基本的渲染效果:颜色是白色,并且设定渲染队列编号是2000(不透明物体的默认编号)。

HLSL Programs

shader需要写在HLSLPROGRAMENDHLSL关键字之间,并且需要使用#pragma预编译指令来说明vertex与fragment着色器的名字,进一步的应该在后面定义这两个着色器,但是为了代码结构,此处将这两个着色器放在hlsl文件中,然后在此处include进来。注意include的目录是相对目录,所以hlsl文件要与shader放在同一个目录下面。

Shader "Custom RP/Unlit"
{
    Properties
    {
    }
    SubShader
    {
        Pass
        {
            HLSLPROGRAM
            #pragma vertex UnlitPassVertex
            #pragma fragment UnlitPassFragment
            #include "UnlitPass.hlsl"
            ENDHLSL
        }
    }
}

Include Guard

HLSL可以使用include来包含并插入到include节点,为了避免重复包含,类似于c++,需要包含保护。

#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
#endif

Shader Functions

在hlsl中,shader是以函数的形式定义的,如下。使用SV_TARGET来说明片元着色器的输出是片元着色结果,使用SV__POSITION来说明顶点着色器的输出是顶点坐标。

#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED

float4 UnlitPassVertex():SV_POSITION
{
    return 0.0;
}

float4 UnlitPassFragment():SV_TARGET
{
    return 0.0;
}

#endif

Space Transformation

  • 对于顶点着色器中的空间坐标变换,首先我们需要MVP矩阵,在此处我们声明了两个矩阵unity_ObjectToWorldunity_MatrixVP,前者是M矩阵,后者是VP矩阵,二者都是Unity的内部计算结果,因此此处并非定义而仅仅是声明。
#ifndef CUSTOM_UNITY_INPUT_INCLUDED
#define CUSTOM_UNITY_INPUT_INCLUDED

float4x4 unity_ObjectToWorld;

float4x4 unity_MatrixVP;


#endif
  • 进一步的,拿到M变换与VP变换的函数。
#ifndef CUSTOM_COMMON_INCLUDED
#define CUSTOM_COMMON_INCLUDED
#include "UnityInput.hlsl"

float3 TransformObjectToWorld(float3 positionOS)
{
    return mul(unity_ObjectToWorld, float4(positionOS, 1.0)).xyz;
}

float4 TransformWorldToHClip(float3 positionWS)
{
    return mul(unity_MatrixVP, float4(positionWS, 1.0));
}

#endif
  • 最终的,我们在顶点着色器中实现MVP变换。
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
#include "../ShaderLibrary/Common.hlsl"
float4 UnlitPassVertex(float3 positionOS:POSITION):SV_POSITION
{
    float3 positionWS = TransformObjectToWorld(positionOS);
    return TransformWorldToHClip(positionWS);
}

float4 UnlitPassFragment():SV_TARGET
{
    return 1.0;
}

#endif

Core Library

  • 我们在上面实现的空间变换,已经在Core RP Library中实现了。因此,我们可以将该库install到项目中。
  • 我们可以把自己实现的空间变换函数删掉,然后包含该库Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl就可以了。进一步的,在这个文件中,各种变换矩阵是跟unity的默认矩阵不同名的,因此,我们需要使用#define预编译指令来处理名称的不一致。同时,我们需要在UnityInput.hlsl中将该文件需要的unity内置矩阵也全部声明。
  • 最后,我们需要声明real4 unity_WorldTransformParams;,但是该real4类型不是内置类型,而是在Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl中定义的,因此在包含UnityInput.hlsl之前需要先包含这一个文件。
#ifndef CUSTOM_UNITY_INPUT_INCLUDED
#define CUSTOM_UNITY_INPUT_INCLUDED

float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;

float4x4 unity_MatrixVP;
float4x4 unity_MatrixV;
float4x4 unity_MatrixInvV;
float4x4 unity_prev_MatrixM;
float4x4 unity_prev_MatrixIM;
float4x4 glstate_matrix_projection;

real4 unity_WorldTransformParams;

#endif
#ifndef CUSTOM_COMMON_INCLUDED
#define CUSTOM_COMMON_INCLUDED
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "UnityInput.hlsl"

#define UNITY_MATRIX_M unity_ObjectToWorld
#define UNITY_MATRIX_I_M unity_WorldToObject
#define UNITY_MATRIX_V unity_MatrixV
#define UNITY_MATRIX_I_V unity_MatrixInvV
#define UNITY_MATRIX_VP unity_MatrixVP
#define UNITY_PREV_MATRIX_M unity_prev_MatrixM
#define UNITY_PREV_MATRIX_I_M unity_prev_MatrixIM
#define UNITY_MATRIX_P glstate_matrix_projection

#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"

#endif

Color

  • 为了更灵活的修改输出颜色,我们在这里用到了shader与hlsl之间的变量传递。
  • 首先,我们在hlsl文件中定义了float4变量_BaseColor,进一步的,我们在shader中利用Properties来实现参数传递,传递方式为_BaseColor("Color",Color)=(1.0,1.0,0.0,1.0),即hlsl的全局变量(面板名称,面板对应的Unity变量的类型)=初始化值。
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
#include "../ShaderLibrary/Common.hlsl"

float4 _BaseColor;

float4 UnlitPassVertex(float3 positionOS:POSITION):SV_POSITION
{
    float3 positionWS = TransformObjectToWorld(positionOS);
    return TransformWorldToHClip(positionWS);
}

float4 UnlitPassFragment():SV_TARGET
{
    return _BaseColor;
}

#endif
Shader "Custom RP/Unlit"
{
    Properties
    {
        _BaseColor("Color",Color)=(1.0,1.0,0.0,1.0)
    }
    SubShader
    {
        Pass
        {
            HLSLPROGRAM
            #pragma vertex UnlitPassVertex
            #pragma fragment UnlitPassFragment
            #include "UnlitPass.hlsl"
            ENDHLSL
        }
    }
}

Batching

SRP Batcher

  • 第一种思路是SRP batcher,其方法是对于同一个shader,其material的结构是一样的,那么该方法将同一sheder的material统一传送到GPU中,而不是每次call draw再去传送,当然了,其仍然会进行多次call。在绘制某一个material的几何体时,会查找该material在GPU中的偏移量,来获取shader所需要的参数。
  • 在shader的inspector中可以看出,暂时是不能使用SRP batcher的,这是因为material需要的参数(逐material或者逐object)都是全局变量,需要在drawcall之前分别传送。如下所示,有两个cbuffer需要我们自己来设置,第一个是UnityPerMaterial,用来存放逐marerial的属性,另外一个是UnityPerDraw,用来存放逐object的属性。

#ifndef CUSTOM_UNITY_INPUT_INCLUDED
#define CUSTOM_UNITY_INPUT_INCLUDED

CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
float4 unity_LODFade;
real4 unity_WorldTransformParams;
CBUFFER_END

float4x4 unity_MatrixVP;
float4x4 unity_MatrixV;
float4x4 unity_MatrixInvV;
float4x4 unity_prev_MatrixM;
float4x4 unity_prev_MatrixIM;
float4x4 glstate_matrix_projection;

#endif
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
#include "../ShaderLibrary/Common.hlsl"

CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
CBUFFER_END

float4 UnlitPassVertex(float3 positionOS:POSITION):SV_POSITION
{
    float3 positionWS = TransformObjectToWorld(positionOS);
    return TransformWorldToHClip(positionWS);
}

float4 UnlitPassFragment():SV_TARGET
{
    return _BaseColor;
}

#endif

这样一来,我们的shader就可以使用SRP batcher了。

  • 但是,我们还没有开启srp batch。

    我们需要在我们的渲染管线实例类的构造函数中,开启SRPBatch。
    public CustomRenderPipeline()
    {
        GraphicsSettings.useScriptableRenderPipelineBatching = true;
    }

Many Colors

我们可以设置多个该shader的material,这些material会被同批次传到GPU中。但是,如果我们要实现每一个几何体都有一个属性,那么就需要若干个material。为了避免这种情况,我们通过为几何体增加Properties来实现。


首先,我们是为几何体创建c#脚本。

  • 我们的类仍然是继承MonoBehaviour,该类可以将脚本绑在几何体上面,他的命名空间是UnityEngine。进一步的,我们设置DisallowMultipleComponent,表示一个几何体只能绑定一个相似类型的MonoBehaviour。
  • 我们实现着色器参数绑定的方法是,利用该脚本设置一个类属性,该属性应当是c#(Unity)的类型,例如此处是Color类型,同时我们给他一个初始化值,进一步的,我们使用SerializeField属性来将其显示在面板上。这样一来,我们就可以以几何体为单位,为对象修改值。
  • 此时只是能够实现修改几何体属性,但是还没有与shader属性关联。首先,我们需要利用Shader.PropertyToID("")来获得shader中的变量(可以是一般的变量,也可以是全局变量,也可以是cbuffer,事实上没有区别)的id,因为一个shader的变量id是固定的,而我们的脚本也是与该shader绑定的,所以设置成static变量。
  • 需要注意的是,SRP batch与Per Object对变量的要求不一样:SRP Batch需要变量在Properties中声明,然后将其放在cbuffer中,因为只有Properities中的属性才会显示在material面板上;而Per Object需要该变量在Properities之外有定义,而对Properities并无要求,这是因为我们并不会用到材质面板属性。
  • 目前我们可以通过面板设置脚本属性,也获得了shader中的变量id,但是还需要绑定并修改。我们需要定义一个MaterialPropertyBlock的全局变量,用于实现shader修改。其中,block.SetColor(,)可以通过类变量来修改给定某个id的shader变量值。最后的,我们需要GetComponent().SetPropertyBlock(block);来提交修改。
  • 进一步的,我们需要将该过程封装在OnValidate()函数中,该函数在面板属性修改时会调用。
  • 此外,我们可以在Awake()函数中调用OnValidate函数,以便在对象实例创建时初始化属性值。
using UnityEngine;

[DisallowMultipleComponent]
public class PerObjectMaterialProperties : MonoBehaviour
{
    static int baseColorId = Shader.PropertyToID("_BaseColor");

    static MaterialPropertyBlock block;

    [SerializeField]
    Color baseColor = Color.white;

    private void OnValidate()
    {
        if(block == null)
        {
            block = new MaterialPropertyBlock();
        }
        block.SetColor(baseColorId, baseColor);
        GetComponent<Renderer>().SetPropertyBlock(block);
    }

    private void Awake()
    {
        OnValidate();
    }
}
  • 最终的,我们将脚本绑定在几何体上,然后调整面板,就可以实现shader属性调整。
  • 需要注意的是,per-object material properties与SRP batch不兼容,也就是说我们只能逐几何体来传输参数值,此时cbuffer与一般的shader变量没有差异,因此可能会使得性能下降。

GPU Instancing

  • GPU实例化是另外一个批处理方法。需要注意的是,前面的SRP batch是通过使用cbuffer来实现material的批传送,并通过Properties属性来实现material面板的参值修改,这样便实现了以material为单位的批处理;per objecy material priorities是通过c#脚本将shader的变量id与面板变量捆绑,来实现面板修改以及实例对象时逐几何体修改参值。但是,instancing有一个重要的区别,便是其只能应用于mesh相同的几何体,其处理方法是将几何体之间的差异信息整理成数组来批量传入GPU,然后在绘制时只需要调用一次Call Draw(因为mesh相同),仅仅是在GPU中按照数组索引不同的参值。
  • 首先的,我们加入#pragma multi_compile_instancing,这使得捆绑该shader的材质面板上可选是否使用实例化。
Shader "Custom RP/Unlit"
{
    Properties
    {
        _BaseColor("Color",Color)=(1.0,1.0,0.0,1.0)
    }
    SubShader
    {
        Pass
        {
            HLSLPROGRAM
            #pragma multi_compile_instancing
            #pragma vertex UnlitPassVertex
            #pragma fragment UnlitPassFragment
            #include "UnlitPass.hlsl"
            ENDHLSL
        }
    }
}

  • 在GPU instancing之下编写shader时,是通过已经编写好的core rp库来实现的,因此在define矩阵与几何变换hlsl引入UnityInstancing.hlsl。我们将使用该文件定义的一系列宏来编写shader的实例化内容。
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"
  • 对于我们需要实例化的数组参数,需要使用UNITY_INSTANCING_BUFFER_START()UNITY_INSTANCING_BUFFER_END()来替代cbuffer。虽然这个在UnityInstancing中也是用cubffer来实现的。
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
  • 进一步的,需要注意的是,在有cbuffer的情况下,即给出了SRP batch的条件,同时没有使用per object方法来覆盖SRP batch,那么优先使用SRP batch的,此时尽管我们将_BaseColor的cbuffer改成了instace buffer,然而我们需要注意的是,我们在前面还用到了UnityPerDraw的cbuffer,其存在也会使得GPU instance失效,因此需要取消。
  • 在UnityInstancing.hlsl中,对实例id的访问是通过几struct来实现的,因此,我们需要定义一个struct来保存instanceId。宏UNITY_VERTEX_INPUT_INSTANCE_ID是其define的uint instanceID。实例化的id是需要在顶点着色器和几何着色器中从输入中提取的,因此,我们将这两个着色器的输入的参量与id写入一个struct中。
struct Attributes
{
    float3 positionOS : POSITION;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};
  • 进一步的,我们改写顶点着色器,除了将之前的顶点坐标部分的输入输出与结构体相关之外,我么使用了UNITY_SETUP_INSTANCE_ID(input);来从input获得实例化的id并转换为全局id。然后使用UNITY_TRANSFER_INSTANCE_ID(input, output);将id记录在输出结构体中。
Varyings UnlitPassVertex(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    float3 positionWS = TransformObjectToWorld(input.positionOS);
    output.positionCS = TransformWorldToHClip(positionWS);
    return output;
}

  • 最后的,我们在片元着色器中同样提取了输入(顶点着色器的输出结构体)的id,然后使用UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);获取了实例化数组缓冲UnityPerMaterial中的属性值_BaseColor。
float4 UnlitPassFragment(Varyings input):SV_TARGET
{
    UNITY_SETUP_INSTANCE_ID(input);
    return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
}
#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED
#include "../ShaderLibrary/Common.hlsl"

UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

struct Attributes
{
    float3 positionOS : POSITION;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};

Varyings UnlitPassVertex(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    float3 positionWS = TransformObjectToWorld(input.positionOS);
    output.positionCS = TransformWorldToHClip(positionWS);
    return output;
}

float4 UnlitPassFragment(Varyings input):SV_TARGET
{
    UNITY_SETUP_INSTANCE_ID(input);
    return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
}

#endif


Drawing Many Instanced Meshes

在上面,我们使用了同一个material的几何体就会批量绘制,但是这意味着我们需要实例化诸多的对象,此外,如果要绘制材质属性不同的几何体,就需要多个材质。事实上,使用实例化时,我们只需要一个mesh,这是因为实例化只能发生在一个mesh上,那么多个几何体就是浪费,此外,由于材质属性会被按照数组的形式传入GPU,那么我们也只需要一个material,只是需要重新给定数组即可。因此,我们只定义一个几何体,然后使用c#脚本来传输数组。

  • 基本的代码结构与per object相似。
  • 我们也需要拿到shader的properities的id。
  • 我们的绘制需要使用Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 256, block),该方法需要mesh、material、matrices、count、block几个参数,因此我们需要定义meshmaterial,并显示在面板上,然后选择合适的mesh与materal,注意的是,此处的这两个属性并不需要与原来的3D object相同,因此,实际上我们可以使用一个空对象。

  • 类似的,我们接下来定义matricesbaseColors两个数组,并在Awake()中赋值这两个数组的值。
  • 最后的,我们也需要定义MaterialPropertyBlock对象,然后利用block.SetVectorArray(baseColorId, baseColors);来传输数据。
  • 最终的,我们使用上面定义的参量,以及方法Graphics.DrawMeshInstanced来绘制实例化物体。
using UnityEngine;

public class MeshBall : MonoBehaviour
{

    static int baseColorId = Shader.PropertyToID("_BaseColor");

    [SerializeField]
    Mesh mesh = default;

    [SerializeField]
    Material material = default;

    Matrix4x4[] matrices = new Matrix4x4[256];
    Vector4[] baseColors = new Vector4[256];

    MaterialPropertyBlock block;

    void Awake()
    {
        for (int i = 0; i < matrices.Length; i++)
        {
            matrices[i] = Matrix4x4.TRS(
                Random.insideUnitSphere * 10f, Quaternion.identity, Vector3.one
            );
            baseColors[i] =
                new Vector4(Random.value, Random.value, Random.value, 1f);
        }
    }

    void Update()
    {
        if (block == null)
        {
            block = new MaterialPropertyBlock();
            block.SetVectorArray(baseColorId, baseColors);

        }
        Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 256, block);
    }
}

Dynamic Batching

此外,还可以用动态批处理,其将诸多的mesh合并为一个mesh。
该方法需要继续使用GPU instance的代码结构,但是该方法的优先级低于instance,更是低于SRP batch。因此可以通过下面两个部分控制开闭。

    public CustomRenderPipeline()
    {
        GraphicsSettings.useScriptableRenderPipelineBatching = false;
    }
        var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings)
        {
            enableDynamicBatching = true,
            enableInstancing = false
        };


Configuring Batching

我们在上一节通过调节三个bool参数实现了控制三种批处理方法,那么我们就可以不再使用硬编码的方法了。

  • 在管线资产类中,创建三个bool成员并放置在面板上。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset
{
    [SerializeField]
    bool useDynamicBatching = true, useGPUInstancing = true, useSRPBatcher = true;
    protected override RenderPipeline CreatePipeline()
    {
        return new CustomRenderPipeline(useDynamicBatching, useGPUInstancing, useSRPBatcher);
    }
}
  • 在管线中,使用这三个参数来构造,其中useSRPBatcher直接在构造函数中设置SRP batch的开关,而另外两个参数通过成员来保存,最终用到render中。
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public class CustomRenderPipeline : RenderPipeline
{
    bool useDynamicBatching, useGPUInstancing;

    public CustomRenderPipeline(bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher)
    {
        this.useDynamicBatching = useDynamicBatching; 
        this.useGPUInstancing = useGPUInstancing;
        GraphicsSettings.useScriptableRenderPipelineBatching = useSRPBatcher;
    }
    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
    }

    protected override void Render(ScriptableRenderContext context, List<Camera> cameras)
    {
        CameraRenderer renderer = new CameraRenderer();

        for (int i = 0; i < cameras.Count; i++)
        {
            renderer.Render(context, cameras[i], useDynamicBatching, useGPUInstancing);
        }
    }
}
  • 在Render中,我们使用这2个参数来进行绘制,即在drawingSetting中实现开关调控。
    public void Render(ScriptableRenderContext context, Camera camera, bool useDynamicBatching, bool useGPUInstancing)
    {
        this.context = context;
        this.camera = camera;

        if (!Cull()) return;

        PrepareBuffer();

        Setup();
        DrawVisiableGeometry(useDynamicBatching, useGPUInstancing);
        DrawUnsupportedShaders();
        DrawGizmos();
        Submit();
    }

    void DrawVisiableGeometry(bool useDynamicBatching, bool useGPUInstancing)
    {
        //Opaque
        var sortingSettings = new SortingSettings(camera)
        {
            criteria = SortingCriteria.CommonOpaque
        };

        var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings)
        {
            enableDynamicBatching = useDynamicBatching,
            enableInstancing = useGPUInstancing
        };

        var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);

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

        //Skybox
        context.DrawSkybox(camera);

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

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

最终的,我们可以在面板上实现调节:

此时,我们的代码结构仍然是GPU instance的结构,并注意在之前关于变换矩阵的部分也需要放进cbuffer中,通过参数而非cbuffer来控制开关。

Transparency

Blend Modes

posted @ 2023-08-20 21:36  ETHERovo  阅读(127)  评论(0编辑  收藏  举报