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

目标:

  • 书写我们的 hlsl shader

  • 支持 SRP batcher, Gpu Instancing, dynamic batching

  • 配置每个对象的材质属性,并随机渲染大量物体

  • 创建透明和镂空材质

1. Shaders

1.1 Unlit

我们的第一个例子是纯色,无光照的 shader。

通过 Assets/Create/Shader/UnlitShader ,在 Custom RP/Shaders/ 下创建 shader。我们要从头开始写我们的 shader,因此删除所有内容。

Shader "Custom RP/Unlit"
{
    Properties { }
    SubShader
    {
        Pass{}
    }
}
  • Shader 定义,后面的字符串,是在编辑器材质编辑时,选择 shader 的下拉列表框的路径

  • Properties 块定义材质属性,这些属性可以在材质编辑器中编辑

  • SubShader 块,定义一个 Pass

    • Pass 定义一种渲染方式

上面代码块都是空的,Unity 会给一个默认实现,将其渲染成实心白色,并且默认渲染队列为 2000,是默认不透明集合体的渲染队列。同时还有双面渲染开关。

1.2 HLSL

Unity shader 用 HLSL 书写,在 Pass 中,HLSLPROGRAM and ENDHLSL 关键字之间。

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

Shader 主要有2种

  • vertex 顶点变换,将顶点变换到设备空间。通过 #pragma vertex vertex_func_name 声明

  • fragment 渲染像素。通过 #pragma fragment fragment_func_name 声明

声明的 vertex/fragment 函数,可以直接写在 HLSLPROGRAM/ENDHLSL 之间,也可以写在其它的 .hlsl 文件中,并通过 #include “xxx.hlsl" 包含进来。

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

下面简单实现我们的 shader:

#ifndef CUSTOM_UNLIT_PASS_INCLUDED
#define CUSTOM_UNLIT_PASS_INCLUDED

float4 UnlitPassVertex() : SV_POSITION 
{
    return .0f;
}

float4 UnlitPassFragment() : SV_TARGET
{
    return .0f;
}

#endif
  • hlsl 文件可能被个文件引用包含,为了保证多次包含,只引入一次代码,需要用到宏来确保

  • shader 入口函数的返回值,需要有“语义”来修饰,以告诉GPU这些值用来干什么,比如上面:

    • SV_POSITION 告诉 GPU 返回值是齐次空间的顶点位置。

    • SV_TARGET 告诉 GPU 将颜色合并到 render target。

1.3 Space Transformation

vertex shader 主要工作就是将顶点变换到正确的空间。因此 shader 需要输入一个位置参数,位置参数用 POSITION 语义修饰。

顶点变换需要一些矩阵,由CPU在渲染对象时传入。这些输入都是类似的,为了后面复用,我们把这些输入定义到一个单独的文件 ShaderLibrary/UnityInput.hlsl 中。

#ifndef UNITY_INPUT_INCLUDED
#define UNITY_INPUT_INCLUDED

float4x4 unity_ObjectToWorld;
float4x4 unity_MatrixVP;

#endif

我们需要一些变换函数,将顶点变换到对应的空间。这些函数也是通用的,因此放到 ShaderLibrary/Common.hlsl 中

#ifndef COMMON_INCLUDED
#define COMMON_INCLUDED

#include "UnityInput.hlsl"

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

}

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

#endif

Unlit.hlsl 现在变成

#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 .0f;
}

#endif

1.4 core library

我们定义的两个变换函数,实际上已经在 Core RP Library package 中定义了,同时还定义了很多很有用的必须函数或其它定义。安装这个 package。

在我们的 Common.hlsl 中包含 Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl

SpaceTransforms.hlsl 中使用的是用宏定义的常量,所以我们定义这些宏。后面会讨论用宏的原因。

Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl 中定义了一些类型,如 real4,根据不同平台,可能是 float4 或 half4。

我们的 shader 现在是这样的:

UnityInput.hlsl

#ifndef UNITY_INPUT_INCLUDED
#define UNITY_INPUT_INCLUDED

float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
real4 unity_WorldTransformParams;

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

#endif

Common.hlsl

#ifndef COMMON_INCLUDED
#define 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"

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

//}

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

#endif

1.5 Color

  • 通过定义 shader 常量,来定义材质的颜色,像素着色时直接返回这个颜色

float _BaseColor;

float4 UnlitPassFragment() : SV_TARGET
{
    return _BaseColor;
}

常量前面的下划线,是告诉 shader,该常量将会被当作材质属性。

  • 通过 .shader 的 Properties ,可以在材质面板上编辑这个属性

    Properties 
    { 
        _BaseColor("Color", Color) = (1.0,1.0,1.0,1.0)
    }

属性语法规则:常量的名字("在材质面板上显示的名字", 属性类型) = 默认值

1. Batching

每次绘制,都需要 CPU 和 GPU 之间的异步操作。如果CPU向GPU传递的数据太多,就会导致浪费时间在等待(传递完成)上。同时,CPU就没有时间处理其它任务了。这最终都会导致 FPS 降低。

创建一个有80个小球的场景,分别用4个我们 shader 创建的材质:红,绿,黄,蓝色。这需要82次 draw call,80个是小球渲染,一个渲染天空盒,还有一个清理 render target。

2.1 SRP Batcher

Batching 是用来合并 draw call(按照这里的上下文,draw call 不是指 api 级别的 draw call,而是指的 unity 定义的 draw call:准备 material/object constant buffer,提交到GPU,绑定,draw),降低CPU/GPU同步的时间消耗的。

通过开启 SRP batcher 可以做到这一点,但是必须按照 SRP batcher 的要求来定义我们的 shader,否则会提示不兼容:

要兼容 SRP batcher,需要将我们的 constant buffer 定义成结构体,并且使用其命名规范:

  • UnityPerMaerial 每个材质的常量

  • UnityPerDraw 每个对象的常量

cbuffer UnityPerMaterial{
    float _BaseColor;
}

这里有个问题:不是所有的硬件/API都支持 constant buffer,为了兼容这种情况,unity 提供了一组宏来定义 cbuffer:

CBUFFER_START(UnityPerMaterial)
    float4 _BaseColor;
CBUFFER_END

对于我们的 shader,需要把对象绘制常量做类似的修改:

CBUFFER_START(UnityPerDraw)
    float4x4 unity_ObjectToWorld;
    float4x4 unity_WorldToObject;
    float4 unity_LODFade;    // 后面有用,先写上
    real4 unity_WorldTransformParams;
CBUFFER_END

如此改完后,我们的 shader 就是兼容 SRP batcher 的了。通过在Project面板中,选中我们的 Unlit.shader 可以看到。

但这还不够,还要在代码中开启 batching:

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

最后,在 FrameDebugger 中可以看到只有一个 SRP Batch:

原理:

PerMaterial/PerDraw constant 的数据被整理到一个 GPU Buffer 上,然后提交到GPU,只要在这之后常量没有发生变化,就不需要更新/提交。唯一的限制是 constant buffer layout 必须要一致,也就是说它们是一个 shader 变体。

2.2 MaterialPropertyBlock

如果我们希望每个小球都有自己的颜色,那么我们需要为每个小球创建一个材质,这工作量很大,其实不需要这样,我们可以利用 MaterialPropertyBlock:

创建一个 PerObjectMaterialProperties 的脚本来定义每个小球的颜色,并在合适的时机,通过 MaterialPropertyBlock 进行应用。

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

    [SerializeField]
    private Color baseColor = Color.white;

    static MaterialPropertyBlock matPropBlock;

    private void Awake()
    {
        OnValidate();
    }

    private void OnValidate()
    {
        if (matPropBlock == null)
            matPropBlock = new MaterialPropertyBlock();

        matPropBlock.SetColor(baseColorId, baseColor);
        GetComponent<Renderer>().SetPropertyBlock(matPropBlock);
    }
}

OnValidate 仅在编辑器,该脚本被加载,以及脚本属性被修改时调用。因此需要在 Awake 中主动调用。

不幸的是,应用了 MaterialPropertyBlock 之后,SRP batcher 将失效。

2.3 GPU Instancing

GPU Instancing 是管线将 mesh 相同,材质也相同的 draw call ,将对象的变换和材质属性收集起来,放到一个数组中提交给 GPU,通过一次 draw call,GPU 遍历每个对象的数据进行渲染。

启用 GPU Instancing,需要在 .shader 中,声明 vertex/fragment shader 前,声明 multi_compile_instancing:

#pragma multi_compile_instancing
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment

hlsl 中,instancing 相关的定义是在 UnityInstancing.hlsl 中的,因此需要在我们的 Common.hlsl 中,在 SpaceTransforms.hlsl 之前将其包含进来:

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

UnityInstancing.hlsl 主要是定义了 instancing 相关的一些宏:

  • UNITY_VERTEX_INPUT_INSTANCE_ID 为 vertex/fragment input 声明 instance id

  • UNITY_SETUP_INSTANCE_ID 准备,使 instance id 有效

  • UNITY_TRANSFER_INSTANCE_ID 将 instance id 传递到下个结构体

  • UNITY_ACCESS_INSTANCED_PROP 根据UNITY_SETUP_INSTANCE_ID 准备好的 instance id,访问当前 instance 的属性。

  • UNITY_INSTANCING_BUFFER_START/UNITY_INSTANCING_BUFFER_END

  • UNITY_DEFINE_INSTANCED_PROP

首先,将 BaseColor 声明到 instancing buffer 中:

UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
    float4 _BaseColor;
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

将顶点输入数据,定义成结构体,并声明 instance id 输入:

struct Attributes{
    float3 positionOS : POSITION;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

顶点返回,传递给 fragment 的值,也定义到结构体中,并声明 instance id 输入:

struct Varyings{
    float4 positionCS : SV_POSITION;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

修改 vertex shader:

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

最后修改 fragment shader:

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

最后,要看到效果,记得先把 SRP batching 关掉。

2.4 Graphics.DrawMeshInstanced

GPU Instancing 在绘制成百的对象时有巨大的提升,但是在场景中编辑这么多对象不太现实。在某些情况下,可能需要通过一种程序化的方式,创建,渲染大量对象。

下面的例子,随机生成了1023个球,并且将它们是变换,颜色,分别收集起来,通过 MaterialPropertyBlock 应用这些球的颜色,最后通过 Graphics.DrawMeshInstanced 进行渲染:

public class MeshBall : MonoBehaviour
{
    [SerializeField]
    Mesh mesh = default;
    [SerializeField]
    Material material = default;

    static int colorID = Shader.PropertyToID("_BaseColor");
    MaterialPropertyBlock matPropBlock = new MaterialPropertyBlock();

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

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

        matPropBlock.SetVectorArray(colorID, baseColors);
    }

    void Update()
    {
        Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 1023, matPropBlock);
    }
}

这些小球以创建的顺序渲染,而且无法被裁剪,因为我们直接调用了 Graphics 的接口。

2.5 Dynamic Batching

对于那些使用同一个材质,且面数很低的模型,可以将这些对象的 mesh 合并成一个 mesh,一次 draw call 完成渲染。

通过配置 DrawingSettings 启用该功能:

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

同时禁用 SRP batcher:

GraphicsSettings.useScriptableRenderPipelineBatching = false;

还有 static batching,原理同 dynamic batching,却别是离线进行合并。

2.6 Configuring Batching

我们希望在我们的RP中,将这些 batching 策略,作为选项进行配置。

首先 DrawVisibleGeometry 支持这些开关:

void DrawVisibleGeometry(bool useDynamicBatching, bool useGPUInstancing)
{
    // 渲染不透明物体
    var sortingSettings = new SortingSettings(camera){ criteria = SortingCriteria.CommonOpaque };
    var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings)
    { enableDynamicBatching = useDynamicBatching, enableInstancing = useGPUInstancing};
    ...
}

然后在 pipeline asset 声明对应的属性,以便用户编辑,创建管线实例时将参数传递进去,就可以了:

[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset 
{
    [SerializeField] bool useDynamicBatching = false;
    [SerializeField] bool useGPUInstancing = false;

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

3. 透明

可以改变材质的 Render Queue 为 Transparent 使材质在透明阶段渲染,但是仅仅这样还没有效果,还需要让 shader 支持混合。

3.1 Blend Mode

首先要在 Pass 定义中声明混合:

Pass {
        Blend [_SrcBlend] [_DstBlend]

        HLSLPROGRAM
        …
        ENDHLSL
      }

其次,定义材质属性。这里利用unity定义的枚举定义属性:

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

然后在材质编辑面板中,就可以看到并编辑混合模式了:

Src 指的是当前像素着色器计算的颜色

Dst 指的是当前 render target 上的颜色。

开启混合时,Src Blend 和Dst Blend 分别指定为 SrcAlpha 和 OneMinusSrcAlpha,指示混合公式为 SrcColor.rgb * SrcColor.a + DstColor.rgb * (1-SrcColor.a)。

可以看到效果(下面的不透明的,上面的半透明的):

注意:

  • 记得要把材质中的颜色的 alpha 值,改为128(0.5)

  • 对于GPU Instancing,由于透明物体是排序渲染的,因此合批是否成功依赖于距离摄像机的距离,所以根据视角的不同,合批结果也会不同。

3.2 Not Writting Depth

半透明渲染不需要写深度,因此我们需要给材质一个开关,当配置为半透明渲染时,禁止写深度。

[Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 0
[Enum(Off, 0, On, 1)] _ZWrite ("Z Write", Float) = 1
...
Blend [_SrcBlend] [_DstBlend]
ZWrite [_ZWrite]

然后在材质编辑面板关闭

3.3 Texturing

该节介绍如何采样贴图,并使用贴图中的alpha的值作为透明度。

  • 首先在材质属性中定义贴图属性:

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

贴图名为"Texture",类型是 2D,默认是Unity 系统提供的 "white" 白色贴图。后面的 {} 是历史遗留特性,没用,但是不能没有,避免出现奇怪的错误。修改后,材质属性面板显示为:

  • 然后修改hlsl:

    • 声明全局贴图及其采样器变量。采样器变量名是在贴图变量名前加sampler

      TEXTURE2D(_BaseMap);
      SAMPLER(sampler_BaseMap);
      
      UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
          UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
      UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
    • 采样贴图,需要顶点提供贴图坐标

      struct Attributes {
              float3 positionOS : POSITION;
              float2 baseUV : TEXCOORD0;UNITY_VERTEX_INPUT_INSTANCE_ID
      };
    • 在材质中,可以配置贴图坐标的缩放和偏移

      UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
              UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
              UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
      UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
    • 材质坐标在顶点着色器中应用缩放和偏移后,交给光栅化器进行插值

      struct Varyings {
              float4 positionCS : SV_POSITION;
              float2 baseUV : VAR_BASE_UV;UNITY_VERTEX_INPUT_INSTANCE_ID
      };
      
      Varyings UnlitPassVertex (Attributes input) {
              …
      
              float4 baseST = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _BaseMap_ST);
              output.baseUV = input.baseUV * baseST.xy + baseST.zw;
              return output;
      }
  • 最后在片段着色器中完成采样

    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);
            return baseMap * baseColor;
    }

效果如下图

3.4 Alpha Clipping

定义一个阈值,在像素着色时,对于那些 alpha 值小于这个阈值的像素,直接丢弃,最终渲染出”镂空“的效果。

  • 首先在材质属性中定义阈值 _Cutoff:

    _Cutoff("Alpha Cutoff", Range(0.0,1.0) = 0.5
  • _Cutoff 是材质参数,因此定义到 UnityPerMaterial 中:

    UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
    UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
  • 在像素着色器中,比较 a 的值,如果小于 _Cutoff ,则 clip:

    float4 base = baseColor * baseMap;
    clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff);

编辑材质,设置一个 _Cutoff 参数。

同时clipping渲染是在 AlphaTest 队列中渲染的,该队列在不透明物体渲染完后渲染。

3.5 Shader Features

一个材质,半透明和 alpha test,不能同时存在,因此需要一个开关。同时需要让 hlsl 根据开关执行不同的逻辑。

Shader Features 可以实现该特性。

  • 首先在材质属性中添加一个 Feature Toggle 开关,名字为“Alpha Clipping",定义了一个宏关键字:_CLIPPING

    [Toggle(_CLIPPING)] _Clipping("Alpha Clipping", Float) = 0
  • 在 .shader 中声明 shader feature:

    #pragma shader_feature _CLIPPING
  • 在 hlsl 中,用宏将 clip 的代码包起来:

    #if defined(_CLIPPING)
    clip(base.a - UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Cutoff);
    #endif

最后,看看效果:

Logo

这里是一个专注于游戏开发的社区,我们致力于为广大游戏爱好者提供一个良好的学习和交流平台。我们的专区包含了各大流行引擎的技术博文,涵盖了从入门到进阶的各个阶段,无论你是初学者还是资深开发者,都能在这里找到适合自己的内容。除此之外,我们还会不定期举办游戏开发相关的活动,让大家更好地交流互动。加入我们,一起探索游戏开发的奥秘吧!

更多推荐