系列第 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 展开)。
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²)。两者关系:
PerceptualSmoothnessToPerceptualRoughness 与 PerceptualRoughnessToRoughness 是 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 时自动计算
interpolatedNormal 与 normal 的区别 :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 的能量守恒由两个机制保证:
金属度互斥 :金属时 brdf.diffuse → 0,避免漫反射 + 镜面反射超过 1
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_feature 与 multi_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; } }
预设按钮的核心价值是避免错配 ——透明度的 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_feature 与 multi_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 public class CustomShaderGUI : ShaderGUI { public override void OnGUI (MaterialEditor editor, MaterialProperty[] props ) ; } material.SetShaderPassEnabled("ShadowCaster" , false ); UnityEngine.Rendering.BlendMode.{One, OneMinusSrcAlpha, SrcAlpha, Zero, ...}; UnityEngine.Rendering.RenderQueue.{Geometry, AlphaTest, Transparent};