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_START
和CBUFFER_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