Unity自定义SRP(二):Draw Call

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

Shaders

为了绘制一些东西,CPU需要告诉GPU绘制什么以及如何绘制,绘制的东西通常是网格,如何绘制通常由一个shader决定,即针对GPU的指令集。

Unlit Shader

新建一个Shaders文件夹,并创建一个名为Unlit的shader,shader的基本结构不用赘述:

Shader "Custom RP/Unlit"
{
    Properties
    {
    }
    SubShader
    {
        Pass
        {
        }
    }  
}

HLSL Program

这里模仿URP使用HLSL作为shader pass中程序的语言:

        Pass
        {
            HLSLPROGRAM
            #pragma vertex UnlitPassVertex
            #pragma fragment UnlitPassFragment
            ENDHLSL
        }

为了方便,我们将顶点着色器和片元着色器的代码置于一个.hlsl文件中:

            HLSLPROGRAM
            #pragma vertex UnlitPassVertex
            #pragma fragment UnlitPassFragment
            #include "UnlitPass.hlsl"
            ENDHLSL

UnlitPass

hlsl文件中,我们按照传统的头文件写法先写上一些宏定义,并加上着色器函数:

#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

空间变换

顶点着色器中,我们先传入模型空间的坐标,

float4 UnlitPassVertex(float3 positionOS : POSITION) : SV_POSITION
{
    return 0.0;
}

这里我们尝试返回世界空间下的坐标,也就是需要一个进行空间变换的矩阵(Unity自带)。为方便,我们新建一个额外的文件UnityInput.hlsl,放到与Shaders同一根目录下新建的文件夹ShaderLibrary中:

#ifndef CUSTOM_UNITY_INPUT_INCLUDED
#define CUSTOM_UNITY_INPUT_INCLUDED

float4x4 unity_ObjectToWorld;

#endif

同时我们定义一个空间变换的函数。新建一个Common.hlsl文件,置于ShaderLibrary文件夹下:

#ifndef CUSTOM_COMMON_INCLUDED
#define CUSTOM_COMMON_INCLUDED

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

#endif

这样我们就可以将顶点从模型空间转换到世界空间了:

#include "../ShaderLibrary/Common.hlsl"

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

不过我们最后所需要的是齐次裁剪空间内的坐标,即还需要View和Projection矩阵,这在UnityInput.hlsl中定义:

float4x4 unity_ObjectToWorld;
float4x4 unity_MatrixVP;

Common.hlsl中添加相应的函数:

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

在顶点着色器中应用:

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

Core Library

上述我们定义的两个空间变换的函数其实包括在Unity的Core RP Pipeline包中,我们直接使用自带的即可,在Common.hlsl中替换:

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

这样会遇到编译错误,因为其相关矩阵皆是宏定义:



我们自己构建一个宏定义即可:

#define UNITY_MATRIX_M unity_ObjectToWorld
#define UNITY_MATRIX_I_M unity_WorldToObject
#define UNITY_MATRIX_V unity_MatrixV
#define UNITY_MATRIX_VP unity_MatrixVP
#define UNITY_MATRIX_P glstate_matrix_projection

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

同时修改UnityInput.hlsl:

float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
real4 unity_WorldTransformParams;

float4x4 unity_MatrixVP;
float4x4 unity_MatrixV;
float4x4 glstate_matrix_projection;

unity_WorldTransformParams包含了一些变换信息。

还有许多的别名和基本的宏在Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl中定义,记得包含:

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

颜色

片元着色器可以返回调色板中修改的颜色。shader中定义属性:

    Properties
    {
        _BaseColor("Base Color", Color) = (1, 1, 1, 1)
    }

UnlitPass中应用:

float4 _BaseColor;

float4 UnlitPassFragment() :SV_TARGET
{
    return _BaseColor;
}

批处理

每个draw call要求CPU和GPU之间的通信,如果大量的数据要送往GPU,那么GPU会花费许多时间在等待数据上,与此同时CPU会花费大量时间在传递数据上,这些都会降低帧率。目前我们的绘制方法是每个物体调用一次draw call,例如,场景中若有5个正方体的话,那么共有7个draw call,5个正方体各一次,天空盒一次,清除渲染目标一次。

SRP Batcher

批处理是结合draw call的过程,减少CPU与GPU通信的时间,最简单的方法是开启SRP batcher,不过目前我们的Unlit shader并不能使用。

SRP batcher采用一种更为精简的方法来减少draw call数量,它捕捉在GPU上的材质属性,这样就不必每次调用draw call都需要传输相应的数据,不过只有在shader针对unifrom数据使用特定的数据结构时才可使用。

所有的材质属性必须定义在一个具体的内存缓冲中,即cbuffer块,名称为UnityPerMaterial

cbuffer UnityPerMaterial
{
    float _BaseColor;
};

这种常量缓冲并不在所有平台上都支持(如OpenGL ES2.0),因此这里使用Core RP Library中的CBUFFER_STARTCBUFFER_END宏定义:

CBUFFER_START(UnityPerMaterial)
    float4 _BaseColor;
CBUFFER_END

对于一些变换矩阵我们也是用相似的方式定义,只不过名称改为UnityPerDraw:

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

这样的话就可以使用SRP batcher了。接下来我们在CustomRenderPipeline中将其开启:

    public CustomRenderPipeline()
    {
        GraphicsSettings.useScriptableRenderPipelineBatching = true;
    }

多种颜色

如果我们想要每个材质的颜色不同的话,我们就不得不创建多个材质,因为Unity只会批处理那些有着相同shader变体的draw call。如果可以每个物体能各自修改自己的颜色就可以了,我们可以创建一个自定义的组件类型,命名为PerObjectMaterialProperties。方法是一个game object会有一个相应的组件,可以修改_Base Color配置,用于设置材质属性:

using UnityEngine;

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

    [SerializeField]
    Color baseColor = Color.white;
}

我们通过MaterialPropertyBlock对象逐物体设置材质属性:

    static MaterialPropertyBlock block;

我们在OnValidate()中设置材质属性,该方法在组件加载或改变时调用:

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

不过SRP batcher并不能处理逐物体材质属性,因此并不会进行批处理。

同时,想在build版本中使用的话,我们在Awake()方法中调用:

    private void Awake()
    {
        OnValidate();
    }

GPU Instancing

GPU Instancing可以使用逐物体材质属性来减少draw call数量,即一个draw call同时绘制多个物体。CPU会收集所有的逐物体变换和材质属性,并将它们放入队列中,送往GPU,GPU接着遍历该队列,按顺序渲染。

为了让shader支持该特性,我们添加一行代码:

            #pragma multi_compile_instancing
            #pragma vertex UnlitPassVertex
            #pragma fragment UnlitPassFragment

为支持GPU Instancing,我们包含进UnityInstancing.hlsl文件:

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

该文件定义了一些用于接收实例化数据队列的宏。为了渲染成功,需要知道当前物体的索引,其通过顶点数据提供。为了方便数据定义,我们定义一个结构体:

struct Attributes
{
    float3 positionOS : POSITION;
};

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

在结构体中加入实例化索引:

struct Attributes
{
    float3 positionOS : POSITION;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

接着在顶点着色器中加入UNITY_SETUP_INSTANCE_ID(input):

float4 UnlitPassVertex(Attributes input) : SV_POSITION
{
    UNITY_SETUP_INSTANCE_ID(input);
    float3 positionWS = TransformObjectToWorld(input.positionOS);
    return TransformWorldToHClip(positionWS);
}

接着我们要提供逐实例材质数据:

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

接着我们就可以使用实例索引在片元着色器中进行相关计算了。为方便,我们同样定义一个结构体,用于顶点着色器和片元着色器之间的数据传输:

struct Varyings
{
    float4 positionCS : SV_POSITION;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

我们使用UNITY_TRANSFER_INSTANCE_ID来传输索引:

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

在片元着色器中,我们使用UNITY_ACCESS_INSTANCED_PROP来访问当前实例的属性:

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

注意,只有那些分享相同材质的物体才能使用GPU instancing,改变材质颜色也可以。

动态批处理

该技术会将一些使用相同材质的小网格组合成大网格绘制,当使用逐物体材质时该方法不会生效。要想使用的话只需要在drawingSettings中开启即可:

        var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings)
        {
            enableDynamicBatching = true,
            enableInstancing = false
        };

同时要关闭SRP batcher,因为它优先级最高:

        GraphicsSettings.useScriptableRenderPipelineBatching = false;

一般情况下,GPU instancing的效果更好。

配置批处理

目前介绍了三种批处理的方法,我们添加一些交互性来配置这些方法:

    void DrawVisibleGeometry(bool useDynamicBatching, bool useGPUInstancing)
    {
        var sortingSettings = new SortingSettings(camera)
        {
            criteria = SortingCriteria.CommonOpaque
        };
        var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings)
        {
            enableDynamicBatching = useDynamicBatching,
            enableInstancing = useGPUInstancing
        };
        ...
    }
    public void Render(ScriptableRenderContext context, Camera camera, bool useDynamicBatching, bool useGPUInstancing)
    {
        ...
        DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
        ...
    }
    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)
    {
        foreach (Camera camera in cameras)
        {
            renderer.Render(context, camera, useDynamicBatching, useGPUInstancing);
        }
    }

最后,我们将这些属性选项放于可配置域中:

    [SerializeField]
    bool useDynamicBatching = true, useGPUInstancing = true, useSRPBatcher = true;

    protected override RenderPipeline CreatePipeline()
    {
        return new CustomRenderPipeline(useDynamicBatching, useGPUInstancing, useSRPBatcher);
    }

透明

接着修改我们的Unlit shader让其同时支持不透明和透明物体。

混合模式

我们定义两个混合模式的属性,src和dst,即源颜色和目标颜色模式,为了方便,我们使用内置的枚举类型来定义属性:

        [Enum(UnityEngine.Rendering.BlendMode)]_SrcBlend("Src Blend", Float) = 1
        [Enum(UnityEngine.Rendering.BlendMode)]_DstBlend("Dst Blend", Float) = 0

我们将Src调整为SrcAlpha,即RGB组件会预期alpha组件相乘。Dst调整为OneMinusSrcAlpha,使权重达到1。

在Pass中我们使用Blend语句设置混合模式:

        Pass
        {
            Blend [_SrcBlend] [_DstBlend]
            HLSLPROGRAM
            ...
            ENDHLSL
        }

透明度混合通常不开启深度写入,我们可以使用一个属性来控制它:

        [Enum(Off, 0, On, 1)]_Zwrite("Z Write", Float) = 1

        Pass
        {
            Blend [_SrcBlend] [_DstBlend]
            ZWrite [_ZWrite]
            HLSLPROGRAM
            ...
            ENDHLSL
        }

纹理

要使用纹理,我们先设置一下属性:

        _BaseMap("Texture", 2D) = "white"{}

纹理需要加载到GPU内存中,这一点由Unity完成,而shader需要一个相关纹理的句柄来访问纹理,该句柄可以想uniform变量一样定义,这里使用TEXTURE2D宏。同时需要定义一个采样器,用于根据包裹和滤波模式控制采样,使用SAMPLER宏定义:

TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);

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

注意这两个变量不能逐实例提供,应放在全局域中。

同时,我们还需要一个后缀为_ST的变量,用于进行纹理的拼贴和偏移,放在UnityPerMaterial缓冲中:

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

我们还需要纹理坐标,这是顶点属性的一部分:

struct Attributes
{
    float4 positionOS : POSITION;
    float2 baseUV : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

传入到片元着色器的数据中也要包含纹理坐标,可以使用任何未使用的语义:

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float2 baseUV : VAR_BASE_UV;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

纹理的缩放和偏移分别存储在_BaseMap_ST的xy和zw分量中,我们在顶点着色器中应用:

Varyings UnlitPassVertex(Attributes input)
{
    ...
    
    float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);
    output.baseUV = input.baseUV * baseST.xy + baseST.zw;
    return output;
}

在片元着色器中,我们使用SAMPLE_TEXTURE2D来进行纹理的采样:

float4 UnlitPassFragment(Varyings input) : SV_TARGET
{
    UNITY_SETUP_INSTANCE_ID(input);
    float4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV);
    float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
    float4 base = baseMap * baseColor;

    return base;
}

Alpha剔除

我们可以根据透明度来剔除片段。定义属性,声明变量_Cutoff:

        _Cutoff ("Alpha Cutoff", Range(0.0, 1.0)) = 0.5
    UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)

在片元着色器中使用clip函数剔除:

    UNITY_SETUP_INSTANCE_ID(input);
    float4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV);
    float4 baseColor = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseColor);
    float4 base = baseMap * baseColor;
    clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
    return base;

不过,我们不能在材质中同时使用透明度混合和alpha剔除,毕竟前者不写入深度,后者写入,同时其使用AlphaTest队列,位于Opaque后。因此,这里添加一个属性来配置alpha剔除:

        [Toggle(_CLIPPING)]_Clipping("Alpha Clipping", Float) = 0
#if defined(_CLIPPING)
    clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff));
#endif

同时,在shader中定义相应的shader变体:

            #pragma shader_feature _CLIPPING
            #pragma multi_compile_instancing

推荐阅读更多精彩内容