前言
Jasper Flick《Unity可编程渲染管线》系列教程之:自定义着色器。该教程分享了用户如何在现有渲染管线的基础上从头构建简易的着色器。原文链接可见该博客末尾。
在上一章节中,我们使用了Unity默认的 无光照着色器(Unlit Shader) 来测试我们的自定义渲染管线。但想要完全的发掘自定义渲染管线的优势的话,默认的着色器是满足不了我们这一点的,因此我们需要创建自定义渲染管线。
我们可以在Unity编辑器中打开 “Assets / Create / Shader” 菜单,从中选择一种着色器来编辑。在此教程中,我们将选择其中的无光照着色器,然后删除Untiy预先生成好的代码,重头开始构建着色器。我们把创建好的着色器命名为 “Unlit”。
一个最简易的可以运行的着色器,由 Shader 主模块,及主模块其中的 Properties,SubShader,Pass子模块组成。这些模块经由Unity引擎处理后会成为一个进能够对白色进行着色的无光照着色器。通过在 Shader 模块后添加字符串,我们可以在Unity编辑器中的菜单添加对应名称的着色器选项。在此教程中,我们将其命名为 “My Pipeline / Unlit”。
Shader "My Pipeline/Unlit" {
Properties {}
SubShader {
Pass {}
}
}
若场景中使用了 Unlit Opaque 材质的物体未变成白色,我们需要手动地为其选择使用我们刚刚创建的自定义着色器。
着色器所使用的不是 Java,C/C++ 这类常规编程语言,而是被称作着色语言的特定语言。Unity引擎支持GLSL和HLSL,Unity内置的默认着色器普遍使用GLSL,而Unity2018版本中新加入的渲染管线则使用HLSL,因此我们的着色器也需要使用HLSL。要使用HLSL,我们需要在着色器的 Pass 模块中添加 HLSLPROGRAM 和 ENDHLSL 宏。
Pass {
HLSLPROGRAM
ENDHLSL
}
最基本的,一个Unity着色器需要包含一个负责处理 顶点(Vertex) 的函数和一个负责处理 片段(Fragment) 的函数,两者均需要带有 pragma 编译指令。在本教程中,顶点处理函数使用的是 UnlitPassVertex,片段处理函数使用的是 UnlitPassFragment。我们不会在着色器的脚本中编写这两个函数的代码,这样会使着色器自身的代码显得过于臃肿,我们会把这些额外的代码封装成库,再在着色器中调用这个库,在此教程中,我们将该库也命名为 “Unlit”,文件类型为 “.hlsl”。
// In .shader file
Pass {
HLSLPROGRAM
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
#include "Unlit.hlsl"
ENDHLSL
}
由于Unity引擎无法直接创建 “.hlsl” 格式的文件。所以我们需要先创建一个无关文件,例如 “xxx.txt”,再手动把文件格式改为 “xxx.hlsl”。
为了防止库文件被其他程序引用时,产生多余的代码段,我们需要在库文件的开头加入引用检测机制:
// In .hlsl file
#ifndef MYRP_UNLIT_INCLUDED
#define MYRP_UNLIT_INCLUDED
#endif // MYRP_UNLIT_INCLUDED
前面提到,我们需要在库中实现一个负责处理顶点的函数和一个负责处理片段的函数。对于顶点处理函数而言,它的返回值是一个 齐次裁剪空间位置(Homogeneous Clip-space Position),为此需要为其提供顶点的位置作为传参。基于这一点,我们首先为顶点处理函数声明一个输入结构体和一个输出结构体:
// In .hlsl file
#ifndef MYRP_UNLIT_INCLUDED
#define MYRP_UNLIT_INCLUDED
struct VertexInput {
float4 pos : POSITION;
};
struct VertexOutput {
float4 clipPos : SV_POSITION;
};
#endif // MYRP_UNLIT_INCLUDED
接下来我们便可以声明定点处理函数 UnlitPassVertex。到目前为止,我们直接把 物体空间顶点位置(Object-space Vertex Position) 当作是裁剪空间位置使用,这是不对的,我们将在后面对其进行修改。此外,我们也简单地声明片段处理函数 UnlitPassFragment,其需要的输入参数为顶点处理函数的输出,而由于目前我们还不需要为着色器添加额外的功能,我们可以先让片段输出函数返回1作为输出。
// In .hlsl file
struct VertexOutput {
float4 clipPos : SV_POSITION;
};
VertexOutput UnlitPassVertex (VertexInput input) {
VertexOutput output;
output.clipPos = input.pos;
return output;
}
float4 UnlitPassFragment (VertexOutput input) : SV_TARGET {
return 1;
}
#endif // MYRP_UNLIT_INCLUDED
到目前为止我们的着色器已经能够编译运行,尽管显示出来的效果并不正确。下一步我们将要把顶点空间转换为正确的空间。如果我们能够得到一个 模型视角投影矩阵(Model-View-Projection Matrix),那么我们可以直接将对象空间转换为裁剪空间。但Unity并不会为我们提供该矩阵,我们能够利用的只有 模型矩阵(Model Matrix),我们可以利用该矩阵把对象空间转换为世界空间。Unity引擎希望得到一个 float4x4 unity_ObjectToWorld 变量来储存该矩阵,为此我们需要在代码中手动地声明这一个变量,在顶点处理函数中用其来把对象空间转换为世界空间。
// In .hlsl file
float4x4 unity_ObjectToWorld;
struct VertexInput {
float4 pos : POSITION;
};
struct VertexOutput {
float4 clipPos : SV_POSITION;
};
VertexOutput UnlitPassVertex (VertexInput input) {
VertexOutput output;
float4 worldPos = mul(unity_ObjectToWorld, input.pos);
output.clipPos = worldPos;
return output;
}
接下来,我们需要把世界空间转换为裁剪空间,完成这一步需要使用到 视角投影矩阵(View-Projection Matrix)。Unity引擎使用一个 float4x4 unity_MatrixVP 变量来保存该矩阵。
// In .hlsl file
float4x4 unity_MatrixVP;
float4x4 unity_ObjectToWorld;
...
VertexOutput UnlitPassVertex (VertexInput input) {
VertexOutput output;
float4 worldPos = mul(unity_ObjectToWorld, input.pos);
output.clipPos = mul(unity_MatrixVP, worldPos);
return output;
}
完成该步后,我们的着色器便可进行正确的空间转换了。我们可以在Unity编辑器中重新看到我们之前摆放的物体,且被涂上了白色。然而我们采用的空间转换方式并不高效,因为程序需要进行一次矩阵乘四维向量的乘法运算,而实际上向量的第四维永远为1,因此我们可以通过显式地把向量的第四位声明为1,从而让编译器可以优化运算。
float4 worldPos = mul(unity_ObjectToWorld, float4(input.pos.xyz, 1.0));
上一节提到过,Unity引擎不会直接为我们提供模型视角投影矩阵,那是因为该矩阵的功能同样能通过模型矩阵和视角投影矩阵完成。除此以外,视角投影矩阵可以在单个帧内摄像机描绘多个物体时被重复使用,因此我们会希望能够让这些数据能够被保存起来,避免进行重复计算。在我们上一节的代码中,我们把模型矩阵和视角投影矩阵声明为了变量,但他们的数据在引擎对单个空间进行描绘时是被当作常量储存起来的,我们需要额外地把这些数据存放在缓存中。在此教程中,视角投影矩阵需要被放入 每帧缓存(Pre-Frame Buffer),模型矩阵需要被放入 每描绘缓存(Per-Draw BUffer)。
当然着色器变量并不一定需要存放在常量缓存中,只是这样能够让所有在缓存中的数据能够更有效的被更改。
// In .hlsl file
cbuffer UnityPerFrame {
float4x4 unity_MatrixVP;
};
cbuffer UnityPerDraw {
float4x4 unity_ObjectToWorld;
}
由于常量缓存并不是在任何平台上都能发挥它的优势,因此着色器更适合在需要用到的时候才通过调用宏来使用常量缓存。在此教程中,我们用 CBUFFER_START 和 CBUFFER_END 宏来替换掉先前的 cbuffer
// In .hlsl file
CBUFFER_START(UnityPerFrame)
float4x4 unity_MatrixVP;
CBUFFER_END
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
CBUFFER_END
不过直接这么使用的话Unity引擎会报告编译错误,这是因为这两个宏还未被定义。相比起手动地判断何时适合使用常量缓存并定义宏来调用,我们可以利用Unity引擎内置的核心库来决定何时定义宏。我们可以在Unity编辑器的 包管理窗口(Package Manager Window) 中增加Unity核心库。我们先切换到 All Packages 的根目录,并在 Advanced 选项下激活 Show Preview Packages,然后选择 Render-pipelines.core,选择后会弹出安装提示,我们按提示安装好即可。在此教程中,我们安装的是 4.6.0-preview 版,这是能兼容 Unity 2018.3 的最高版本。
现在我们便可以在代码中引用Unity核心库了。该核心库的路径是 “Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl”。
// In .hlsl file
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
CBUFFER_START(UnityPerFrame)
float4x4 unity_MatrixVP;
CBUFFER_END
通过引用核心库,我们的着色器能够重新运作了,但在针对 OpenGL ES 2 平台的编译依然还是会报错,这是因为Unity引擎针对 OpenGL ES 2 平台的编译器不支持自身的核心库。解决这个问题的一个方法是增加 #pragma prefer_hlslcc gles 编译指令,该编译指令通常被用在Unity引擎中的轻量渲染管线的着色器编译上。然而 OpenGL ES 2 是一款针对旧移动设备开发而出的平台,当下的主流平台已经基本弃用 OpenGL ES 2,我们实际上并不需要针对 OpenGL ES 2 进行编译。为此我们可以使用 #pragma target 编译指令,手动把我们的编译层级从默认的2.5指定为3.5。
// In .shader file
#pragma target 3.5
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
为了更好地管理我们的各个文件,我们在 My Pipeline 目录下新建一个 ShaderLibrary 文件夹,并把 Unlit.hlsl 文件放在 ShaderLibrary 文件夹下。然后再新建一个 Shader 文件夹,把 Unlit 文件放在 Shader 文件夹下。
由于改变了文件路径,在着色器代码中,我们也需要把引用的库的路径做相应的调整
// In .shader file
#include "../ShaderLibrary/Unlit.hlsl"
当前整体的代码如下:
着色器代码:
// In .shader file
Shader "My Pipeline/Unlit" {
Properties {}
SubShader {
Pass {
HLSLPROGRAM
#pragma target 3.5
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
#include "../ShaderLibrary/Unlit.hlsl"
ENDHLSL
}
}
}
库代码:
// In .hlsl file
#ifndef MYRP_UNLIT_INCLUDED
#define MYRP_UNLIT_INCLUDED
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
CBUFFER_START(UnityPerFrame)
float4x4 unity_MatrixVP;
CBUFFER_END
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
CBUFFER_END
struct VertexInput {
float4 pos : POSITION;
};
struct VertexOutput {
float4 clipPos : SV_POSITION;
};
VertexOutput UnlitPassVertex (VertexInput input) {
VertexOutput output;
float4 worldPos = mul(unity_ObjectToWorld, float4(input.pos.xyz, 1.0));
output.clipPos = mul(unity_MatrixVP, worldPos);
return output;
}
float4 UnlitPassFragment (VertexOutput input) : SV_TARGET {
return 1;
}
#endif // MYRP_UNLIT_INCLUDED
现在我们已经具备了一个实现了最基础功能的自定义着色器,我们可以用它来进一步研究渲染管线是如何执行渲染的。有一个我们一直以来关心的问题,是渲染管线的性能极限究竟在哪。我们通过在场景中加入大量的物体来测试渲染管线的性能。物体的位置可以随意设置,但需要保持各物体的大小比例是一致的。
当我们通过帧调试器来监视场景的渲染状态时,我们会注意到每一个物体都需要调用一次描绘命令来描绘自身。很明显种情况下的性能是不高效的,因为每一个描绘命令都会需要CPU与GPU之间的通信。理想状态下,最好是能够通过一次描绘命令来描绘多个物体,通过选中帧调试器中的一个描绘命令,我们可以查看到关于多重描绘的相关建议。
通过查看帧调试器的提示我们可以得知,由于批处理功能没有被启用或是受到 深度排序(Depth Sorting) 的干扰,我们暂时无法使用批处理功能。如果我们去查看玩家设置的话,我们会发现 Dynamic Batching 选项确实处于未启用状态。然而我们直接启用它的话并不会出现我们所期望的效果,这是因为我们启用的是Unity引擎内置的默认渲染管线的批处理功能,不会应用到我们的自定义渲染管线。
想要为我们的渲染管线启用动态批处理功能,我们需要手动在我们的代码中启用它。更准确地,我们需要在渲染管线进行描绘前启用它。我们需要在渲染管线的 MyPipeline.Render 部分中,把描绘设定设定为 DrawRendererFlags.EnableDynamicBatching。
// In render pipeline file
var drawSettings = new DrawRendererSettings(
camera, new ShaderPassName("SRPDefaultUnlit")
);
drawSettings.flags = DrawRendererFlags.EnableDynamicBatching;
drawSettings.sorting.flags = SortFlags.CommonOpaque;
做完这一步后我们再去帧调试器查看,动态批处理功能依然没有启用,但原因却和之前不同。动态批处理的原理是Unity引擎把多个物体归并到同一个 网格(Mesh) 中,这样就能同时对多个物体进行描绘。归并多个物体到同一个网格会消耗CPU时间,因此只有较小的网格支持这种操作。在三维物体中,球体的网格很大,立方体的网格则相对小很多。如果场景中的事球体物体,那么则需要将其改成立方体后重新尝试。
动态批处理支持应用了同一种材质的小型网格。但如果网格采用了多种材质,那么情况就会变得复杂。为了深入研究这一点,我们将为我们的着色器添加颜色更改功能。我们需要在着色器的 Properties 模块中增加颜色属性,且用白色作为默认颜色。
// In .shader file
Properties {
_Color ("Color", Color) = (1, 1, 1, 1)
}
此外我们还需要在库文件中,新增一个 float4 _Color 变量,并令片段处理函数返回该变量。由于每种材质都会单独声明一次颜色,因此我们也可以把颜色存入常量缓存中,那么Unity引擎只需要在切换材质时再对颜色进行更改。在此教程中,我们把颜色的常量缓存命名为 UnityPerMaterial。
// In .hlsl file
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
CBUFFER_END
CBUFFER_START(UnityPerMaterial)
float4 _Color;
CBUFFER_END
struct VertexInput {
float4 pos : POSITION;
};
...
float4 UnlitPassFragment (VertexOutput input) : SV_TARGET {
return _Color;
}
接下来我们新建多个材质,并把材质设为不同的颜色以加以区分。最后为场景中的物体应用上不同的材质。我们可以从帧调试器中看到,动态批处理功能被启用了,且出现了多个批处理。从理论上讲,Unity引擎每种材质分配一个批处理,且由于Unity引擎还会对批处理进行优化分组以避免 堆叠效应(Overdraw),我们通常会看到批处理的数量会大于场景中物体使用了的材质的总数量。
尽管动态批处理在某些场景下能够优化渲染性能,但在另一些情况下,则有可能会降低渲染性能。如果场景中应用了相同材质的的小型网格不多,那么禁用掉动态批处理将会是一个明智的举措,因为这样Unity引擎就不需要额外地在每一帧的渲染中判断是否需要执行动态批处理。为此我们需要为我们的渲染管线添加一个功能,使得我们可以设置动态批处理是否要被启用。这一功能无法在玩家设置上实现,我们需要直接为 MyPipelineAsset 添加一个启动设置器。
// In render pipeline file
[SerializeField]
bool dynamicBatching;
当我们的渲染管线示例被创建后,我们需要告诉该实例是否需要启用动态批处理功能。我们将上文所创建的变量作为参数传入到渲染管线的构造函数中:
// In render pipeline file
protected override IRenderPipeline InternalCreatePipeline () {
return new MyPipeline(dynamicBatching);
}
由于Unity库提供的渲染管线构造函数并不接受任何传参,因此我们需要抛弃默认的构造函数,重写构造函数。新的构造函数需要接受 dynamicBatching 作为传参。在构造函数内部,根据传入的参数值来判断描绘时是否需要启用动态批处理,最后再对 Render 中的描绘设定进行设置。
// In render pipeline file
DrawRendererFlags drawFlags;
public MyPipeline (bool dynamicBatching) {
if (dynamicBatching) {
drawFlags = DrawRendererFlags.EnableDynamicBatching;
}
}
drawSettings.flags = drawFlags;
需要注意的是,每当我们在Unity编辑器中更改 Dynamic Batching 的状态,Untiy的批处理状态会被立即更改,所以每当我们调整一次 Dynamic Batching,就会有一个新的渲染管线示例被创建。
当前整体的代码如下:
渲染管线代码(由于原代码过长,笔者进行了适当的缩减,原代码可以在上一章节中查看):
// In render pipeline file
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Experimental.Rendering;
using Conditional = System.Diagnostics.ConditionalAttribute;
[SerializeField]
bool dynamicBatching; // Receive user instruction regarding to enabling dynamic batching
...
/* Pipeline object field */
public class MyPipeline : RenderPipeline {
DrawRendererFlags drawFlags;
public MyPipeline (bool dynamicBatching) {
if (dynamicBatching) {
drawFlags = DrawRendererFlags.EnableDynamicBatching;
}
}
...
void Render (ScriptableRenderContext context, Camera camera) {
...
/* Drawing */
var drawSettings = new DrawRendererSettings( // Set pipeline to use default unlit shader pass
camera, new ShaderPassName("SRPDefaultUnlit"));
drawSettings.flags = drawFlags;
drawSettings.sorting.flags = SortFlags.CommonOpaque; // Sort opaque object render order
var filterSettings = new FilterRenderersSettings(true) { // Limit pipeline to render opaque object frist
renderQueueRange = RenderQueueRange.opaque}; // Render queue range: 0 ~ 2500
context.DrawRenderers(cull.visibleRenderers, ref drawSettings, filterSettings);
...
}
/* Pipeline asset field */
[CreateAssetMenu(menuName = "Rendering/My Pipeline")] // Add pipeline asset to editor menu
public class MyPipelineAsset : RenderPipelineAsset {
protected override IRenderPipeline InternalCreatePipeline () {
return new MyPipeline(dynamicBatching); // Instantiate pipeline object
}
}
着色器代码:
// In .shader file
Shader "My Pipeline/Unlit" {
Properties {
_Color ("Color", Color) = (1, 1, 1, 1)
}
SubShader {
Pass {
HLSLPROGRAM
#pragma target 3.5
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
#include "../ShaderLibrary/Unlit.hlsl"
ENDHLSL
}
}
}
库代码:
// In .hlsl file
#ifndef MYRP_UNLIT_INCLUDED
#define MYRP_UNLIT_INCLUDED
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
CBUFFER_START(UnityPerFrame)
float4x4 unity_MatrixVP;
CBUFFER_END
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
CBUFFER_END
CBUFFER_START(UnityPerMaterial)
float4 _Color;
CBUFFER_END
struct VertexInput {
float4 pos : POSITION;
};
struct VertexOutput {
float4 clipPos : SV_POSITION;
};
VertexOutput UnlitPassVertex (VertexInput input) {
VertexOutput output;
float4 worldPos = mul(unity_ObjectToWorld, float4(input.pos.xyz, 1.0));
output.clipPos = mul(unity_MatrixVP, worldPos);
return output;
}
float4 UnlitPassFragment (VertexOutput input) : SV_TARGET {
return _Color;
}
#endif // MYRP_UNLIT_INCLUDED
动态批处理机制不是我们唯一能减少每一帧调用描绘指令的策略,另一个策略是采用GPU实例化。在实例化过程中,CPU会指示GPU在单个描绘指令中对特定的网格-材质组合进行多次描绘。这样我们便有可能对使用了相同的网格和材质的物体组合起来描绘,而不是需要额外地新建一个新网格。这样便也解除了网格大小的限制。
GPU实例化在默认状态下是开启的,与动态批处理类似,我们也GPU实例化功能添加启用开关。这样我们便可更加方便地比较有无GPU实例化对渲染性能地影响。
// In render pipeline file
[SerializeField]
bool instancing;
protected override IRenderPipeline InternalCreatePipeline () {
return new MyPipeline(dynamicBatching, instancing);
}
在 MyPipeline 构造函数中,同样为GPU实例化增加相应的判断。我们采用 布尔-或(Boolean-OR) 检测机制,这样便可同时设置动态批处理和GPU实例化功能。
// In render pipeline file
public MyPipeline (bool dynamicBatching, bool instancing) {
if (dynamicBatching) {
drawFlags = DrawRendererFlags.EnableDynamicBatching;
}
if (instancing) {
drawFlags |= DrawRendererFlags.EnableInstancing;
}
}
启用了GPU实例化并不代表场景中的物体力能自动地参与到实例化中。这需要获得物体所应用的材质的支持。由于实例化并不是对任何材质而言都是需要的,因此我们需要两种着色器 变体(Variants):一种支持GPU实例化,另一种不支持GPU实例化。我们可以通过在着色器代码中声明 #pragma multi_compile_instancing 来创建我们所需要的所有变体。在此教程中,该指令会生成两种变体,其中一种定义了 INSTANCING_ON 关键字,另一种则没有。
// In .shader file
#pragma target 3.5
#pragma multi_compile_instancing
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
完成这一步后,我们便可在Unity编辑器的材质窗口中,看到 Enable GPU Instancing 选项。
实例化启用后,GPU会被告知使用相同的常量数据来对相同的网格进行多次描绘。但模型矩阵也是常量数据中的一部分,那意味着我们对相同网格的多次渲染采用的是相同的渲染方式。为了改进这一点,我们需要用一个列表来储存所有物体的模型矩阵,再把该列表存入常量缓存中。这样每一个实例便可使用与自己相对应的模型矩阵来进行描绘。
那么对于不执行实例化的着色器变体,我们只使用最开始声明的 unity_ObjectToWorld 变量。对于需要执行实例化的着色器变体,则需要另外声明一个矩阵列表。为了避免重复的代码,我们定义一个新的宏 UNITY_MATRIX_M,使用该名字作为宏名是因为Unity核心库中也定义了相同的宏来处理两种着色器变体的情况。该核心库的路径在 “Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl”。但为了避免出现重定义的报错,我们需要先定义自己的宏,再引用核心库。
// In .hlsl file
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
CBUFFER_END
#define UNITY_MATRIX_M unity_ObjectToWorld
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"
...
VertexOutput UnlitPassVertex (VertexInput input) {
VertexOutput output;
float4 worldPos = mul(UNITY_MATRIX_M, float4(input.pos.xyz, 1.0));
output.clipPos = mul(unity_MatrixVP, worldPos);
return output;
}
在进行GPU实例化时,当前正在被描绘的物体的索引会被GPU添加到该物体的顶点数据中。前面我们定义的 UNITY_MATRIX_M 需要依赖于这个索引,因此我们需要在 VertexInput 结构体中加入物体的索引值的宏 UNITY_VERTEX_INPUT_INSTANCE_ID。
// In .hlsl file
struct VertexInput {
float4 pos : POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
最后,我们需要在顶点处理函数中 UnlitPassVertex 中把获取到的索引用于调取对应的模型矩阵。
// In .hlsl file
VertexOutput UnlitPassVertex (VertexInput input) {
VertexOutput output;
UNITY_SETUP_INSTANCE_ID(input);
float4 worldPos = mul(UNITY_MATRIX_M, float4(input.pos.xyz, 1.0));
output.clipPos = mul(unity_MatrixVP, worldPos);
return output;
}
如此一来,场景中的物体便被执行实例化了。和同动态批处理一样,由于使用了多种材质,Unity引擎也生成了多个实例化批处理。
除了模型矩阵以外,视角投影矩阵也会被存放入常量缓存中。视角投影矩阵是模型矩阵的逆矩阵,用于在非均匀比例下计算法向量。由于在此教程中,我们使用的是均匀比例,因此我们不需要这些额外的矩阵。所以我们可以通过 #pragma instancing_options assumeuniformscaling 指令来告知Unity引擎这一点。但如果我们需要支持非均匀比例,那么便需要为这种情况创建一个着色器变体或一个新的着色器。
// In .shader file
#pragma multi_compile_instancing
#pragma instancing_options assumeuniformscaling
如果我们想要让场景中的物体具备多种颜色的话,我们便需要创建多种材质,这意味着在渲染中会产生更多的批处理。但正如转换矩阵能够被储存进列表中统一处理,我们也能对颜色做相同的处理。如此一来我们便可在同一个批处理下处理具有不同颜色的物体。
我们首先需要设定一个功能,让我们可以通过该功能来为每一个物体设定其自身的颜色。我们不能采用为物体添加不同的材质的方法,因为我们本来就是要研究如何让所有物体使用同一种材质,但各自的颜色互不相同。创建一个名为 InstancedColor 的组件,并在组件的脚本中增加供用户设定颜色的功能代码,最后为场景中的物体增加该组件:
// In component script file
using UnityEngine;
public class InstancedColor : MonoBehaviour {
[SerializeField]
Color color = Color.white;
}
完成这一步后,我们需要用组件设定的颜色来覆盖掉材质设定的颜色,为此我们便需要为物体的渲染组件提供材质属性模块。我们首先创建一个 MaterialPropertyBlock 对象实例,并通过该实例的 SetColor 方法设定其颜色属性。最后通过 SetPropertyBlock 方法把实例传入到物体的 MeshRenderer 组件中。我们假设物体的颜色在渲染过程中是保持不变的。
// In component script file
[SerializeField]
Color color = Color.white;
void Awake () {
var propertyBlock = new MaterialPropertyBlock();
propertyBlock.SetColor("_Color", color);
GetComponent<MeshRenderer>().SetPropertyBlock(propertyBlock);
}
把我们的组件增加到物体中后,再执行渲染,我们便可看到物体的颜色较之前发生了改变。如果我们想要不执行渲染,直接在编辑器窗口中就能看到颜色改变,那么我们需要对代码做以下调整:
// In component script file
[SerializeField]
Color color = Color.white;
void Awake () {
OnValidate();
}
void OnValidate () {
var propertyBlock = new MaterialPropertyBlock();
propertyBlock.SetColor("_Color", color);
GetComponent<MeshRenderer>().SetPropertyBlock(propertyBlock);
}
接着我们为场景中的所有物体都添加上 InstancedColor 组件,不过我们需要确保所有物体只需要添加一次该组件就可以了,而且所有物体都应该使用同一种材质。
根据我们在上文写的代码,引擎在每次用组件颜色覆盖材质颜色的时候,都会创建一个新的 MaterialPropertyBlock 实例。然而这是没有必要的,因为每个物体的 网格渲染器(Mesh Render) 都会持续的记录被覆盖的属性,并把他们从属性模块中拷贝出来,这意味着我们可以重用 MaterialPropertyBlock,只需要在必要的时候才重新创建。为此我们需要对代码做以下调整:
// In component script file
[SerializeField]
Color color = Color.white;
static MaterialPropertyBlock propertyBlock;
...
void OnValidate () {
if (propertyBlock == null) {
propertyBlock = new MaterialPropertyBlock();
}
propertyBlock.SetColor("_Color", color);
GetComponent<MeshRenderer>().SetPropertyBlock(propertyBlock);
}
此外,我们还能通过预先为每个颜色属性分配其专属ID来进一步优化性能:
// In component script file
[SerializeField]
Color color = Color.white;
static MaterialPropertyBlock propertyBlock;
static int colorID = Shader.PropertyToID("_Color");
...
void OnValidate () {
if (propertyBlock == null) {
propertyBlock = new MaterialPropertyBlock();
}
propertyBlock.SetColor(colorID, color);
GetComponent<MeshRenderer>().SetPropertyBlock(propertyBlock);
}
为每个物体覆盖颜色会导致GPU实例化中断。尽管场景中的物体使用的是同一种材质,但实际产生影响的是用于渲染的数据。当我们为对每一个物体执行了覆盖颜色的操作,我们实际上强制他们单独地进行了描绘。
为此,我们采取的优化方法是把所有的颜色数据像模型矩阵一样存放到列表中,这样GPU实例化就可以重新正常运行了。但与模型矩阵的处理不同的是,Unity的核心库没有提供我们可以利用的现成的宏,因此我们需要手动地通过 UNITY_INSTANCING_BUFFER_START 宏来创建常量缓存。宏字段的中间,我们把颜色定义为一个 UNITY_DEFINE_INSTANCED_PROP(float4, _Color) 宏,如果着色器启用了GPU实例化功能,那么该宏会创建一个列表来存放颜色属性,若GPU实例化功能未被启用,则颜色将依旧以 float4 _Color 变量的形式存在。
// In .hlsl file
//CBUFFER_START(UnityPerMaterial)
//float4 _Color;
//CBUFFER_END
UNITY_INSTANCING_BUFFER_START(PerInstance)
UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
UNITY_INSTANCING_BUFFER_END(PerInstance)
随后我们需要在片段处理函数中做相应的修改,使其能够分别处理颜色为单变量,或颜色为列表的情况。我们通过添加 UNITY_ACCESS_INSTANCED_PROP 宏来实现这一功能。
// In .hlsl file
float4 UnlitPassFragment (VertexOutput input) : SV_TARGET {
return UNITY_ACCESS_INSTANCED_PROP(PerInstance, _Color);
}
经过调整后,片段处理函数能够处理列表形式的颜色数据,同样也可以处理带索引的GPU实例。若要增加这一功能,我们先为 VertexOutput 增加 UNITY_VERTEX_INPUT_INSTANCE_ID 宏,接下来为片段处理函数增加 UNITY_SETUP_INSTANCE_ID 宏。最后我们通过 UNITY_TRANSFER_INSTANCE_ID 宏来把实例索引从 VertexInput 传到 VertexOutput 中。
// In .hlsl file
...
struct VertexInput {
float4 pos : POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct VertexOutput {
float4 clipPos : SV_POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
VertexOutput UnlitPassVertex (VertexInput input) {
VertexOutput output;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
float4 worldPos = mul(UNITY_MATRIX_M, float4(input.pos.xyz, 1.0));
output.clipPos = mul(unity_MatrixVP, worldPos);
return output;
}
float4 UnlitPassFragment (VertexOutput input) : SV_TARGET {
UNITY_SETUP_INSTANCE_ID(input);
return UNITY_ACCESS_INSTANCED_PROP(PerInstance, _Color);
}
完成这一步后,所有物体便可以在单个描绘指令中完成描绘,即使他们采用了不一样的颜色。然而常量缓存是有空间限制的,实例的最大批处理大小取决于我们为每个实例分配的空间。除此以外,缓存的大小也收到平台的影响,因此我们还是应该尽可能的使用较小的网格和材质。例如如果我们混合地使用球形物体和立方物体,那么批处理就不得不分开执行,导致一定程度的性能降低。
到目前为止,我们已经创建了一个最基本且性能尽可能地被优化的着色器。在后续的章节中,我们将基于该着色器,继续为其添加高级的功能。
最终的的代码如下:
渲染管线代码(由于原代码过长,笔者进行了适当的缩减,原代码可以在上一章节中查看):
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Experimental.Rendering;
using Conditional = System.Diagnostics.ConditionalAttribute;
[SerializeField]
bool dynamicBatching; // Receive user instruction about whether enabling dynamic batching
[SerializeField]
bool instancing; // Receive user instruction about whether enabling GPU instancing
...
/* Pipeline object field */
public class MyPipeline : RenderPipeline {
DrawRendererFlags drawFlags;
public MyPipeline (bool dynamicBatching, bool instancing) {
if (dynamicBatching) {
drawFlags = DrawRendererFlags.EnableDynamicBatching;
}
if (instancing) {
drawFlags |= DrawRendererFlags.EnableInstancing;
}
}
...
void Render (ScriptableRenderContext context, Camera camera) {
...
/* Drawing */
var drawSettings = new DrawRendererSettings( // Set pipeline to use default unlit shader pass
camera, new ShaderPassName("SRPDefaultUnlit"));
drawSettings.flags = drawFlags;
drawSettings.sorting.flags = SortFlags.CommonOpaque; // Sort opaque object render order
var filterSettings = new FilterRenderersSettings(true) { // Limit pipeline to render opaque object frist
renderQueueRange = RenderQueueRange.opaque}; // Render queue range: 0 ~ 2500
context.DrawRenderers(cull.visibleRenderers, ref drawSettings, filterSettings);
...
}
/* Pipeline asset field */
[CreateAssetMenu(menuName = "Rendering/My Pipeline")] // Add pipeline asset to editor menu
public class MyPipelineAsset : RenderPipelineAsset {
protected override IRenderPipeline InternalCreatePipeline () {
return new MyPipeline(dynamicBatching, instancing); // Instantiate pipeline object
}
}
着色器代码:
// In .shader file
Shader "My Pipeline/Unlit" {
Properties {
_Color ("Color", Color) = (1, 1, 1, 1)
}
SubShader {
Pass {
HLSLPROGRAM
#pragma target 3.5
#pragma multi_compile_instancing
#pragma instancing_options assumeuniformscaling
#pragma vertex UnlitPassVertex
#pragma fragment UnlitPassFragment
#include "../ShaderLibrary/Unlit.hlsl"
ENDHLSL
}
}
}
库代码:
// In .hlsl file
#ifndef MYRP_UNLIT_INCLUDED
#define MYRP_UNLIT_INCLUDED
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
CBUFFER_START(UnityPerFrame)
float4x4 unity_MatrixVP;
CBUFFER_END
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
CBUFFER_END
#define UNITY_MATRIX_M unity_ObjectToWorld
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/UnityInstancing.hlsl"
UNITY_INSTANCING_BUFFER_START(PerInstance)
UNITY_DEFINE_INSTANCED_PROP(float4, _Color)
UNITY_INSTANCING_BUFFER_END(PerInstance)
struct VertexInput {
float4 pos : POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct VertexOutput {
float4 clipPos : SV_POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
VertexOutput UnlitPassVertex (VertexInput input) {
VertexOutput output;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
float4 worldPos = mul(UNITY_MATRIX_M, float4(input.pos.xyz, 1.0));
output.clipPos = mul(unity_MatrixVP, worldPos);
return output;
}
float4 UnlitPassFragment (VertexOutput input) : SV_TARGET {
UNITY_SETUP_INSTANCE_ID(input);
return UNITY_ACCESS_INSTANCED_PROP(PerInstance, _Color);
}
#endif // MYRP_UNLIT_INCLUDED
颜色组件代码:
using UnityEngine;
public class InstancedColor : MonoBehaviour {
[SerializeField]
Color color = Color.white;
static MaterialPropertyBlock propertyBlock;
static int colorID = Shader.PropertyToID("_Color");
void Awake () {
OnValidate();
}
void OnValidate () {
if (propertyBlock == null) {
propertyBlock = new MaterialPropertyBlock();
}
propertyBlock.SetColor(colorID, color);
GetComponent<MeshRenderer>().SetPropertyBlock(propertyBlock);
}
}
Jasper Flick. (2019). Custom Shaders. Retrieved from https://catlikecoding.com/unity/tutorials/scriptable-render-pipeline/custom-shaders/