Banner

「PBR 系列」第三篇 · 实时化工程实践:IBL、Filament 与 URP 源码

核心结论:Disney 的数学要在每帧 16ms 内跑完 100 万像素,靠的不是更快的 GPU,而是 Karis 在 UE4 引入的 Split Sum 近似——把 IBL 卷积积分拆成「预滤波 cubemap × BRDF LUT」两次纹理采样。本篇详解这套工程化方案,对照 Filament 标准模型和 Unity URP 源码,最后给出移动端的 ALU 与带宽妥协指南。

一、从理论到实时的鸿沟

第二篇我们用一段 ~80 行的 HLSL 实现了完整 Disney BRDF。但渲染方程的左半边——

——里那个半球积分才是真正的瓶颈。对每个像素做 1024 次蒙特卡洛采样,移动端直接卡死,主机端也只能用于影视级离线渲染。

实时 PBR 必须解决两个问题:

  1. 直接光照(Direct Lighting):点光、方向光、聚光——已知光源方向,BRDF 直接求值,不需要积分;
  2. 间接光照(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
// 创建 64x64 RT
var rt = new RenderTexture(64, 64, 0, GraphicsFormat.R16G16B16A16_SFloat);

var cam = new GameObject().AddComponent<Camera>();
cam.orthographic = true;
cam.targetTexture = rt;

// 用专门的 Bake Shader 绘制每个像素
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
// === D 项: GGX NDF ===
float D_GGX(float NoH, float a)
{
float a2 = a * a;
float f = (NoH * a2 - NoH) * NoH + 1.0;
return a2 / (PI * f * f);
}

// === V 项: 高度相关 Smith Joint GGX ===
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);
}

// === F 项: Schlick ===
vec3 F_Schlick(float u, vec3 f0)
{
return f0 + (vec3(1.0) - f0) * pow(1.0 - u, 5.0);
}

// === Diffuse: Lambert ===
float Fd_Lambert()
{
return 1.0 / PI;
}

// === BRDF 主体 ===
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);

// perceptualRoughness 映射到 alpha
float roughness = perceptualRoughness * perceptualRoughness;

float D = D_GGX(NoH, roughness);
vec3 F = F_Schlick(LoH, f0);
float V = V_SmithGGXCorrelated(NoV, NoL, roughness);

// specular BRDF
vec3 Fr = (D * V) * F;

// diffuse BRDF
vec3 Fd = diffuseColor * Fd_Lambert();

// 应用光照
}

3.2 三参数重映射

Filament 暴露给美术的核心参数是 baseColormetallicroughnessreflectance。但 BRDF 内部使用的是 diffuseColorf0alpha,需要重映射:

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
// BRDF 数据结构与基础函数
Packages/com.unity.render-pipelines.universal/ShaderLibrary/BRDF.hlsl

// 直接光与间接光合成
Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl

// 全局光照(IBL + Lightmap)
Packages/com.unity.render-pipelines.universal/ShaderLibrary/GlobalIllumination.hlsl

// 数学工具与 BSDF 基础
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; // 优化用预计算
};

grazingTermnormalizationTerm 是 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)!这意味着:

  1. URP 提前在 InitializeBRDFData 阶段做了金属/电介质能量分配——金属时 diffuse = 0,电介质时 diffuse 保留全部 albedo;
  2. 菲涅尔的视角依赖能量分配被省略了——掠射角下电介质的 specular 应当压制 diffuse,但 URP 没做这一步;
  3. 通过 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);
}

完整流程:SurfaceDataInitializeBRDFData → 主光 + 附加光 + 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:

  1. 隔离 SH9 漫反射:在 fragment shader 末尾 return half4(SampleSH(N), 1),正常场景应当呈现”低频环境色”——若一片漆黑则是 Light Probe 没烘焙;
  2. 隔离 cubemap specularreturn half4(GlossyEnvironmentReflection(reflectVector, ...), 1),金属球应当能看到模糊的天空盒倒影;
  3. 检查 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 漫反射没接进来。检查:

  1. SampleSH(N) 返回值是否非零;
  2. bakedGISampleSH 是否正确叠加(一些场景下需要二选一,否则双倍计数);
  3. 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 如何应对那些标准模型无法处理的特殊材质——布料。


参考文献

  1. Karis, B. (2013). Real Shading in Unreal Engine 4. SIGGRAPH Course.
  2. Lagarde, S. & de Rousiers, C. (2014). Moving Frostbite to Physically Based Rendering. SIGGRAPH Course.
  3. Romain Guy & Mathias Agopian (2018). Material System in Filament. Google.
  4. Filament 文档. Filament.md.html.
  5. Material System in Filament(中文翻译).
  6. Unity Technologies. Universal Render Pipeline 14.0 Documentation.
  7. Renaldas Zioma (2015). Optimizing PBR for Mobile. SIGGRAPH Mobile.
  8. QianMo. PBR-White-Paper. GitHub.