核心结论:Disney 的数学要在每帧 16ms 内跑完 100 万像素,靠的不是更快的 GPU,而是 Karis 在 UE4 引入的 Split Sum 近似——把 IBL 卷积积分拆成「预滤波 cubemap × BRDF LUT」两次纹理采样。本篇详解这套工程化方案,对照 Filament 标准模型和 Unity URP 源码,最后给出移动端的 ALU 与带宽妥协指南。
一、从理论到实时的鸿沟
第二篇我们用一段 ~80 行的 HLSL 实现了完整 Disney BRDF。但渲染方程的左半边——
——里那个半球积分才是真正的瓶颈。对每个像素做 1024 次蒙特卡洛采样,移动端直接卡死,主机端也只能用于影视级离线渲染。
实时 PBR 必须解决两个问题:
- 直接光照(Direct Lighting):点光、方向光、聚光——已知光源方向,BRDF 直接求值,不需要积分;
- 间接光照(IBL / Image-Based Lighting):环境光来自全方位的环境贴图,必须做积分——这才是真正的难题。
整个第三篇围绕第二个问题展开。
二、Karis Split Sum 近似
Brian Karis 在 SIGGRAPH 2013 “Real Shading in Unreal Engine 4” 中提出了 PBR 实时化的”奠基性技巧”。
2.1 问题陈述
对于环境光下的 specular 项,需要计算:
其中 是按 GGX 重要性采样(Importance Sampling)的方向。即使 也意味着每像素 16 次 cubemap 采样,移动端无法承受。
2.2 Split Sum 的”分治”
Karis 的核心 insight:把 BRDF 与光照分离。
虽然这种”分治”在数学上不严格成立(积分的乘积 ≠ 乘积的积分),但在实践中误差肉眼几乎不可见,且收益巨大:
- 第一项:仅依赖 cubemap 与粗糙度,可预烘焙为 mipmap 化的环境贴图(roughness 越大用越高 mip);
- 第二项:仅依赖 ,与具体环境无关,可预烘焙为 的 2D LUT。
运行时只需 1 次 cubemap 采样 + 1 次 LUT 采样,就能逼近积分结果。
2.3 第一项:Pre-filtered Environment Map
对 cubemap 做基于 GGX 的预滤波卷积:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| // 预烘焙:对每个 mip 级别用对应粗糙度卷积 float4 PrefilterEnvMap(float roughness, float3 R) { float3 N = R; float3 V = R; float3 prefilteredColor = 0; float totalWeight = 0; const int NumSamples = 1024; for (int i = 0; i < NumSamples; i++) { float2 Xi = Hammersley(i, NumSamples); float3 H = ImportanceSampleGGX(Xi, roughness, N); float3 L = 2 * dot(V, H) * H - V; float NoL = saturate(dot(N, L)); if (NoL > 0) { prefilteredColor += EnvMap.SampleLevel(EnvMapSampler, L, 0).rgb * NoL; totalWeight += NoL; } } return float4(prefilteredColor / totalWeight, 1.0); }
|
烘焙后,运行时仅需:
1 2 3 4 5
| half mip = perceptualRoughness * (MAX_REFLECTION_LOD); half3 reflectVector = reflect(-V, N); half4 encodedIrradiance = SAMPLE_TEXTURECUBE_LOD(_GlossyEnvironmentCubeMap, sampler, reflectVector, mip); half3 indirectSpec = DecodeHDREnvironment(encodedIrradiance, _GlossyEnvironmentCubeMap_HDR);
|
2.4 第二项:BRDF LUT(也称 FGD LUT)
第二项完全与场景无关,只随 BRDF 模型与参数变化。具体推导:
注意将 Schlick 菲涅尔 代入并线性化,可得到两个独立的积分:
最终 specular 还原为:
LUT 通常按 (sqrt(NoV), perceptualRoughness) 编码:
| 通道 |
含义 |
| R (Scale) |
(菲涅尔被消去) |
| G (Bias) |
|
| B(可选) |
Disney Diffuse 修正项 |
1 2 3 4 5 6 7 8
| half3 EnvironmentBRDF(BRDFData brdfData, half3 indirectDiffuse, half3 indirectSpecular, half NoV) { half2 fab = SAMPLE_TEXTURE2D_LOD(_BRDFLut, sampler_BRDFLut, float2(NoV, brdfData.perceptualRoughness), 0).rg; half3 c = brdfData.diffuse * indirectDiffuse; c += indirectSpecular * (brdfData.specular * fab.x + fab.y); return c; }
|
2.5 预积分 FGD:通道编码扩展
Filament / HDRP 在 LUT 上做了进一步压缩:
| 通道 |
存储内容 |
数学表达 |
| R |
菲涅尔加权积分 |
|
| G |
纯菲涅尔积分 |
|
| B |
Disney 漫反射修正 |
|
烘焙脚本(Unity 编辑器):
1 2 3 4 5 6 7 8 9 10
| var rt = new RenderTexture(64, 64, 0, GraphicsFormat.R16G16B16A16_SFloat);
var cam = new GameObject().AddComponent<Camera>(); cam.orthographic = true; cam.targetTexture = rt;
material.SetFloat("_FixedNdotV", Mathf.Sqrt((x + 0.5f) / 64f)); Graphics.Blit(null, rt, material);
|
每个 LUT 像素在 GPU 内做的事:
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
| // 伪代码 float2 IntegrateBRDF(float NdotV, float roughness) { float3 V = float3(sqrt(1 - NdotV * NdotV), 0, NdotV); float A = 0; float B = 0; const uint SAMPLES = 1024; for (uint i = 0; i < SAMPLES; ++i) { float2 Xi = Hammersley(i, SAMPLES); float3 H = ImportanceSampleGGX(Xi, float3(0, 0, 1), roughness); float3 L = 2 * dot(V, H) * H - V; float NdotL = saturate(L.z); float NdotH = saturate(H.z); float VdotH = saturate(dot(V, H)); if (NdotL > 0) { float G = G_SmithJoint(NdotV, NdotL, roughness); float G_Vis = G * VdotH / (NdotH * NdotV); float Fc = pow(1 - VdotH, 5); A += (1 - Fc) * G_Vis; B += Fc * G_Vis; } } return float2(A, B) / SAMPLES; }
|
三、Filament 标准模型
Google Filament 是工程实现 Disney 标准模型最完整、文档最清晰的开源参考。它的 standard lit 模型可视为 Disney 的”实时精简版”。
3.1 完整代码
GLSL
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
| float D_GGX(float NoH, float a) { float a2 = a * a; float f = (NoH * a2 - NoH) * NoH + 1.0; return a2 / (PI * f * f); }
float V_SmithGGXCorrelated(float NoV, float NoL, float a) { float a2 = a * a; float GGXL = NoV * sqrt((-NoL * a2 + NoL) * NoL + a2); float GGXV = NoL * sqrt((-NoV * a2 + NoV) * NoV + a2); return 0.5 / (GGXV + GGXL); }
vec3 F_Schlick(float u, vec3 f0) { return f0 + (vec3(1.0) - f0) * pow(1.0 - u, 5.0); }
float Fd_Lambert() { return 1.0 / PI; }
void BRDF(...) { vec3 h = normalize(v + l);
float NoV = abs(dot(n, v)) + 1e-5; float NoL = clamp(dot(n, l), 0.0, 1.0); float NoH = clamp(dot(n, h), 0.0, 1.0); float LoH = clamp(dot(l, h), 0.0, 1.0);
float roughness = perceptualRoughness * perceptualRoughness;
float D = D_GGX(NoH, roughness); vec3 F = F_Schlick(LoH, f0); float V = V_SmithGGXCorrelated(NoV, NoL, roughness);
vec3 Fr = (D * V) * F;
vec3 Fd = diffuseColor * Fd_Lambert();
}
|
3.2 三参数重映射
Filament 暴露给美术的核心参数是 baseColor、metallic、roughness、reflectance。但 BRDF 内部使用的是 diffuseColor、f0、alpha,需要重映射:
3.2.1 BaseColor → diffuseColor
金属没有漫反射,电介质漫反射 = baseColor。简单插值:
1
| vec3 diffuseColor = (1.0 - metallic) * baseColor.rgb;
|
3.2.2 Reflectance →
非金属的 应当能表达从普通材质(4%)到宝石(8-16%)的全范围。Lagarde 的映射函数:
reflectance = 0.5 对应 (默认)。
1
| vec3 f0 = 0.16 * reflectance * reflectance * (1.0 - metallic) + baseColor * metallic;
|
3.2.3 perceptualRoughness → roughness
1
| float roughness = perceptualRoughness * perceptualRoughness;
|
平方映射的视觉理由在第二篇已详述:让美术的 [0,1] 滑动条在感知上线性。
| 参数 |
美术调控范围 |
物理含义 |
| BaseColor |
sRGB [30, 240](电介质);金属直接给反射光谱 |
漫反射颜色 / 金属反射率 |
| Metallic |
0 或 1(推荐二元) |
金属/电介质判别 |
| Roughness |
[0.045, 1.0] |
微表面粗糙度 |
| Reflectance |
0.35(无对应物)~ 1.0 |
电介质 IOR |
建议:BaseColor 应避免包含任何光照信息(除非微小 occlusion);Metallic 推荐二元值(混合金属用蒙版分离区域);电介质的 reflectance 不要低于 0.35。
四、Unity URP 源码对照
4.1 关键文件
1 2 3 4 5 6 7 8 9 10 11
| Packages/com.unity.render-pipelines.universal/ShaderLibrary/BRDF.hlsl
Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl
Packages/com.unity.render-pipelines.universal/ShaderLibrary/GlobalIllumination.hlsl
Packages/com.unity.render-pipelines.core/ShaderLibrary/BSDF.hlsl
|
4.2 BRDFData 结构
1 2 3 4 5 6 7 8 9 10 11 12 13
| struct BRDFData { half3 albedo; half3 diffuse; // (1 - metallic) * albedo half3 specular; // f0 half reflectivity; // 1 - oneMinusReflectivity half perceptualRoughness; // 美术值 half roughness; // perceptualRoughness^2 half roughness2; // roughness^2 half grazingTerm; half normalizationTerm; half roughness2MinusOne; // 优化用预计算 };
|
grazingTerm、normalizationTerm 是 URP 为简化 fragment 端运算预计算的辅助量。
4.3 直接光 BRDF 求值
URP 把 D / V / F 三项整合到一个内联函数里,并做了一些工程化”缩放”以适配它diffuse 不除 PI 的工程惯例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| half3 DirectBRDFSpecular(BRDFData brdfData, half3 normalWS, half3 lightDirectionWS, half3 viewDirectionWS) { float3 halfDir = SafeNormalize(float3(lightDirectionWS) + float3(viewDirectionWS)); float NoH = saturate(dot(float3(normalWS), halfDir)); half LoH = half(saturate(dot(lightDirectionWS, halfDir))); // GGX NDF + 工程优化版 V // d = NoH^2 * brdfData.roughness2MinusOne + 1.00001 float d = NoH * NoH * brdfData.roughness2MinusOne + 1.00001f; half LoH2 = LoH * LoH; half specularTerm = brdfData.roughness2 / ((d * d) * max(0.1h, LoH2) * brdfData.normalizationTerm); return specularTerm * brdfData.specular; }
|
注意:URP 的 specularTerm 实际上是经过精简的 D × V,省去了显式的 F 项(被合并到 brdfData.specular 之外)。这是为移动端节省 ALU 的工程妥协。
完整 BRDF:
1 2 3 4
| half3 DirectBRDF(BRDFData brdfData, half3 normalWS, half3 lightDirectionWS, half3 viewDirectionWS) { return brdfData.diffuse + DirectBRDFSpecular(brdfData, normalWS, lightDirectionWS, viewDirectionWS) * brdfData.specular; }
|
代码剥离说明:URP 实际源码包含大量条件编译(#ifndef _SPECULARHIGHLIGHTS_OFF、#if defined(SHADER_API_MOBILE)、debug 分支等)。本系列默认剥离这些工程条件,只保留核心数学逻辑,避免分散对物理本质的理解。生产代码请回到 Packages/com.unity.render-pipelines.universal/ShaderLibrary/BRDF.hlsl 查看完整实现。
4.4 漫反射与高光能量的工程化调配
第一篇 9.4 节我们讲过 Shirley 的能量耦合:被反射走的光不能再参与漫反射。第二篇 12.1 节我们看到 Disney 2012 没有严格执行这一约束。那么 URP 在工程上是怎么做的?
能量互斥原理:到达表面的光,要么被反射(Specular),要么折射进入内部后散射出射(Diffuse)。理论上:
- 第一个 表示扣除菲涅尔反射的能量;
- 第二个 表示金属吸收所有透射光,不会有漫反射。
但这种”运行时实时计算 ”在 fragment shader 的光照循环中代价不小——尤其是多光源场景每个像素要重复算很多次。URP 选择了一个静态近似:
1 2 3
| // InitializeBRDFData() 内的关键预计算 brdfData.diffuse = albedo * (1.0 - metallic); // ← 只扣金属,不扣菲涅尔 brdfData.specular = lerp(kDielectricSpec.rgb, albedo, metallic);
|
注意这里只乘了 (1 - metallic),没有乘 (1 - F)!这意味着:
- URP 提前在
InitializeBRDFData 阶段做了金属/电介质能量分配——金属时 diffuse = 0,电介质时 diffuse 保留全部 albedo;
- 菲涅尔的视角依赖能量分配被省略了——掠射角下电介质的 specular 应当压制 diffuse,但 URP 没做这一步;
- 通过
EnvironmentBRDFSpecular 中的 surfaceReduction = 1 / (roughness² + 1) 与 grazingTerm 间接做了一次”非物理但视觉上接近”的补偿。
1 2 3 4 5
| half3 EnvironmentBRDFSpecular(BRDFData brdfData, half fresnelTerm) { float surfaceReduction = 1.0 / (brdfData.roughness2 + 1.0); return half3(surfaceReduction * lerp(brdfData.specular, brdfData.grazingTerm, fresnelTerm)); }
|
grazingTerm = saturate((1 - roughness) + reflectivity) 是一个经验性的”掠射角全反射”上界,使粗糙金属在边缘恢复一些反射强度。这是典型的”够用就好”工程妥协:物理上不严格、性能上极便宜、视觉上勉强可接受。
4.5 离线与实时的能量分配差异
| 模型 |
计算 |
性能 |
物理严谨性 |
| 离线(Arnold / Mantra) |
每条 ray 实时计算 并精确扣减 |
慢 |
严格 |
| Frostbite 修正版 |
直接光阶段乘 ,间接光走 LUT |
中等 |
较严格 |
| URP / Filament |
仅在初始化时 (1 - metallic),运行时用 LUT 补偿 |
极快 |
近似 |
| 移动端(OpenGL ES 3.0) |
完全跳过菲涅尔扣减,仅用 albedo × NoL |
最快 |
不严谨 |
理解这条权衡光谱后,回头看 URP 源码的”似乎不够物理”就很自然了——它本来就是在为帧率买单。如果你想做一个更物理的实现,关键是在 InitializeBRDFData 之外另开一条 path:在直接光循环内部计算 并对 brdfData.diffuse 做衰减。
4.6 IBL 合成(Specular Split Sum)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| // IBL specular (Split Sum 第一项) half3 GlossyEnvironmentReflection(half3 reflectVector, float3 positionWS, half perceptualRoughness, half occlusion, float2 normalizedScreenSpaceUV) { half mip = PerceptualRoughnessToMipmapLevel(perceptualRoughness); half4 encodedIrradiance = SAMPLE_TEXTURECUBE_LOD(_GlossyEnvironmentCubeMap, sampler_GlossyEnvironmentCubeMap, reflectVector, mip); half3 irradiance = DecodeHDREnvironment(encodedIrradiance, _GlossyEnvironmentCubeMap_HDR); return irradiance * occlusion; }
// 环境光 BRDF(Split Sum 第二项 + 漫反射合成) half3 EnvironmentBRDF(BRDFData brdfData, half3 indirectDiffuse, half3 indirectSpecular, half fresnelTerm) { half3 c = indirectDiffuse * brdfData.diffuse; c += indirectSpecular * EnvironmentBRDFSpecular(brdfData, fresnelTerm); return c; }
|
注意这里 indirectDiffuse 是从哪里来的?它不是从 cubemap 直接采样得到的——而是来自下一节要讲的球谐函数。
4.7 IBL 漫反射的工程霸主:球谐函数(SH9)
讨论 IBL 时常常忽略一个核心事实:漫反射 IBL 几乎不会通过采样 cubemap 来完成——这是一种带宽浪费。
4.7.1 为什么 SH 适合漫反射
漫反射(Lambert)相当于把环境光做了一次半球加权积分:
这是一个极低频信号——相当于把环境贴图做了极强模糊。低频信号的本质是:信号的能量绝大部分集中在少数几个低频基函数上。SH(Spherical Harmonics)正好是球面上的”傅立叶级数”,而漫反射在 SH 的 0、1、2 阶(共 9 个系数)就能精确表达 99% 以上的能量。
4.7.2 SH 的物理直觉
可以把 SH 类比为:用 9 个特殊的 3D 方向 + 9 个权重,重建出整个环境的低频光照分布。
- :1 项,常数光(环境平均颜色);
- :3 项,方向梯度(从哪个方向稍微亮一点);
- :5 项,二次方向变化(光从两侧来 vs 从中间来)。
每个颜色通道独立,因此 SH9 共需 27 个浮点数(3 通道 × 9 系数),存在 7 个 float4 constant register 中。
4.7.3 URP 的 SampleSH 实现
URP 把 Light Probe 的数据预编码进 SH,运行时只需法线与 SH 系数做一次 dot:
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
| // URP 中的 SH 漫反射采样(简化) half3 SampleSH(half3 normalWS) { real4 SHCoefficients[7]; SHCoefficients[0] = unity_SHAr; SHCoefficients[1] = unity_SHAg; SHCoefficients[2] = unity_SHAb; SHCoefficients[3] = unity_SHBr; SHCoefficients[4] = unity_SHBg; SHCoefficients[5] = unity_SHBb; SHCoefficients[6] = unity_SHC;
return max(half3(0, 0, 0), SampleSH9(SHCoefficients, normalWS)); }
// 真正的 9 系数评估 real3 SampleSH9(real4 SHCoefficients[7], real3 N) { real4 shAr = SHCoefficients[0]; real4 shAg = SHCoefficients[1]; real4 shAb = SHCoefficients[2]; real4 shBr = SHCoefficients[3]; real4 shBg = SHCoefficients[4]; real4 shBb = SHCoefficients[5]; real4 shCr = SHCoefficients[6];
// 线性项 (L0 + L1) real3 x1; x1.r = dot(shAr, real4(N, 1.0)); x1.g = dot(shAg, real4(N, 1.0)); x1.b = dot(shAb, real4(N, 1.0));
// 二次项 (L2) real4 vB = N.xyzz * N.yzzx; real3 x2; x2.r = dot(shBr, vB); x2.g = dot(shBg, vB); x2.b = dot(shBb, vB);
real vC = N.x * N.x - N.y * N.y; real3 x3 = shCr.rgb * vC;
return x1 + x2 + x3; }
|
成本对比:
| 方案 |
采样次数 |
带宽 |
视觉效果 |
| Cubemap 漫反射卷积采样 |
1 次 cubemap |
~32 字节/像素 |
良好 |
| SH9 评估 |
0 次纹理采样 |
0(常量寄存器) |
几乎相同 |
| SH3(仅 L0+L1) |
0 次 |
0 |
略差但可接受 |
这就是为什么 Unity Light Probe、UE Volumetric Lightmap、HDRP Probe Volume 全部走 SH 路径——零纹理采样、寄存器 friendly,再没有比这更便宜的选择。
4.7.4 间接漫反射合成
最终在 PBR fragment 中:
1 2 3
| half3 indirectDiffuse = SampleSH(normalWS) * occlusion; // SH9 漫反射 half3 indirectSpecular = GlossyEnvironmentReflection(...); // Split Sum 镜面 half3 indirectColor = EnvironmentBRDF(brdfData, indirectDiffuse, indirectSpecular, fresnelTerm);
|
到这里,URP 完整的 IBL 流水线已经清晰:漫反射走 SH9,镜面反射走 Split Sum——两条独立的近似路径,配合 brdfData 的能量预分配,构成了现代实时 PBR 的间接光基石。
4.8 整体流水线
URP 的 PBR 主入口在 LightingPhysicallyBased:
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
| half3 LightingPhysicallyBased(BRDFData brdfData, half3 lightColor, half3 lightDirectionWS, half lightAttenuation, half3 normalWS, half3 viewDirectionWS, ...) { half NdotL = saturate(dot(normalWS, lightDirectionWS)); half3 radiance = lightColor * (lightAttenuation * NdotL); return DirectBRDF(brdfData, normalWS, lightDirectionWS, viewDirectionWS) * radiance; }
// Final = Direct + Indirect half4 UniversalFragmentPBR(InputData inputData, SurfaceData surfaceData) { BRDFData brdfData; InitializeBRDFData(surfaceData, brdfData); Light mainLight = GetMainLight(...); half3 color = GlobalIllumination(brdfData, ...) * occlusion; // IBL + Lightmap color += LightingPhysicallyBased(brdfData, mainLight, ...); // 主光 #if defined(_ADDITIONAL_LIGHTS) LIGHT_LOOP_BEGIN(...) Light light = GetAdditionalLight(...); color += LightingPhysicallyBased(brdfData, light, ...); LIGHT_LOOP_END #endif color += emission; return half4(color, surfaceData.alpha); }
|
完整流程:SurfaceData → InitializeBRDFData → 主光 + 附加光 + GI → 雾合成 → Alpha 输出。
五、移动端性能裁剪指南
在移动 GPU(Mali、Adreno、Apple GPU)上,PBR 的主要瓶颈是 ALU 与寄存器压力。下面是一些常见的妥协路径。
5.1 NDF 的近似
将 GGX 的二次多项式分母替换为 mad 友好形式,使用 fp16:
1 2 3 4 5
| // 优化前 float d = (NoH * a2 - NoH) * NoH + 1.0;
// 优化后(移动端 fp16 友好) half d = (NoH * a - NoH) * (half)(NoH * a) + 1.0h;
|
5.2 G/V 项简化
直接抛弃高度相关 Smith Joint,改用经典 Schlick-GGX:
1 2 3 4 5 6 7 8
| // Karis 2013 经典近似 half V_SchlickGGX_Approx(half NoV, half NoL, half roughness) { half k = (roughness + 1) * (roughness + 1) * 0.125; // (a+1)^2 / 8 half GV = NoV / (NoV * (1 - k) + k); half GL = NoL / (NoL * (1 - k) + k); return GV * GL; }
|
精度损失肉眼几乎不可见,但 sqrt 全部省掉。
5.3 IBL 的级联简化
| 优化策略 |
节省 |
视觉损失 |
| BRDF LUT 改用 64×64(默认 128×128) |
内存减半,采样精度↓ |
几乎不可见 |
| Pre-filtered cubemap 减少 mip 级别(5 → 3) |
显存减半 |
中等粗糙度边界轻微突变 |
| 关闭 indirect specular(仅保留 indirect diffuse) |
大幅省 |
金属外观显著变差 |
| 用 SH9 代替 cubemap 漫反射 |
7×64×64 → 27 floats |
几乎不可见 |
| BRDF LUT 烘焙进顶点色 |
省一次纹理采样 |
仅适用低频静态光照 |
5.4 ALU 与带宽的权衡
经验法则(Mali-G77 / Adreno 660 实测):
pow(x, 5) 慢:用 x*x; x*x*x 手动展开,省 ~30% ALU;
normalize() 已硬件化:不必手动 inversesqrt(dot(v,v)) * v;
half 精度普及:BRDF 中间变量无脑用 half,仅在世界空间坐标、法线插值用 float;
- Branch vs Mask:
#ifdef _SPECULARHIGHLIGHTS_OFF 这种编译期分支无成本,运行期 if 慎用。
5.5 移动端 PBR 的防御性编程
ALU 优化讲完,接下来是移动端图形开发最痛但又最容易被忽略的坑:FP16(half)精度灾难。半精度浮点数在 Mali-G77 之后已基本零成本,但 BRDF 计算中存在多个隐藏的”精度雷区”。
5.5.1 FP16 范围与精度
| 类型 |
取值范围 |
精度 |
float (FP32) |
|
7 位有效十进制数字 |
half (FP16) |
|
3 位有效十进制数字 |
注意 FP16 最大值仅 65504。在 BRDF 中,任何中间结果超过这个值,结果立即变 INF/NaN。
5.5.2 三大典型雷区
雷区一:除零与 NaN 蔓延
1 2 3 4
| // GGX D 项分母 half d = (NoH * a2 - NoH) * NoH + 1.0; return a2 / (PI * d * d); // 当 NoH = 1.0 且 roughness = 0 时,d → 0,a2 → 0 // 0/0 = NaN,且 NaN 会污染整个 fragment
|
NaN 会沿着所有数学运算”传染”,最终导致整个 mesh 出现黑色或彩色噪点。修复:
1 2 3 4
| half d = (NoH * a2 - NoH) * NoH + 1.0; d = max(d, 1e-4h); // 防止分母过小 half2 result = a2 / (PI * d * d); result = max(result, HALF_MIN); // 防 0
|
雷区二:平方与高次幂溢出
1 2
| // Charlie sin^(1/α),当 α 极小(光滑)时 1/α 极大 return (2.0 + invAlpha) * pow(sin2h, invAlpha * 0.5) / (2.0 * PI);
|
pow(sin2h, 50.0)(roughness ≈ 0.02 时)即使 sin2h = 0.99,结果仍接近 0,但中间运算路径可能溢出。修复:
1 2
| half roughness = max(perceptualRoughness, 0.045h); // 强制最小粗糙度 half a2 = roughness * roughness;
|
URP / HDRP 都有 MIN_PERCEPTUAL_ROUGHNESS 这种全局常量,本质就是为了避免高频运算溢出。
雷区三:FP16 不能存世界空间坐标
世界空间坐标可能动辄几千几万米,远超 FP16 范围:
1 2
| half3 positionWS = ...; // ❌ 大场景会爆炸 float3 positionWS = ...; // ✅ 必须用 float
|
5.5.3 何时用 float / half 决策表
| 变量类型 |
推荐精度 |
原因 |
| 世界空间坐标 |
float |
范围可能极大 |
| 屏幕空间坐标 |
float |
后处理中可能溢出 |
| 深度值 |
float |
远裁剪面常达 1000+ |
| 切线空间法线(解码后) |
half |
范围已在 [-1, 1] |
| BRDF 中间变量(NoL, D, V, F) |
half |
范围 [0, ~1] |
albedo, specular color |
half |
sRGB 解码后 [0, 1] |
roughness, metallic |
half |
输入即在 [0, 1] |
| 累积辐射度(多光源叠加) |
half 但需 saturate |
多光源叠加可能 > 1 |
5.5.4 防御性代码模板
1 2 3 4 5 6 7 8 9
| // 推荐的 BRDF 主体起手式(移动端) half NoL = saturate(dot(N, L)); half NoV = max(abs(dot(N, V)) + 1e-5, HALF_MIN); half3 H = SafeNormalize(L + V); // SafeNormalize 防 0 向量 half NoH = saturate(dot(N, H)); half LoH = saturate(dot(L, H));
half roughness = max(perceptualRoughness * perceptualRoughness, 0.002h); half a2 = roughness * roughness;
|
SafeNormalize 在 URP 中的实现:
1 2 3 4 5
| half3 SafeNormalize(half3 inVec) { half dp3 = max(HALF_MIN, dot(inVec, inVec)); return inVec * rsqrt(dp3); }
|
这种”看起来啰嗦”的代码模板,在移动端是强制规范。每加一个 max(..., 1e-5)、每用一次 SafeNormalize,就避免一次潜在的 NaN 蔓延。
六、调试与验证三件套
部署任意自定义 PBR shader 前,建议跑过以下三项验证:
6.1 白炉测试(White Furnace Test)
将物体放入恒定 1.0 亮度的环境光,对纯白色金属(),理论上每个像素应当都是 1.0。任何偏暗都意味着 BRDF 损失了能量。
1 2 3 4 5 6 7 8
| // 白炉测试 fragment shader half4 frag(Varyings i) : SV_Target { half NoV = saturate(dot(i.normalWS, i.viewDirWS)); half2 fab = SAMPLE_TEXTURE2D(_BRDFLut, sampler_BRDFLut, float2(NoV, _Roughness)).rg; half3 spec = float3(1, 1, 1) * fab.x + fab.y; // f0 = 1 纯白金属 return half4(spec, 1); // 期望 ≈ 1.0 }
|
实测会发现单次散射的 Cook-Torrance 在 roughness = 1.0 时输出约 0.6-0.7。这种能量损失需要 Kulla-Conty 补偿,详见第五篇。
6.2 粗糙度梯度图
渲染 0.0 → 1.0 粗糙度的球阵列,检查能量平滑过渡:
| 粗糙度 |
视觉特征 |
| 0.0~0.1 |
锐利镜面反射 |
| 0.2~0.4 |
中度高光,轮廓清晰 |
| 0.5~0.7 |
模糊高光,散射明显 |
| 0.8~1.0 |
几乎纯漫反射,仅边缘可见 sheen |
如果某个区段出现亮度跳跃(特别是 0.0→0.05 经常出问题),说明 perceptualRoughness 映射有 bug。
6.3 金属/电介质对比
相同 BaseColor 在 metallic 0/1 时的视觉差异是否符合预期:
- 金属:明显有色高光、无漫反射;
- 电介质:白色高光(除非 baseColor 强染色 IOR)、明显漫反射;
- 中间值 0.3、0.5、0.7:虽然物理上不合法,但视觉应平滑过渡。
通过这三项验证,剩下的偏差基本只是参数调整问题。
七、工程调试
Frame Debugger 抓帧排查 IBL 出错
如果项目中关闭了所有直接光,物体仍呈现”看起来不对”的颜色,问题十有八九在 IBL:
- 隔离 SH9 漫反射:在 fragment shader 末尾
return half4(SampleSH(N), 1),正常场景应当呈现”低频环境色”——若一片漆黑则是 Light Probe 没烘焙;
- 隔离 cubemap specular:
return half4(GlossyEnvironmentReflection(reflectVector, ...), 1),金属球应当能看到模糊的天空盒倒影;
- 检查 BRDF LUT 通道:
return half4(fab.x, fab.y, 0, 1),正常应是 R 通道在掠射角偏亮的渐变。
roughness² 与 perceptualRoughness 混用
最常见的 PBR shader bug:忘记把美术参数从 perceptualRoughness 转换到 alpha:
1 2 3 4 5 6
| // ❌ 错误:直接用 perceptualRoughness 进入 GGX float D = D_GGX(NoH, perceptualRoughness);
// ✅ 正确:必须先平方 float roughness = perceptualRoughness * perceptualRoughness; float D = D_GGX(NoH, roughness);
|
视觉表现:高光区在 roughness ∈ [0, 0.5] 区间几乎看不到变化,到 0.6 之后突然变模糊。
间接漫反射的能量丢失
如果场景中只看到 direct light 的漫反射(背光面纯黑),说明 IBL 漫反射没接进来。检查:
SampleSH(N) 返回值是否非零;
bakedGI 与 SampleSH 是否正确叠加(一些场景下需要二选一,否则双倍计数);
- AO 通道是否过暗(
occlusion = 0 会把 IBL 全乘成 0)。
多 Pass 场景下的指数 Banding
移动端常见现象:金属球的高光边缘出现”水波纹”色阶。原因是 FP16 精度不足以表达连续渐变。修复:
1 2
| // 把高光的最后输出从 half 转回 float 做累积 float3 finalColor = (float3)indirectColor + directLight;
|
或者在 shader 末尾加 dither(finalColor, screenUV) 抖动。
八、本篇总结
实时 PBR 的工程化路径:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 渲染方程 (理论) │ ↓ Karis Split Sum (近似) │ ├─→ Pre-filtered Env Map (烘焙) ──→ Specular IBL │ ├─→ BRDF LUT (烘焙) ─→ FGD LUT (扩展) ──→ 能量分配 │ └─→ SH9 编码 (Light Probe) ──→ Diffuse IBL │ ↓ Filament 标准模型 (参考) │ ↓ Unity URP (生产) ←→ 移动端裁剪
|
下一篇我们走出 Disney 框架的”标准舒适区”,看 PBR 如何应对那些标准模型无法处理的特殊材质——布料。
参考文献
- Karis, B. (2013). Real Shading in Unreal Engine 4. SIGGRAPH Course.
- Lagarde, S. & de Rousiers, C. (2014). Moving Frostbite to Physically Based Rendering. SIGGRAPH Course.
- Romain Guy & Mathias Agopian (2018). Material System in Filament. Google.
- Filament 文档. Filament.md.html.
- Material System in Filament(中文翻译).
- Unity Technologies. Universal Render Pipeline 14.0 Documentation.
- Renaldas Zioma (2015). Optimizing PBR for Mobile. SIGGRAPH Mobile.
- QianMo. PBR-White-Paper. GitHub.