Banner

「Custom SRP」:表面着色器与 BRDF

系列第 3 篇。承接 Note 4 的光照框架——light.attenuation × DirectBRDF(surface, brdf, light) 中的后半部分正是本篇要展开的”表面接收到光线后如何反射”。本篇关注的是 Shader 内部的世界:HLSL 库的工程化组织、表面属性的物理输入层、PBR BRDF 的完整推导、透明度模式的物理与代码映射、粒子系统的特殊兼容。

TL;DR

  • Shader 库分层:UnityInput → Common → Surface → BRDF → Lighting → ShaderPass。每层只依赖下层、不反向引用,构成无环依赖图。
  • Metallic-Roughness 工作流:基础色 + 金属度 + 粗糙度三参数表达整个 PBR 材质空间,避免传统 Specular 工作流的能量违规风险。
  • BRDF 五大分量:Disney Diffuse + GGX NDF + Smith G + Schlick F + 能量守恒归一化。每个分量都有标准数学形式与 GPU 友好近似。
  • 透明度四模式:Opaque / Cutout / Fade / Transparent,差异在 ZWrite、Blend 方程、是否 alpha test。Premultiplied Alpha 是 Transparent 模式的物理基础——它让透明物体仍能保留独立的镜面反射强度。
  • 粒子隐性约束:Soft Particles 需要场景深度纹理输入,会影响 Render Graph 的 attachment 资源声明——这是 Shader 端需求向管线层倒灌的典型案例。

1. Shader 架构与库分层

1.1 Pass 结构

一个完整的 Lit Shader 至少包含三个 Pass,每个 Pass 由 LightMode ShaderTagId 标识,对应 Note 2 中 RendererList 的过滤目标:

HLSL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Shader "Custom RP/Lit"
{
Properties { ... }

SubShader
{
HLSLINCLUDE
#include "Assets/Custom RP/ShaderLibrary/Common.hlsl"
#include "LitInput.hlsl"
ENDHLSL

Pass
{
Tags { "LightMode" = "CustomLit" }
// ... ZWrite / Blend / Cull 配置
HLSLPROGRAM
#pragma target 3.5
#pragma multi_compile_instancing
#pragma multi_compile _ LIGHTMAP_ON
#pragma multi_compile _ _DIRECTIONAL_PCF3 _DIRECTIONAL_PCF5 _DIRECTIONAL_PCF7
// ... 其他 multi_compile
#pragma vertex LitPassVertex
#pragma fragment LitPassFragment
#include "LitPass.hlsl"
ENDHLSL
}

Pass
{
Tags { "LightMode" = "ShadowCaster" }
ColorMask 0
HLSLPROGRAM
#pragma vertex ShadowCasterPassVertex
#pragma fragment ShadowCasterPassFragment
#include "ShadowCasterPass.hlsl"
ENDHLSL
}

Pass
{
Tags { "LightMode" = "Meta" }
Cull Off
HLSLPROGRAM
#pragma vertex MetaPassVertex
#pragma fragment MetaPassFragment
#include "MetaPass.hlsl"
ENDHLSL
}
}

CustomEditor "CustomShaderGUI"
}

三个 Pass 各司其职:

Pass 何时执行 输出
CustomLit 主几何阶段(GeometryPass) 颜色到 color attachment、深度到 depth attachment
ShadowCaster 阴影 Atlas 写入阶段(ShadowsPass) 仅深度到 Atlas,ColorMask 0 显式禁用颜色写入
Meta GI 烘焙阶段(编辑器、非运行时) Albedo / Emission 给烘焙器读取

HLSLINCLUDE 块中的 include 是所有 Pass 共享的——这避免了在每个 Pass 中重复声明纹理与 CBUFFER。LitInput.hlsl 集中管理材质属性的声明与采样函数,是材质级的”input 抽象层”。

1.2 HLSL 库分层

Shader 库的分层组织决定了维护性与扩展性。Catlike 实现采用六层分级:

flowchart TD
    A[UnityInput.hlsl
引擎 builtin 变量
unity_ObjectToWorld / unity_MatrixVP / ...] --> B B[Common.hlsl
UNITY_MATRIX 宏 / 空间变换 / Dither / DecodeNormal] --> C C[Surface.hlsl
struct Surface · 表面物理属性] --> E C --> D D[Light.hlsl
struct Light · 光源统一抽象
StructuredBuffer 读取] --> E E[BRDF.hlsl
struct BRDF · 反射率分解
Disney Diffuse / GGX / Smith / Schlick] --> F F[Shadows.hlsl
采样 Atlas · PCF · Cascade 选择] --> G G[Lighting.hlsl
GetLighting · BRDF × Light 累加
Forward+ Tile 遍历] --> H H[GI.hlsl
间接光 · Lightmap / SH / Reflection Probe] --> I I[LitPass.hlsl
Vertex/Fragment 入口
整合所有上层] style A fill:#f5f5f5,stroke:#999 style I fill:#e3f2fd,stroke:#1976d2,stroke-width:2px

依赖方向是单向的——下层不引用上层。这条”无环依赖”约束让 Shader 库可以被独立编译验证,也让 include 顺序自然有序:

1
2
3
4
5
6
7
// LitPass.hlsl 的标准 include 顺序
#include "Assets/Custom RP/ShaderLibrary/Surface.hlsl"
#include "Assets/Custom RP/ShaderLibrary/Shadows.hlsl"
#include "Assets/Custom RP/ShaderLibrary/Light.hlsl"
#include "Assets/Custom RP/ShaderLibrary/BRDF.hlsl"
#include "Assets/Custom RP/ShaderLibrary/GI.hlsl"
#include "Assets/Custom RP/ShaderLibrary/Lighting.hlsl"

错乱顺序会触发未定义符号编译错误——这本身是分层的健康检查。

1.3 SRP Batcher 兼容的 CBUFFER 布局

Note 2 §4.1 已经详细介绍了 SRP Batcher 兼容性的硬性要求。Lit Shader 的 LitInput.hlsl 必须遵循:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
TEXTURE2D(_BaseMap);
TEXTURE2D(_MaskMap);
TEXTURE2D(_NormalMap);
TEXTURE2D(_EmissionMap);
TEXTURE2D(_DetailMap);
TEXTURE2D(_DetailNormalMap);
SAMPLER(sampler_BaseMap);

UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
UNITY_DEFINE_INSTANCED_PROP(float4, _DetailMap_ST)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_DEFINE_INSTANCED_PROP(float4, _EmissionColor)
UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
UNITY_DEFINE_INSTANCED_PROP(float, _ZWrite)
UNITY_DEFINE_INSTANCED_PROP(float, _Metallic)
UNITY_DEFINE_INSTANCED_PROP(float, _Occlusion)
UNITY_DEFINE_INSTANCED_PROP(float, _Smoothness)
UNITY_DEFINE_INSTANCED_PROP(float, _Fresnel)
UNITY_DEFINE_INSTANCED_PROP(float, _DetailAlbedo)
UNITY_DEFINE_INSTANCED_PROP(float, _DetailSmoothness)
UNITY_DEFINE_INSTANCED_PROP(float, _DetailNormalScale)
UNITY_DEFINE_INSTANCED_PROP(float, _NormalScale)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

注意 UnityPerMaterial 既是 SRP Batcher 兼容的 CBUFFER 名,也是 GPU Instancing 的 instance buffer 名——两条路径共用同一份属性声明,由 UNITY_INSTANCING_BUFFER_START / UNITY_DEFINE_INSTANCED_PROP 宏在编译时根据是否启用 Instancing 展开为不同形式。这种统一是 Note 2 中”两条快路径互补”的实现基础。

属性访问统一通过 INPUT_PROP 宏(包装 UNITY_ACCESS_INSTANCED_PROP),让上层代码不必关心当前是否处于 Instancing 路径:

1
2
3
4
5
6
7
8
#define INPUT_PROP(name) UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, name)

float4 GetBase(float2 baseUV)
{
float4 map = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, baseUV);
float4 color = INPUT_PROP(_BaseColor);
return map * color;
}

2. 表面属性输入

Surface 结构是 Shader 内部的物理属性中间层——所有外部输入(贴图、Color、Slider)经过 Vertex/Fragment 阶段处理后,最终汇聚为一个 Surface 实例传给 BRDF 计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Surface
{
float3 position;
float3 normal;
float3 interpolatedNormal; // 用于 normalBias,避免 normal map 干扰
float3 viewDirection;
float depth;
float3 color;
float alpha;
float metallic;
float occlusion;
float smoothness;
float fresnelStrength;
float dither;
uint renderingLayerMask;
};

这个 struct 是 BRDF 的统一输入契约。无论原始材质的复杂程度如何,到达 BRDF 时都已经规约到这十几个标量字段——这与 Note 4 中 Light 结构的统一抽象思路一致。

2.1 Albedo / Alpha:基础色与透明度

_BaseMap 是 sRGB 编码的基础色纹理,_BaseColor 是材质 tint。在线性空间中:

1
2
3
4
5
6
float4 GetBase(float2 baseUV, float2 detailUV)
{
float4 map = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, baseUV);
float4 color = INPUT_PROP(_BaseColor);
return map * color;
}

注意采样器 sampler_BaseMap 是隐式定义的(Unity 约定 sampler + 纹理名 = 该纹理的默认采样器),允许多张共享 wrap/filter 设置的贴图复用同一采样器槽位——硬件采样器槽位通常只有 16 个,是稀缺资源。

Alpha 走两条独立路径:

  • 作为透明度参与 blend:直接传到 fragment 输出
  • 作为 alpha test 依据:与 _Cutoff 比较后 clip() 丢弃像素

具体走哪条由透明度模式决定(§4 展开)。

2.2 Metallic-Roughness 工作流

PBR 现代标准是 Metallic-Roughness 工作流:用三个标量(基础色 + 金属度 + 粗糙度)表达完整材质空间,避免传统 Specular 工作流的能量违规风险。

物理模型:

  • Metallic = 0:电介质(塑料、木头、皮肤、水)。漫反射来自基础色,镜面反射用固定的 4% 灰白
  • Metallic = 1:金属(铜、金、铁)。无漫反射,镜面反射的颜色就是基础色
  • 0 < Metallic < 1:物理上不存在,但工程上用于过渡区域(生锈、漆面磨损)

Catlike 实现遵循 Disney 的 F0 公式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct BRDF
{
float3 diffuse;
float3 specular;
float roughness;
float perceptualRoughness;
float fresnel;
};

BRDF GetBRDF(Surface surface, bool applyAlphaToDiffuse = false)
{
BRDF brdf;

float oneMinusReflectivity = OneMinusReflectivity(surface.metallic);
brdf.diffuse = surface.color * oneMinusReflectivity;

if (applyAlphaToDiffuse)
brdf.diffuse *= surface.alpha; // 见 §4 PMA

brdf.specular = lerp(MIN_REFLECTIVITY, surface.color, surface.metallic);

brdf.perceptualRoughness =
PerceptualSmoothnessToPerceptualRoughness(surface.smoothness);
brdf.roughness = PerceptualRoughnessToRoughness(brdf.perceptualRoughness);

brdf.fresnel = saturate(surface.smoothness + 1.0 - oneMinusReflectivity);

return brdf;
}

MIN_REFLECTIVITY 是 0.04 的灰白色 RGB——表示电介质表面的固定反射率。OneMinusReflectivity(metallic) = (1 - 0.04) × (1 - metallic) 给出在金属度从 0→1 时漫反射强度从 0.96→0 的线性过渡。

2.3 Smoothness ↔ Roughness 转换

美术友好的参数是 Smoothness(0 = 完全粗糙、1 = 完美光滑),而 BRDF 公式使用的是 Roughness(α = roughness²)。两者关系:

PerceptualSmoothnessToPerceptualRoughnessPerceptualRoughnessToRoughness 是 RP Core Library 提供的标准转换函数,封装了上述映射。

为什么是平方而不是直接用?因为人眼对粗糙度变化的感知是非线性的——线性 perceptualRoughness 在视觉上是均匀的,但物理 BRDF 要求实际 α 偏向高粗糙度区间分布得更细。平方关系恰好提供这种感知线性化。

2.4 Normal Map 与 TBN

Normal Map 存储切线空间的法线扰动。从切线空间到世界空间的变换需要 TBN 矩阵——切线(T)、副切线(B)、法线(N)三个正交向量组成的 3×3 旋转矩阵。

Vertex 阶段构造 TBN:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct Varyings
{
float4 positionCS_SS : SV_POSITION;
float3 positionWS : VAR_POSITION;
float3 normalWS : VAR_NORMAL;
float4 tangentWS : VAR_TANGENT; // .w 存 sign(决定 B 的朝向)
float2 baseUV : VAR_BASE_UV;
float2 detailUV : VAR_DETAIL_UV;
UNITY_VERTEX_INPUT_INSTANCE_ID
};

Varyings LitPassVertex(Attributes input)
{
Varyings output;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);

output.positionWS = TransformObjectToWorld(input.positionOS);
output.positionCS_SS = TransformWorldToHClip(output.positionWS);
output.normalWS = TransformObjectToWorldNormal(input.normalOS);
output.tangentWS = float4(
TransformObjectToWorldDir(input.tangentOS.xyz),
input.tangentOS.w);
// ... UV 变换
return output;
}

Fragment 阶段解码 normal 并构造完整 TBN:

1
2
3
4
5
6
7
8
9
10
11
12
13
float3 GetNormalTS(float2 uv)
{
float4 map = SAMPLE_TEXTURE2D(_NormalMap, sampler_BaseMap, uv);
float scale = INPUT_PROP(_NormalScale);
return DecodeNormal(map, scale); // RP Core Library 提供,处理 DXT5nm
}

// 在 Fragment 主函数中
float3 normalTS = GetNormalTS(input.baseUV);
float3 binormalWS = cross(input.normalWS, input.tangentWS.xyz) * input.tangentWS.w;
float3x3 tangentToWorld = float3x3(input.tangentWS.xyz, binormalWS, input.normalWS);
surface.normal = normalize(mul(normalTS, tangentToWorld));
surface.interpolatedNormal = normalize(input.normalWS);

几个关键点:

  • tangentWS.w 的 sign 用途:网格的 UV 镜像(如对称模型)需要翻转 B 方向,否则镜像区域的 Normal Map 显示反向。这个 sign 由 Unity 在导入 Mesh 时自动计算
  • interpolatedNormalnormal 的区别:BRDF 计算用 normal(带 normal map 扰动),而 normalBias、shadow sampling 用 interpolatedNormal(顶点法线插值,光滑稳定)
  • DXT5nm 解码:Unity 对 Normal Map 默认使用 DXT5nm 压缩,将 X 存在 Alpha、Y 存在 Green,Z 通过 sqrt(1 - x² - y²) 重建。DecodeNormal 自动处理这层解压

2.5 Mask Map:四通道复合

每个材质如果用四张独立贴图(Metallic / Occlusion / Detail Mask / Smoothness),会消耗 4 个采样槽位 + 4 次采样指令。Mask Map 把这些标量打包到单张贴图的四通道:

通道 内容 取值
R Metallic 0=电介质,1=金属
G Occlusion 0=被遮挡,1=完全暴露
B Detail Mask 0=不应用细节,1=完全应用
A Smoothness 0=粗糙,1=光滑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
float4 GetMask(float2 baseUV)
{
return SAMPLE_TEXTURE2D(_MaskMap, sampler_BaseMap, baseUV);
}

float GetMetallic(float2 baseUV)
{
float metallic = INPUT_PROP(_Metallic);
metallic *= GetMask(baseUV).r;
return metallic;
}

float GetSmoothness(float2 baseUV, float2 detailUV)
{
float smoothness = INPUT_PROP(_Smoothness);
smoothness *= GetMask(baseUV).a;
// ... detail map 调制
return smoothness;
}

代价是美术工作流复杂化——必须用专门工具(Substance Painter / Photoshop)打包通道。但在批量项目中节省的采样开销(4×→1×)通常远大于工作流成本。

2.6 Detail Map:高频细节叠加

Detail Map 是高频次纹理(孔洞、划痕、织物纹路),叠加在基础贴图之上以提升近距离观察的精细度。Catlike 实现使用独立的 UV 通道(_DetailMap_ST)允许 detail 平铺频率与基础贴图不同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float3 GetBase(float2 baseUV, float2 detailUV)
{
float4 map = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, baseUV);
float4 detail = SAMPLE_TEXTURE2D(_DetailMap, sampler_DetailMap, detailUV);
detail = detail * 2.0 - 1.0; // 中心 0.5 → 0
detail *= INPUT_PROP(_DetailAlbedo);

// 重要:用 detail mask 控制强度
float mask = GetMask(baseUV).b;
detail *= mask;

// 叠加到 base
return SoftLight(map.rgb, detail.rgb); // PhotoShop 风格的 SoftLight 混合
}

Detail Map 的 RGB 中心化(* 2 - 1)让贴图 0.5 灰对应”无修改”——美术可以用灰色填充表示”此处无细节”,避免了显式 alpha mask 的额外通道开销。

2.7 寄存器压力与半精度(half / real)规范

到目前为止,本文示例代码统一使用 float——这在桌面端足够了,但在移动端 TBR 架构下,全 float 的 PBR Shader 几乎一定会撞到性能墙。Surface 与 BRDF 结构体中的多数字段(颜色、法线方向、粗糙度、Fresnel 强度)都在 之间,单精度浮点的 23 位尾数对它们而言是巨大浪费。

半精度 FP16 的硬件层意义

  • 寄存器占用减半:一个 half4 占用 1 个 32-bit GPR,而 float4 占用 2 个。Shader 的总寄存器使用量直接决定 GPU 能并行调度多少个 wavefront/warp——这就是 Occupancy(占据率)
  • Occupancy 决定延迟隐藏能力:当某个 warp 等待纹理采样(典型 100-300 cycle 延迟)时,GPU 切换到另一个 warp 继续执行。寄存器越省、能切换的 warp 越多、纹理延迟被掩盖得越彻底
  • ALU 吞吐翻倍:移动端 GPU(Mali、Adreno、Apple Silicon)对 FP16 提供原生 2× 吞吐——half 的乘加速度是 float 的两倍,配合 vec2_packed_half 一类的紧凑指令收益更显著

Unity 的 RP Core Library 提供 real 宏作为可移植的半精度别名:

1
2
3
4
5
6
7
8
9
10
11
12
// 在 Common.hlsl 中
#if defined(SHADER_API_MOBILE) || defined(SHADER_API_SWITCH)
#define real half
#define real2 half2
#define real3 half3
#define real4 half4
#else
#define real float
#define real2 float2
#define real3 float3
#define real4 float4
#endif

桌面端 real == float(兼容性优先),移动端 real == half(性能优先)。

精度规范的工程清单

数据类型 推荐精度 理由
世界位置(positionWS) float3 大场景下 half 精度不足,会产生空间抖动
屏幕坐标 / SV_POSITION float4 同上
深度(用于阴影/比较) float 精度敏感
方向向量(normal / view / light) half3 单位向量,[-1, 1] 范围
颜色(base color / specular / 输出) half4 sRGB 与 HDR linear 都在 half 范围
粗糙度 / Smoothness / Metallic half 标量
Fresnel / Attenuation half 同上
BRDF 中间结果 half 大部分在
矩阵(unity_ObjectToWorld 等) float4x4 引擎传入的标准精度

💡 半精度采用是性能”免费午餐”中最显著的一项:把一个全 float 的 PBR Lit Shader 改写为 half/real 混合精度,移动端帧率提升 15-30% 是常见结果,且视觉差异在大多数场景下肉眼难辨。这是 PBR 材质系统在移动端的生命线——一个不用 half 的移动端 Shader 库,无论算法多漂亮都谈不上 production-ready

实践原则:

  • 新写的 Shader 一律用 real 替代 float,仅在位置、矩阵、深度等精度敏感字段保留 float
  • Catlike 教程中的 float 示例是教学清晰度优先——production 项目应该全面替换为 real
  • 每次修改 Shader 后用 RenderDoc 或 Mali Offline Compiler 检查 GPR 使用量;GPR 数下降即收益

3. 物理 BRDF 推导

BRDF(Bidirectional Reflectance Distribution Function,双向反射分布函数)描述光在表面的反射行为。Cook-Torrance 框架把它拆解为漫反射与镜面反射两部分:

其中 是光方向、 是视方向、 是半程向量、 是表面法线。

镜面项的三个分量分别建模微表面的不同物理特性:

  • D(Normal Distribution Function):微表面法线分布,决定镜面反射形状
  • G(Geometric Shadowing):微表面相互遮挡概率
  • F(Fresnel):能量按入射角的反射比例

3.1 Disney Diffuse:漫反射模型

最简单的漫反射是 Lambert 是漫反射率)。但 Lambert 在掠射角下与真实测量数据不匹配——粗糙表面在边缘略微变亮,光滑表面则更暗。

Disney Diffuse(Burley 2012)引入粗糙度修正:

实现上 Catlike 用了一个简化版本(在大多数实时场景下视觉差异不可察觉):

1
2
3
4
float3 DirectBRDF(Surface surface, BRDF brdf, Light light)
{
return SpecularStrength(surface, brdf, light) * brdf.specular + brdf.diffuse;
}

漫反射直接返回 brdf.diffuse(已经是 的等效形式)。这是工程取舍——Disney Diffuse 的修正项在多光源累加时收益微弱,但每光源每像素多 4 次乘法。

3.2 GGX:Normal Distribution Function

GGX(Trowbridge-Reitz)是当代实时渲染的事实标准 NDF:

其中 。GGX 相对早期的 Beckmann 分布,长尾特性更明显——高光中心收敛但边缘有微弱拖尾,更接近真实测量。

实现:

1
2
3
4
5
6
7
8
9
10
11
12
float SpecularStrength(Surface surface, BRDF brdf, Light light)
{
float3 h = SafeNormalize(light.direction + surface.viewDirection);
float nh2 = Square(saturate(dot(surface.normal, h)));
float lh2 = Square(saturate(dot(light.direction, h)));

float r2 = Square(brdf.roughness);
float d2 = Square(nh2 * (r2 - 1.0) + 1.00001);
float normalization = brdf.roughness * 4.0 + 2.0;

return r2 / (d2 * max(0.1, lh2) * normalization);
}

这个函数实际计算的是 的简化形式(V 是 G 的一种表述),融合了 D、G、能量归一化三项的简化结果。其推导细节涉及多个数值优化:

  • 0.00001 防止 NDF 在镜面方向的奇点
  • max(0.1, lh2) 限制掠射角下的能量发散
  • roughness * 4 + 2 是经验拟合的 Visibility 项归一化

这种”融合简化”是性能与正确性的工程平衡——直接照搬学术公式(D / G / F 各算一遍再除以分母)的成本是这个版本的 3-4 倍。

3.3 Schlick Fresnel

Fresnel 项描述”光线以掠射角入射时反射率增强”的现象——这是为什么湖面在远处反射强、近处偏弱。Schlick 近似用一个简单的 5 次幂取代精确 Fresnel 公式:

是垂直入射(0°)时的反射率,由 §2.2 的 brdf.specular 提供。Catlike 实现把 Fresnel 单独提出作为环境反射的强度调制(间接光部分,Note 6 展开):

1
2
3
4
5
6
7
8
float3 IndirectBRDF(Surface surface, BRDF brdf, float3 diffuse, float3 specular)
{
float fresnelStrength = surface.fresnelStrength *
Pow4(1.0 - saturate(dot(surface.normal, surface.viewDirection)));
float3 reflection = specular * lerp(brdf.specular, brdf.fresnel, fresnelStrength);
reflection /= brdf.roughness * brdf.roughness + 1.0;
return (diffuse * brdf.diffuse + reflection) * surface.occlusion;
}

Pow4 而不是标准的 Pow5——这是另一个工程近似。Pow4(x) = (x²)² 只用 2 次乘法,而 Pow5 需要先 Pow4 再乘一次。视觉差异在大多数场景中难以察觉。

最后的 reflection /= brdf.roughness² + 1 是粗糙表面环境反射的衰减——粗糙度高时镜面反射应该减弱,避免过亮。

3.4 能量守恒与 GGX 多次散射的物理盲区

完整 BRDF 的能量守恒由两个机制保证:

  1. 金属度互斥:金属时 brdf.diffuse → 0,避免漫反射 + 镜面反射超过 1
  2. F0 嵌入 specular:Schlick Fresnel 的 直接是 brdf.specular,确保镜面反射强度合理

💡 GGX 的单次散射假设与高粗糙度暗化:标准 GGX NDF 是一个单次散射(Single-Scattering)模型——每条光线被微表面反射一次后就直接离开。但真实粗糙表面的微面元之间会相互遮挡与多次反弹,这部分能量在 GGX 模型下被简单丢弃了。当 时,丢失的能量比例可达 20%-40%,导致高粗糙度材质(粗糙金属、磨砂塑料、暗哑陶瓷)在暗部看起来过于死黑、缺少应有的环境响应——这是 PBR 视觉调校中”明明参数都对、感觉就是不对”的常见根源。HDRP 通过预积分查找表(如 Kulla-Conty 补偿、FSD 多次散射 LUT)补偿这部分能量;UE5 的 Lumen 也在 indirect specular 路径上做了类似处理。Custom SRP 出于性能考虑不实现多次散射补偿,但在构建材质系统时应该知晓这一物理近似的视觉极限——遇到高粗糙度暗化问题时,把”美术再调一调”改为”物理模型本身就是欠缺的”是更准确的归因。

3.5 BRDF 总成本

整个 DirectBRDF 在每光源每像素的执行成本:

1
2
3
4
5
6
7
8
SafeNormalize(h)              ← 1 normalize
dot × 2 ← 2 dot
saturate × 2 ← 2 saturate
Square × 3 ← 3 mul
GGX 主体 ← ~5 mul + 1 div
Diffuse + Specular ← 1 mul + 1 add
─────────────────────────────
约 15-20 ALU 指令 + 2-3 个 RT 采样(Surface 阶段已完成)

在 Note 4 的 Forward+ 内循环中,每个光源都执行这套计算——这是为什么”减少每 Tile 命中光源数”对性能至关重要。


4. 透明度模式

四种透明度模式覆盖了几乎所有视觉需求。差异体现在 ZWrite、Blend 方程、是否 alpha test、brdf.diffuse 是否预乘 alpha 四个维度的组合。

4.1 模式对比

模式 RenderQueue ZWrite Src Blend Dst Blend clip() PMA
Opaque Geometry (2000) On One Zero
Cutout AlphaTest (2450) On One Zero ✓(与 _Cutoff 比较)
Fade Transparent (3000) Off SrcAlpha OneMinusSrcAlpha
Transparent Transparent (3000) Off One OneMinusSrcAlpha

各模式的物理语义:

  • Opaque — 完全不透明。最大化性能(早 Z 剔除生效)
  • Cutout — 二值透明(草、镂空叶片、围栏)。clip() 提前丢弃像素,深度仍写入(保持遮挡正确性)
  • Fade — 整体淡入淡出(角色出场、UI 渐显)。所有反射都按 alpha 衰减,alpha=0 时完全看不见
  • Transparent — 有色透明物体(玻璃、水、彩色塑料)。漫反射按 alpha 衰减,镜面反射不衰减——这是物理正确的玻璃外观

4.2 Premultiplied Alpha 的物理意义

Transparent 模式的核心是 Premultiplied Alpha(PMA):fragment 输出时把 RGB 预先乘 alpha,让 blend 公式简化为:

对应 Blend One OneMinusSrcAlpha

物理动机:玻璃既透明(让背景透过)又有镜面反射(高光仍然鲜明)。如果用 Fade 模式,alpha=0.1 时镜面高光也会被弱化为 10% 强度,玻璃看起来像雾——失去了”透明但仍有反射”的特征。

PMA 让 RGB 与 alpha 解耦:

  • alpha 控制”穿透多少背景”
  • 镜面反射部分不预乘 alpha,仍以全强度输出

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float4 LitPassFragment(Varyings input) : SV_TARGET
{
Surface surface = GetSurface(input);

#if defined(_CLIPPING)
clip(surface.alpha - INPUT_PROP(_Cutoff));
#endif

BRDF brdf = GetBRDF(surface, _PREMULTIPLY_ALPHA);

float3 color = GetLighting(input.positionCS_SS.xy, surface, brdf, gi);

return float4(color, surface.alpha);
}

_PREMULTIPLY_ALPHA 关键字打开时,GetBRDF(surface, true)brdf.diffuse *= surface.alpha。镜面反射部分(brdf.specular)保留全强度。最终 fragment 的 RGB 是预乘后的漫反射 + 全强度镜面反射,alpha 是表面透明度——blend 公式正确合成。

4.3 Cutout:alpha test 与早期丢弃

Cutout 模式用 clip() 实现二值透明:

1
2
3
#if defined(_CLIPPING)
clip(GetFinalAlpha(surface.alpha) - INPUT_PROP(_Cutoff));
#endif

clip(x)x < 0 时丢弃像素。被丢弃的像素:

  • 不写入 color attachment
  • 不写入 depth attachment(这点很关键,决定了 Cutout 物体的深度遮挡正确性)

Cutout 模式仍然 ZWrite On + Blend Off,与 Opaque 几乎一样——只是多了 alpha test。这让 Cutout 物体能参与 Early-Z 剔除(在 fragment 着色前判定深度遮挡),但 clip() 本身会禁用 Early-Z 优化——硬件无法预知哪些像素会被丢弃,必须先执行完整 fragment shader 才能确定。

实践推论:Cutout 物体的 fragment shader 成本接近全屏 Opaque,远高于 Opaque 的”只在可见区域执行”。大量草、树叶等 Cutout 物体在移动端是性能杀手——通常需要把 Cutout 草的 LOD 远端切换为简单 billboard + alpha blend,避免大屏幕区域的 alpha test 开销。

💡 现代解法:Alpha To Coverage (A2C):在开启 MSAA 的项目中,更现代的 Cutout 实现是摒弃 clip()、转而使用 Alpha To Coverage。A2C 把 fragment 的 alpha 值转化为该像素多重采样点的覆盖率掩码——alpha = 0.5 时让一半的 sub-sample 通过、一半被丢弃。这带来三重收益:(1) 不再需要 clip() 指令,Early-Z 保留部分优势(虽然驱动对 A2C 的早期深度处理实现各异);(2) 边缘从二值硬切变成多采样平滑过渡,自然消除 Cutout 边缘锯齿,等价于免费的 alpha blend 视觉品质;(3) 仍然 ZWrite On,没有 transparent 排序问题,不破坏深度遮挡。代价是必须开启 MSAA(移动端 4×MSAA 在 TBR 上已是低成本特性)。AlphaToMask On 一行 Shader 配置即可启用,配合 clip(alpha - 0.5) 作为 fallback。这是当代 3A 项目处理海量树叶、草甸、镂空织物的事实标准——只要 MSAA 在管线中可用就应该首选。Custom SRP 默认不开 MSAA,但项目集成 A2C 是值得的下一步演进。

4.4 Shader Pass 的渲染状态切换

四种模式通过 _SrcBlend / _DstBlend / _ZWrite 等材质属性 + [Enum(UnityEngine.Rendering.BlendMode)] 让 ShaderGUI 切换:

1
2
3
4
5
6
7
8
9
10
11
12
13
Pass
{
Tags { "LightMode" = "CustomLit" }

Blend [_SrcBlend] [_DstBlend], One [_DstBlendAlpha]
ZWrite [_ZWrite]

HLSLPROGRAM
#pragma shader_feature _CLIPPING
#pragma shader_feature _PREMULTIPLY_ALPHA
// ...
ENDHLSL
}

Blend 第二组参数(alpha 通道)单独控制:透明物体的最终 alpha 通常用 One 而非 OneMinusSrcAlpha,避免多层透明物体叠加后 alpha 错误衰减。

shader_featuremulti_compile 的差异在 Note 8 工程架构中展开——简言之 shader_feature 只编译实际被材质使用的 variant,multi_compile 编译所有 variant。透明度关键字适合用 shader_feature


5. 粒子系统兼容

粒子系统对 Shader 提出了额外需求:每粒子色彩调制、双帧动画、近场淡化。这些需求不是必须的(Lit Shader 不开启时仍能给粒子用),但开启后能显著提升视觉质量。

5.1 Vertex Color:每粒子调制

粒子系统会把每粒子的颜色(来自 Color over Lifetime 模块)写入网格的顶点色通道。Shader 端只需在 Vertex 阶段把它传到 Fragment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct Attributes
{
float3 positionOS : POSITION;
float3 normalOS : NORMAL;
float4 tangentOS : TANGENT;
float2 baseUV : TEXCOORD0;
#if defined(_VERTEX_COLORS)
float4 color : COLOR;
#endif
UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
// ... 标准字段
#if defined(_VERTEX_COLORS)
float4 color : VAR_COLOR;
#endif
};

// Fragment 中
Surface surface;
surface.color = base.rgb;
surface.alpha = base.a;
#if defined(_VERTEX_COLORS)
surface.color *= input.color.rgb;
surface.alpha *= input.color.a;
#endif

_VERTEX_COLORS 关键字让此功能可选,对非粒子用法没有性能开销。

5.2 Flipbook:双帧动画

粒子表情贴图(爆炸序列、烟雾序列)通常是网格状的 sprite sheet。Animation 模块输出”当前帧索引 + 下一帧索引 + 混合系数”到 UV2.x/y/z 通道:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Attributes
{
// ... 标准字段
#if defined(_FLIPBOOK_BLENDING)
float4 baseUV : TEXCOORD0; // .xy = frame N UV, .zw = frame N+1 UV
float flipbookBlend : TEXCOORD1;
#endif
};

// Fragment 中
float4 GetBase(Varyings input)
{
float4 a = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV.xy);
#if defined(_FLIPBOOK_BLENDING)
float4 b = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.baseUV.zw);
a = lerp(a, b, input.flipbookBlend);
#endif
return a * INPUT_PROP(_BaseColor);
}

代价是双倍采样,但消除了帧切换的视觉跳变——4 fps 烟雾序列开启 Flipbook 后视觉流畅度等同 24 fps。

5.3 Soft Particles:近场深度淡化

粒子贴图与场景几何相交时会出现明显的硬边(贴图边缘与几何相交线)。Soft Particles 通过比较粒子片元深度与场景深度,在深度差小时降低 alpha:

1
2
3
4
5
6
7
8
9
10
#if defined(_SOFT_PARTICLES)
float backgroundDepth = LinearEyeDepth(
SAMPLE_DEPTH_TEXTURE_X(_CameraDepthTexture, sampler_point_clamp,
input.positionCS_SS.xy * _CameraDepthTexture_TexelSize.xy),
_ZBufferParams);
float particleDepth = LinearEyeDepth(input.positionCS_SS.z, _ZBufferParams);
float fade = saturate(_SoftParticlesDistance *
((backgroundDepth - particleDepth) - _SoftParticlesRange));
surface.alpha *= fade;
#endif

⚠️ Soft Particles 的隐性管线约束:这个特性需要 Shader 采样 _CameraDepthTexture——也就是说,Render Graph 必须在透明几何 Pass 之前生成深度副本。这是 Shader 需求向管线层倒灌的典型案例。在 Custom SRP 中,这通过 CopyAttachmentsPass 实现:在不透明几何完成后、透明几何开始前,把 depth attachment 复制到一张独立的 _CameraDepthTexture 资源,让透明 Pass 既能采样深度(来自副本)又能继续写入深度(写入原 attachment)。永远不能让 Pass 同时把 attachment 当 RT 又当 SRV 采样——这会触发 Render Graph 的资源竞争错误,且在 TBR GPU 上强制 attachment store 到主存,破坏 Note 1 §7 描述的合并优化。

5.4 Distortion 与 Color Modulation

进阶粒子特性还包括:

  • Distortion:粒子贴图作为屏幕空间扭曲的法线场,扰动背景(适合热气、爆炸冲击波)
  • Color Modulation:粒子的 alpha 通道分别用作 Albedo 与 Emission 的强度因子

这些特性同样通过 multi_compile 关键字独立启用。Catlike 实现把它们集中在一个 Unlit Particle Shader(无 BRDF,只走 Color × Texture 路径),与 Lit Shader 共享 LitInput 但不进入完整 PBR 流程——粒子通常不需要 PBR,扁平颜色配合烟雾/火焰类美术效果更合适。


6. Shader GUI 扩展

CustomShaderGUI 类继承 ShaderGUI,提供透明度模式预设按钮、CBUFFER 检查、属性折叠等编辑器增强:

CSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class CustomShaderGUI : ShaderGUI
{
MaterialEditor editor;
Object[] materials;
MaterialProperty[] properties;

public override void OnGUI(MaterialEditor materialEditor, MaterialProperty[] props)
{
EditorGUI.BeginChangeCheck();
base.OnGUI(materialEditor, props);

editor = materialEditor;
materials = materialEditor.targets;
properties = props;

// 透明度模式预设按钮
EditorGUILayout.Space();
if (GUILayout.Button("Opaque")) SetOpaque();
if (GUILayout.Button("Cutout")) SetCutout();
if (GUILayout.Button("Fade")) SetFade();
if (GUILayout.Button("Transparent")) SetTransparent();

if (EditorGUI.EndChangeCheck())
SetShadowCasterPass();
}

void SetTransparent()
{
SetProperty("_Clipping", "_CLIPPING", false);
SetProperty("_PremulAlpha", "_PREMULTIPLY_ALPHA", true);
SetProperty("_SrcBlend", (float)BlendMode.One);
SetProperty("_DstBlend", (float)BlendMode.OneMinusSrcAlpha);
SetProperty("_ZWrite", 0f);
RenderQueue = RenderQueue.Transparent;
}
// ... 其他 SetXxx
}

预设按钮的核心价值是避免错配——透明度的 4 个独立属性(Clipping、PremultiplyAlpha、SrcBlend、DstBlend、ZWrite、RenderQueue)必须协调一致。手动设置容易漏掉一项导致渲染异常。

ShadowCaster Pass 也需要根据当前透明度模式调整:

1
2
3
4
5
6
7
8
9
void SetShadowCasterPass()
{
MaterialProperty shadows = FindProperty("_Shadows", properties, false);
if (shadows == null || shadows.hasMixedValue) return;

bool enabled = shadows.floatValue < (float)ShadowMode.Off;
foreach (Material m in materials)
m.SetShaderPassEnabled("ShadowCaster", enabled);
}

SetShaderPassEnabled 是 Material 级别的 Pass 启用开关——比 multi_compile 更轻量(不产生 variant,仅运行时跳过),适合”完全不投阴影”这种全局选择。


7. TA Takeaway

7.1 BRDF 复杂度与性能预算

完整 PBR Lit Shader 的 fragment 成本可以分解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Surface Inputs (~15 ALU + 5-7 sampler):
├─ Albedo: 1 sample
├─ Mask Map: 1 sample
├─ Normal Map: 1 sample
├─ Detail Map: 1 sample
├─ Detail Normal: 1 sample
└─ Metallic/Smoothness/Occlusion 计算

BRDF Setup (~10 ALU):
├─ OneMinusReflectivity → diffuse
├─ specular = lerp(0.04, color, metallic)
└─ roughness = (1-smoothness)²

Per-Light Direct Lighting (~20 ALU):
├─ light.direction × surface.normal → NdotL
├─ SafeNormalize(l + v) → halfVector
├─ GGX D/V 融合
└─ diffuse + specular

Indirect Lighting (~30 ALU):
├─ SH 采样(Note 6)
├─ Reflection Probe 采样
└─ Fresnel + Roughness 调制

Shadows (~15-30 ALU + 4-16 sampler):
└─ PCF(Note 5)

每像素约 80-150 ALU 指令 + 7-13 次纹理采样——这是现代 PBR Shader 的典型成本。在 1080p 60fps 移动端,每帧 GPU 预算约 8.3ms,能支持 ~100M ALU/帧,意味着 Overdraw < 2 时 PBR 是可行的;Overdraw 飙升时(透明物体堆叠、特效)成本指数级失控。

7.2 关键字管理是材质 Shader 的命脉

Lit Shader 的 multi_compile 列表典型包括:

1
2
3
4
5
6
7
8
9
10
11
#pragma multi_compile_instancing
#pragma multi_compile _ LIGHTMAP_ON
#pragma multi_compile _ _SHADOW_FILTER_MEDIUM _SHADOW_FILTER_HIGH
#pragma multi_compile _ _CASCADE_BLEND_SOFT _CASCADE_BLEND_DITHER
#pragma multi_compile _ _SHADOW_MASK_ALWAYS _SHADOW_MASK_DISTANCE
#pragma shader_feature _CLIPPING
#pragma shader_feature _PREMULTIPLY_ALPHA
#pragma shader_feature _NORMAL_MAP
#pragma shader_feature _MASK_MAP
#pragma shader_feature _DETAIL_MAP
#pragma shader_feature _EMISSION

排列组合数 = 2 × 2 × 3 × 3 × 3 × 2 × 2 × 2 × 2 × 2 × 2 = 6912 variant——单 Shader 编译生成数万个版本。shader_featuremulti_compile 的精确划分能将运行时实际加载的 variant 减少到几十个,但这需要在 Shader 设计时就规划好。Note 8 会展开通用策略。

7.3 物理参数的”美术友好”封装

Smoothness vs Roughness、F0 vs Reflectance 等物理参数与美术参数之间的转换层是 Shader 库的核心价值之一。给美术暴露的应该永远是感知线性、视觉直观的参数:

物理参数(内部使用) 美术参数(暴露) 转换
Roughness α Smoothness α = (1 - smoothness)²
F0 Metallic F0 = lerp(0.04, color, metallic)
(1 - F0)(1 - metallic) (隐式 diffuse 强度) OneMinusReflectivity

直接暴露物理参数(α、F0)会让美术陷入数值地狱——0.04 听起来像”很少反射”但物理上是大多数电介质的 baseline。通过封装,美术只需要思考”这是金属吗?”和”这是光滑的吗?”两个直观问题。

7.4 Shader 是管线的需求源头

Soft Particles 需要深度纹理 → Render Graph 必须有 CopyAttachmentsPass。Reflection Probe → 需要 PerObjectData.ReflectionProbes 在 RendererListDesc 声明。Lightmap → 需要 PerObjectData.Lightmaps + LIGHTMAP_ON multi_compile。每个 Shader 特性都有对应的管线层支撑——Shader 不是孤立编写的,它在不停地”要求”管线提供资源

设计自定义 Shader 特性时,永远要追问:

  • 这个特性需要哪些 builtin 变量?是否需要新的 PerObjectData flag?
  • 这个特性需要采样哪些贴图?这些贴图的来源 Pass 是什么?
  • 这个特性是否引入新的 multi_compile?variant 爆炸是否可控?

不回答这三个问题就动手写 Shader 的人,最终会得到能编译但跑出错误结果的 Shader——因为引擎没有为它准备数据。

7.5 实践原则

  • 库分层不可妥协:Surface/Light/BRDF/Lighting 各司其职,禁止跨层访问
  • CBUFFER 兼容性是 daily check:每次修改属性后查看 Inspector 的 SRP Batcher 状态
  • Mask Map 是中量级项目的默认形态:4 通道打包 vs 4 张独立贴图,采样开销 4×→1×
  • Cutout 慎用:Early-Z 失效;近距离用 Cutout,远距离切换为 LOD billboard
  • Transparent 用 PMA 而非 Fade:除非真的需要”整体淡入淡出”语义
  • 粒子用专用 Shader:避免完整 PBR 路径在大量 Overdraw 下的失控
  • 关键字预算管理:单 Shader variant 数 ≤ 64 是健康范围,> 256 需要 Shader Variant Collection 介入预编译

关键 API 速查

HLSL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// HLSL 库 include 顺序
#include "Assets/Custom RP/ShaderLibrary/Common.hlsl"
#include "Assets/Custom RP/ShaderLibrary/Surface.hlsl"
#include "Assets/Custom RP/ShaderLibrary/Shadows.hlsl"
#include "Assets/Custom RP/ShaderLibrary/Light.hlsl"
#include "Assets/Custom RP/ShaderLibrary/BRDF.hlsl"
#include "Assets/Custom RP/ShaderLibrary/GI.hlsl"
#include "Assets/Custom RP/ShaderLibrary/Lighting.hlsl"

// SRP Batcher 兼容的 CBUFFER + Instancing 兼容
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
// ... 全部材质属性
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
#define INPUT_PROP(name) UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, name)

// Surface struct
struct Surface {
float3 position;
float3 normal; // 含 normal map 扰动
float3 interpolatedNormal; // 顶点法线,用于 normalBias
float3 viewDirection;
float depth;
float3 color;
float alpha;
float metallic;
float occlusion;
float smoothness;
float fresnelStrength;
float dither;
uint renderingLayerMask;
};

// BRDF struct
struct BRDF {
float3 diffuse;
float3 specular;
float roughness;
float perceptualRoughness;
float fresnel;
};

// 标准 BRDF 调用链
BRDF brdf = GetBRDF(surface, _PREMULTIPLY_ALPHA);
float3 directColor = SpecularStrength(surface, brdf, light) * brdf.specular
+ brdf.diffuse;

// 透明度关键字
#pragma shader_feature _CLIPPING
#pragma shader_feature _PREMULTIPLY_ALPHA

// 粒子关键字
#pragma shader_feature _VERTEX_COLORS
#pragma shader_feature _FLIPBOOK_BLENDING
#pragma shader_feature _SOFT_PARTICLES

// Soft Particles 深度采样
SAMPLE_DEPTH_TEXTURE_X(_CameraDepthTexture, sampler_point_clamp, screenUV);
LinearEyeDepth(rawDepth, _ZBufferParams);
1
2
3
4
5
6
7
8
9
10
11
12
// C# 端 ShaderGUI
public class CustomShaderGUI : ShaderGUI
{
public override void OnGUI(MaterialEditor editor, MaterialProperty[] props);
}

// Material Pass 启用开关
material.SetShaderPassEnabled("ShadowCaster", false);

// 透明度模式 BlendMode 枚举
UnityEngine.Rendering.BlendMode.{One, OneMinusSrcAlpha, SrcAlpha, Zero, ...};
UnityEngine.Rendering.RenderQueue.{Geometry, AlphaTest, Transparent};