GameTech/URP 渲染实战
CHAPTER 11第 11 章

从零手写 PBR

前两章我们推过公式、读过 URP 的源码。这一章把两者合起来:不调用 URP 的任何 BRDF 函数,从一个只有主光的小 Shader 起步,一步步长成支持多光源、阴影、IBL、能量补偿和全套 Pass 的 Cookbook/Ch11/MyPBR,最后和 URP Lit 并排对比。

本章你会学到
把 GGX、高度相关 Smith 可见性、Schlick 菲涅尔和 Lambert 写成可复用的 HLSL,并弄清 Unity 光源强度里的 π
在 Forward 与 Forward+ 下正确遍历附加光、接收主光与附加光阴影,复用 URP 的“数据”而不是它的 BRDF
用 split-sum 实现 IBL:SH 漫反射、按粗糙度选 mip 的预滤波反射、解析近似与程序生成的 DFG LUT,以及多次散射能量补偿
补齐 ShadowCaster、DepthOnly、DepthNormals、Meta Pass,支持法线贴图、Mask Map、自发光、SRP Batcher 与 Rendering Debugger

11.1 我们要造什么

“手写 PBR”在很多教程里止步于一个只有方向光的球:D、G、F 三个函数乘起来,高光看着挺像,就算完成了。但真正能放进项目的 PBR Shader 要回答一长串问题:附加光在 Forward+ 下怎么循环?阴影坐标在级联阴影、屏幕空间阴影下怎么取?环境光从哪来、要不要除以 π?反射探针的 mip 和粗糙度什么关系?粗糙金属为什么越粗越暗、该不该补?SSAO 读不到我的法线怎么办?烘焙时自发光为什么不发光?这些问题的答案大部分不在 BRDF 公式里,而在“Shader 与管线的契约”里。

所以本章给自己定了一条边界:BRDF 全部自己写,管线数据尽量复用。所谓 BRDF,指的是“给定入射方向、出射方向和材质参数,算出反射比例”的那部分数学:法线分布 D、可见性 V、菲涅尔 F、漫反射项,以及环境 BRDF(DFG)。这些我们一行都不调 URP。所谓管线数据,指的是灯光颜色与方向、阴影图采样、Forward+ 的分簇光源列表、球谐系数、反射探针图集、SSAO 结果——这些是 URP 每帧准备好放进全局变量的,我们直接用它提供的取数函数(GetMainLight、GetAdditionalLight、SampleSH、GlossyEnvironmentReflection 等),不重复造轮子。这样写出的 Shader 既能在 URP 的各种开关组合下正确工作,又能让你完全掌控光照模型本身。

MyPBR_Input.hlsl CBUFFER · 贴图 MySampleSurface() MyPBR_BRDF.hlsl D · V · F · Lambert DFG · 能量补偿 MyPBR_Lighting.hlsl 主光 · 附加光 · IBL MyComputeLighting() ForwardPass vert / frag URP 提供的“数据”:Light、阴影、Forward+ 分簇、 SH、反射探针、SSAO(Lighting.hlsl 及其依赖) ShadowCaster阴影图 DepthOnly深度预渲染 DepthNormalsSSAO / 贴花 Meta光照贴图烘焙 MyPBRDebug分项调试视图 所有 Pass 共享 MyPBR_Input.hlsl 的同一份 UnityPerMaterial —— SRP Batcher 兼容的前提
图 11-1MyPBR 的文件结构与数据流。橙色是本章“自己写”的光照模型部分,虚线框是从 URP 借用的管线数据。

最终成品支持的功能列在下表。每一行在后面都有对应的小节,你可以按顺序读,也可以跳到感兴趣的部分。

功能实现位置说明
直接光 BRDFMyPBR_BRDF.hlslGGX + 高度相关 Smith 可见性 + Schlick 菲涅尔 + Lambert
多光源MyPBR_Lighting.hlslForward 逐物体列表、Forward+ 分簇列表、逐顶点附加光、Light Layers
阴影同上 + ShadowCaster Pass主光级联/屏幕空间阴影、附加光阴影、软阴影各档
漫反射 IBLSAMPLE_GI球谐(逐像素/逐顶点/混合)或光照贴图
镜面 IBLMySamplePrefilteredEnv反射探针按粗糙度选 mip、盒体投影、探针混合、Forward+ 探针图集
环境 BRDFMyEnvDFGKaris 解析近似,或 C# 程序生成的 DFG LUT(关键字 _DFG_LUT)
能量补偿MyEnergyCompensation多次散射补偿,材质滑条 0~1 可调,便于对比
贴图MyPBR_Input.hlslBase Map、法线贴图、Mask Map(R 金属 G AO A 光滑)、自发光
其余 PassMyPBR_DepthPasses.hlsl 等ShadowCaster、DepthOnly、DepthNormals、Meta
工程化MyPBR.shaderSRP Batcher、GPU Instancing、雾、SSAO、Rendering Debugger

11.2 从公式到代码之前:三个约定

第 9 章推导过渲染方程和 Cook-Torrance 模型,这里不再重复推导,只把要落到代码里的公式列出来,并把三个“写代码时才会碰到”的约定讲清楚。对一盏精确光源(方向光、点光、聚光,统称 punctual light),渲染方程里的积分退化成求和:

Lo(v)=∑kf(lk,v)Ek(n·lk),f=cdiffπ+D(h)G2(l,v)F(v,h)4(n·l)(n·v)

其中 Ek 是第 k 盏灯在垂直入射时产生的辐照度(Irradiance)。

约定一:Unity 灯光颜色里藏着一个 π

Unity 的 Light 组件只给出“颜色 × 强度”,没有说它是辐照度还是别的什么物理量。要确定它的含义,最可靠的办法是看 URP 自己怎么用它。URP 的直接光漫反射写的是 brdfData.diffuse * light.color * NdotL,漫反射项里没有 1/π;它的 GGX 也写成 roughness2 / (d * d),同样没有 π。也就是说 URP 计算的是 π·f,再乘上 light.color。换成物理写法:

Lo=f·(π·Clight)·(n·l)⇒E⊥=π·Clight

直观理解:一盏颜色为 1 的灯正对一块反照率为 1 的 Lambert 白板,白板的亮度就是 1。我们的实现保持 BRDF 的物理形式(漫反射除以 π、D 里带 π),在“乘灯光”那一步乘回 π。这样做有两个好处:BRDF 代码可以直接对照论文和第 9 章的公式检查;与 URP Lit 在同一盏灯下亮度一致,场景不用为换 Shader 重新打光。

注意不要“为了物理正确”把 π 去掉或者多除一次。前者让漫反射亮 π 倍,后者让高光暗 π 倍,两种错误在单张截图里都不一定显眼,但一旦和 URP Lit 的物体摆在同一个场景里就会露馅。本章 11.5 节的矩阵对比图就是检验这一点的。

约定二:三种“粗糙度”

美术调的是 Smoothness(光滑度);我们先换成感知粗糙度(Perceptual Roughness)r=1−smoothness;GGX 公式里用的是 α=r2;而 D 和 V 实际只用到 α2。平方的目的是让滑条在视觉上更均匀——如果直接把滑条当 α,光滑那一半的变化会挤在很小的区间里。代码里我们在 MyInitMaterial 一次性算好 α²,光源循环里不再重复计算。感知粗糙度还被下限截断到 0.045:理想镜面(α=0)在精确光源下的高光是一个狄拉克函数,数值上会变成无穷大或半精度溢出,截断后高光变成一个极小但有限的亮点。

约定三:一切计算在线性空间

工程设置为 Linear 色彩空间后,sRGB 贴图在采样时由硬件解码成线性值,相机目标在写入时再编码回去,Shader 里看到的全是线性量。材质面板上的 Color 属性也会被 Unity 自动转换成线性。这一点 c13 会专门讲,这里只需要记住:不要在 Shader 里手动做 pow 2.2。

案例 11.1BRDF 四件套进阶单独打开 ↗

目标:把 D、V、F 和 Lambert 写成四个独立的小函数,名字统一加 My 前缀,避免和 core 包 BSDF.hlsl 里的同名函数(如 D_GGX、F_Schlick)冲突。

Assets/Cookbook/Ch11/MyPBR_BRDF.hlsl(节选:brdf_terms)
// 法线分布函数 D:GGX / Trowbridge-Reitz
//   D(h) = a² / ( π ( (n·h)² (a² - 1) + 1 )² )
float MyD_GGX(float NoH, float a2)
{
    float d = (NoH * a2 - NoH) * NoH + 1.0;   // = NoH²(a²-1)+1,写成两次 MAD
    return a2 / (PI * d * d);
}

// 可见性项 V = G2 / (4 NoL NoV),G2 用高度相关(Height-Correlated)Smith-GGX
//   V = 0.5 / ( NoL * sqrt(NoV²(1-a²) + a²) + NoV * sqrt(NoL²(1-a²) + a²) )
float MyV_SmithGGXCorrelated(float NoV, float NoL, float a2)
{
    float lambdaV = NoL * sqrt((NoV - NoV * a2) * NoV + a2);
    float lambdaL = NoV * sqrt((NoL - NoL * a2) * NoL + a2);
    return 0.5 / max(lambdaV + lambdaL, 1e-5);
}

// 菲涅尔 F:Schlick 近似  F = F0 + (F90 - F0)(1 - cosθ)^5
float3 MyF_Schlick(float3 f0, float f90, float cosTheta)
{
    float x  = 1.0 - cosTheta;
    float x2 = x * x;
    float x5 = x2 * x2 * x;                    // 手写 pow5,比 pow() 便宜且没有 pow(0,5) 的边界问题
    return f0 + (f90 - f0) * x5;
}

// 漫反射:Lambert。能量守恒要求 ∫ f_d cosθ dω = albedo,所以要除以 π
float MyFd_Lambert()
{
    return INV_PI;
}
// 法线分布函数 D:GGX / Trowbridge-Reitz
//   D(h) = a² / ( π ( (n·h)² (a² - 1) + 1 )² )
float MyD_GGX(float NoH, float a2)
{
    float d = (NoH * a2 - NoH) * NoH + 1.0;   // = NoH²(a²-1)+1,写成两次 MAD
    return a2 / (PI * d * d);
}

// 可见性项 V = G2 / (4 NoL NoV),G2 用高度相关(Height-Correlated)Smith-GGX
//   V = 0.5 / ( NoL * sqrt(NoV²(1-a²) + a²) + NoV * sqrt(NoL²(1-a²) + a²) )
float MyV_SmithGGXCorrelated(float NoV, float NoL, float a2)
{
    float lambdaV = NoL * sqrt((NoV - NoV * a2) * NoV + a2);
    float lambdaL = NoV * sqrt((NoL - NoL * a2) * NoL + a2);
    return 0.5 / max(lambdaV + lambdaL, 1e-5);
}

// 菲涅尔 F:Schlick 近似  F = F0 + (F90 - F0)(1 - cosθ)^5
vec3 MyF_Schlick(vec3 f0, float f90, float cosTheta)
{
    float x  = 1.0 - cosTheta;
    float x2 = x * x;
    float x5 = x2 * x2 * x;                    // 手写 pow5,比 pow() 便宜且没有 pow(0,5) 的边界问题
    return f0 + (f90 - f0) * x5;
}

// 漫反射:Lambert。能量守恒要求 ∫ f_d cosθ dω = albedo,所以要除以 π
float MyFd_Lambert()
{
    return INV_PI;
}

D:GGX 法线分布

D(h)=α2π((n·h)2(α2−1)+1)2

代码里分母的写法 (NoH * a2 - NoH) * NoH + 1.0 看起来绕,其实是把 (n·h)2(α2−1)+1 拆成两次乘加(MAD),GPU 上一条指令完成一次乘加。这个式子在 n·h=1 时取峰值 1/(πα2):越光滑峰越高越窄,但积分 ∫D(n·h)dω=1 始终成立——光滑表面的高光“小而亮”、粗糙表面“大而暗”,总能量不变。GGX 相比 Beckmann 的长尾巴(高光周围一圈柔和的光晕)就来自分母的平方形式衰减得慢。

V:高度相关的 Smith 可见性

几何项 G2 描述的是微表面之间的相互遮挡:从视线方向看过去,一部分微平面被前面的凸起挡住(Masking);从光源方向看过去,一部分微平面处在别人的阴影里(Shadowing)。Smith 模型用一个辅助函数 Λ 描述单个方向上被遮挡的程度,对 GGX 有解析解:

Λ(θ)=−1+1+α2tan2θ2,G2=11+Λ(θv)+Λ(θl)

右边这个 G2 叫“高度相关(Height-Correlated)”形式。它和常见的“可分离”形式 G1(v)G1(l) 的区别在于:可分离形式假设“被视线遮挡”和“被光线遮挡”是独立事件,而实际上高处的微平面两样都不容易被挡、低处的两样都容易被挡,两者正相关。高度相关形式考虑了这种相关性,遮挡估计更准,也更不容易把能量“挡多了”。

光线 l 被挡住(Shadowing) 视线 v 反射光被前方凸起挡住(Masking) 高处的微平面同时“看得见光、也看得见眼睛”,低处的两样都难 —— 两种遮挡是相关的
图 11-2微表面的阴影与遮挡。高度相关 Smith 把两个方向的遮挡放在同一个式子里,比可分离形式更准确。

BRDF 里 G2 总是和分母 4(n·l)(n·v) 一起出现,把 tanθ 用余弦展开后,二者合并成可见性项 V,分母里的 NoL、NoV 会和 Λ 里的项约掉:

V=G24(n·l)(n·v)=0.5(n·l)(n·v)2(1−α2)+α2+(n·v)(n·l)2(1−α2)+α2

这就是 MyV_SmithGGXCorrelated。这种写法最大的好处是:没有单独的 1/(4NoL·NoV),掠射角时不会出现“0 除以 0”。分母仍可能在 NoL 与 NoV 同时为 0 时为 0,所以加了 max(…, 1e-5)。

F:Schlick 菲涅尔

F=F0+(F90−F0)(1−v·h)5

注意菲涅尔用的角度是视线与半角向量的夹角 v·h(等于 l·h),因为真正发生镜面反射的是朝向 h 的那些微平面;而不是 n·v。后者只在环境光(11.4 节)里作为近似出现。F90 我们取 1:任何材质在完全掠射时都接近全反射。五次方手写成 x2 * x2 * x,既省掉 pow 的 exp/log 展开,又避免某些平台上 pow(0, 5) 的未定义行为。

Lambert 漫反射

Lambert 是常数 BRDF c/π。为什么除以 π?因为余弦加权的半球积分 ∫cosθdω=π,除以 π 后整块表面反射出去的总能量才恰好是反照率 c。第 9 章提到的 Disney Diffuse 在粗糙表面掠射角有回射增亮,但它本身不严格能量守恒,开销也更大;移动端友好的 URP Lit 也用 Lambert,这里保持一致,便于对比。

把参数准备好:MyMaterial

Assets/Cookbook/Ch11/MyPBR_BRDF.hlsl(节选:material)
// 由表面参数推出 BRDF 需要的量(与光源无关,每像素算一次)
struct MyMaterial
{
    float3 diffuseColor;        // 进入 Lambert 的颜色
    float3 f0;                  // 正入射镜面反射率
    float  perceptualRoughness;
    float  a2;                  // α² ,α = perceptualRoughness²
    float3 energyCompensation;  // 多次散射补偿系数,由 DFG 算出,默认 1
};

MyMaterial MyInitMaterial(half3 albedo, half metallic, half perceptualRoughness)
{
    MyMaterial m;
    // 金属度工作流:金属没有漫反射,基础色就是 F0;电介质 F0 固定 0.04,基础色进漫反射。
    // 电介质表面先被镜面反射走约 4%,剩下的才进入次表面形成漫反射,所以乘 (1 - 0.04)
    m.diffuseColor = albedo * (1.0 - metallic) * (1.0 - MY_DIELECTRIC_F0);
    m.f0 = lerp(MY_DIELECTRIC_F0.xxx, albedo, metallic);
    m.perceptualRoughness = max(perceptualRoughness, MY_MIN_PERCEPTUAL_ROUGHNESS);
    float a = m.perceptualRoughness * m.perceptualRoughness;   // 感知粗糙度平方 = GGX 的 α
    m.a2 = a * a;
    m.energyCompensation = 1.0;
    return m;
}
// 由表面参数推出 BRDF 需要的量(与光源无关,每像素算一次)
struct MyMaterial
{
    vec3  diffuseColor;        // 进入 Lambert 的颜色
    vec3  f0;                  // 正入射镜面反射率
    float perceptualRoughness;
    float a2;                  // α² ,α = perceptualRoughness²
    vec3  energyCompensation;  // 多次散射补偿系数,由 DFG 算出,默认 1
};

MyMaterial MyInitMaterial(vec3 albedo, float metallic, float perceptualRoughness)
{
    MyMaterial m;
    // 金属度工作流:金属没有漫反射,基础色就是 F0;电介质 F0 固定 0.04,基础色进漫反射。
    // 电介质表面先被镜面反射走约 4%,剩下的才进入次表面形成漫反射,所以乘 (1 - 0.04)
    m.diffuseColor = albedo * (1.0 - metallic) * (1.0 - MY_DIELECTRIC_F0);
    m.f0 = mix(vec3(MY_DIELECTRIC_F0), albedo, metallic);
    m.perceptualRoughness = max(perceptualRoughness, MY_MIN_PERCEPTUAL_ROUGHNESS);
    float a = m.perceptualRoughness * m.perceptualRoughness;   // 感知粗糙度平方 = GGX 的 α
    m.a2 = a * a;
    m.energyCompensation = vec3(1.0);
    return m;
}

金属度工作流的核心就两行:diffuseColor 与 f0。金属没有次表面散射,进入表面的光很快被吸收,因此漫反射为 0,基础色全部用来当 F0(金属的颜色体现在反射里);电介质(塑料、木头、石头……)的 F0 很低且几乎不带颜色,常用 0.04 近似,基础色进入漫反射。乘 (1−0.04) 是一个最简单的能量分配:表面先镜面反射走约 4%,剩下的才进入次表面。更精细的做法是乘 (1−F) 或用“漫反射能量补偿”,但收益很小,URP Lit 也是这样的常数近似。

组合:直接光 BRDF

Assets/Cookbook/Ch11/MyPBR_BRDF.hlsl(节选:direct_brdf)
// 单个光源方向 L 上的 BRDF:分别返回漫反射项 fd 与镜面项 fs(单位 1/sr),
// 还没乘入射光和 NoL —— 那是“光照”的事,不是“材质”的事。
void MyDirectBRDF(MyMaterial m, float3 N, float3 V, float3 L,
                  out float3 fd, out float3 fs, out float NoL)
{
    float3 H = SafeNormalize(L + V);
    float NoV = abs(dot(N, V)) + 1e-5;         // 法线贴图会让 N 背向视线,取绝对值避免除 0
    NoL = saturate(dot(N, L));
    float NoH = saturate(dot(N, H));
    float LoH = saturate(dot(L, H));           // = VoH,半角向量两侧对称

    float  D   = MyD_GGX(NoH, m.a2);
    float  Vis = MyV_SmithGGXCorrelated(NoV, NoL, m.a2);
    float3 F   = MyF_Schlick(m.f0, 1.0, LoH);

    fs = (D * Vis) * F * m.energyCompensation;
    fd = m.diffuseColor * MyFd_Lambert();
}
// 单个光源方向 L 上的 BRDF:分别返回漫反射项 fd 与镜面项 fs(单位 1/sr),
// 还没乘入射光和 NoL —— 那是“光照”的事,不是“材质”的事。
void MyDirectBRDF(MyMaterial m, vec3 N, vec3 V, vec3 L,
                  out vec3 fd, out vec3 fs, out float NoL)
{
    vec3 H = SafeNormalize(L + V);
    float NoV = abs(dot(N, V)) + 1e-5;         // 法线贴图会让 N 背向视线,取绝对值避免除 0
    NoL = saturate(dot(N, L));
    float NoH = saturate(dot(N, H));
    float LoH = saturate(dot(L, H));           // = VoH,半角向量两侧对称

    float D   = MyD_GGX(NoH, m.a2);
    float Vis = MyV_SmithGGXCorrelated(NoV, NoL, m.a2);
    vec3  F   = MyF_Schlick(m.f0, 1.0, LoH);

    fs = (D * Vis) * F * m.energyCompensation;
    fd = m.diffuseColor * MyFd_Lambert();
}

MyDirectBRDF 只返回 BRDF 本身(单位是 1/sr),刻意不乘灯光与 NoL:BRDF 属于“材质”,乘灯光属于“光照”,两者分开后,调试视图可以单独显示每一项,c12 的清漆、各向异性也能在同一框架下替换镜面项。NoV 用了 abs(…) + 1e-5:法线贴图常会让像素法线略微背向视线,直接 saturate 会让 NoV 变成 0,V 项和环境 BRDF 都在这里出问题,表现为模型边缘出现黑点或亮点;取绝对值是 Filament 等引擎的通行做法。

图 11-3用 MyPBRDebug 单独输出各项,三个球的光滑度从左到右为 0.85 / 0.6 / 0.3。左一:归一化的 D(除以峰值),越光滑高光越小;左二:G2,粗糙球在明暗交界和轮廓处被明显压暗;右二:F(此格改用逆光),只有掠射的轮廓处变亮;右一:完整的直接镜面 DVF·E·NoL。

案例 11.2第一步:只有主光的最小 PBR入门单独打开 ↗

目标:在加任何复杂功能之前,用最少的代码验证 BRDF 是对的。这个 Shader 只读主光方向和颜色,没有阴影、没有环境光、没有贴图。

Assets/Cookbook/Ch11/Step1_DirectPBR.shader
// 第一步:只有主光、没有阴影、没有环境光的最小 PBR。
// 目的:先单独验证 BRDF(D、V、F、Lambert 与 π 的约定)是否正确,再往上加东西。
Shader "Cookbook/Ch11/Step1_DirectPBR"
{
    Properties
    {
        _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
        _Metallic ("Metallic", Range(0, 1)) = 0
        _Smoothness ("Smoothness", Range(0, 1)) = 0.5
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }
        Pass
        {
            Tags { "LightMode"="UniversalForward" }
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            CBUFFER_START(UnityPerMaterial)
                half4 _BaseColor;
                half  _Metallic;
                half  _Smoothness;
            CBUFFER_END
            // MyPBR_BRDF.hlsl 里的 LUT 分支要用到这张贴图的声明(本步不启用)
            TEXTURE2D(_DFGLut);
            #include "Assets/Cookbook/Ch11/MyPBR_BRDF.hlsl"

            struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; };
            struct Varyings   { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; float3 normalWS : TEXCOORD1; };

            Varyings vert(Attributes v)
            {
                Varyings o;
                o.positionWS = TransformObjectToWorld(v.positionOS.xyz);
                o.positionCS = TransformWorldToHClip(o.positionWS);
                o.normalWS   = TransformObjectToWorldNormal(v.normalOS);
                return o;
            }

            half4 frag(Varyings i) : SV_Target
            {
                float3 N = normalize(i.normalWS);
                float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS);
                Light light = GetMainLight();

                MyMaterial m = MyInitMaterial(_BaseColor.rgb, _Metallic, 1.0 - _Smoothness);
                float3 fd, fs; float NoL;
                MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL);

                // 渲染方程只剩一项:Lo = f(l,v) · E⊥ · NoL,E⊥ = π · light.color
                float3 Lo = (fd + fs) * PI * light.color * NoL;
                return half4(Lo, 1);
            }
            ENDHLSL
        }
    }
}
// 第一步:只有主光、没有阴影、没有环境光的最小 PBR。(GLSL 版,对应 Ch11/Step1_DirectPBR.shader)
// 目的:先单独验证 BRDF(D、V、F、Lambert 与 π 的约定)是否正确,再往上加东西。
Shader "Cookbook/Ch11/Step1_DirectPBR"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }
        Pass
        {
            Tags { "LightMode"="UniversalForward" }
            GLSLPROGRAM
            uniform vec4  _BaseColor;
            uniform float _Metallic;
            uniform float _Smoothness;
            // MyPBR_BRDF.glsl 里的 LUT 分支要用到这张贴图的声明(本步不启用)
            uniform sampler2D _DFGLut;
            #include "Ch11/MyPBR_BRDF.glsl"

            VARYING vec3 positionWS;
            VARYING vec3 normalWS;

            #ifdef VERTEX
            void main()
            {
                positionWS  = TransformObjectToWorld(positionOS.xyz);
                gl_Position = TransformWorldToHClip(positionWS);
                normalWS    = TransformObjectToWorldNormal(normalOS);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                vec3 N = normalize(normalWS);
                vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
                Light light = GetMainLight();

                MyMaterial m = MyInitMaterial(_BaseColor.rgb, _Metallic, 1.0 - _Smoothness);
                vec3 fd, fs; float NoL;
                MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL);

                // 渲染方程只剩一项:Lo = f(l,v) · E⊥ · NoL,E⊥ = π · light.color
                vec3 Lo = (fd + fs) * PI * light.color * NoL;
                fragColor = vec4(Lo, 1.0);
            }
            #endif
            ENDGLSL
        }
    }
}

片元函数就是渲染方程的一项:(fd + fs) * PI * light.color * NoL。GetMainLight() 的无参版本只返回方向、颜色和距离衰减(方向光恒为 1,在 Forward 下它还兼做“灯光是否被 Culling Mask 剔除”的开关),没有阴影。TEXTURE2D(_DFGLut) 这一行是因为 MyPBR_BRDF.hlsl 里的 LUT 分支引用了它——即使本步不启用 _DFG_LUT,这张贴图的声明也必须存在,否则预处理后的代码在语法上不完整。

图 11-4Step1_DirectPBR 的结果。上排为橙色电介质,下排为金色金属,光滑度从左到右 0.1 → 0.95。没有环境光时背光面全黑,光滑的金属几乎全黑——它只反射“环境”,而此时环境不存在。

这张图已经能说明很多事情:电介质的漫反射亮度不随光滑度变化(能量分配给了漫反射),高光越来越小越来越亮;金属没有漫反射,随着光滑度升高,球体的大部分区域反而变黑,只剩一个刺眼的光点。很多初学者会在这一步觉得“金属不对”,其实是对的——金属的外观几乎完全由它反射的环境决定,这正是 11.4 节 IBL 要补上的部分。

提示验证 BRDF 时,最好关掉后处理(本书配图默认不开 Tonemapping)、用纯色背景、用 0.5 左右的中性灰材质做一个对照球。高光是否在光滑度变化时保持“总量守恒”,可以截图后用取色器比较高光区域的平均亮度。

11.3 MyPBR 的骨架:属性、CBUFFER 与表面输入

从这一节开始搭建正式版本。先看 ShaderLab 层面的属性声明:

Assets/Cookbook/Ch11/MyPBR.shader(节选:properties)
Properties
{
    [MainTexture] _BaseMap ("Base Map", 2D) = "white" {}
    [MainColor]   _BaseColor ("Base Color", Color) = (1, 1, 1, 1)
    _Metallic   ("Metallic", Range(0, 1)) = 0
    _Smoothness ("Smoothness", Range(0, 1)) = 0.5

    [Toggle(_MASKMAP)] _UseMaskMap ("Use Mask Map", Float) = 0
    [NoScaleOffset] _MaskMap ("Mask (R Metal, G AO, A Smooth)", 2D) = "white" {}
    _OcclusionStrength ("Occlusion Strength", Range(0, 1)) = 1

    [Toggle(_NORMALMAP)] _UseNormalMap ("Use Normal Map", Float) = 0
    [NoScaleOffset][Normal] _BumpMap ("Normal Map", 2D) = "bump" {}
    _BumpScale ("Normal Scale", Float) = 1

    [Toggle(_EMISSION)] _UseEmission ("Use Emission", Float) = 0
    [NoScaleOffset] _EmissionMap ("Emission Map", 2D) = "white" {}
    [HDR] _EmissionColor ("Emission Color", Color) = (0, 0, 0, 1)

    [Toggle(_DFG_LUT)] _UseDFGLut ("Use DFG LUT (else analytic)", Float) = 0
    [NoScaleOffset] _DFGLut ("DFG LUT", 2D) = "black" {}
    _EnergyCompensation ("Multi-Scatter Compensation", Range(0, 1)) = 1
    _SpecularOcclusion ("Specular Occlusion", Range(0, 1)) = 1

    [Toggle(_ALPHATEST_ON)] _AlphaClip ("Alpha Clip", Float) = 0
    _Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5
}
// 第 11 章最终成果:完全手写 BRDF 的 PBR Shader,可直接在项目中使用。(GLSL 版,对应 Ch11/MyPBR.shader)
// 支持:Forward / Forward+、主光与附加光阴影、SH + 反射探针 IBL、DFG(解析或 LUT)、多次散射补偿、
//       法线贴图、Mask Map(R 金属 G AO A 光滑)、自发光、SSAO、Rendering Debugger。
// (HLSL 版另外支持光照贴图、Light Layers、SRP Batcher、GPU Instancing,网页版没有这些功能)
Shader "Cookbook/Ch11/MyPBR"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        // 所有 Pass 共享同一份材质属性声明,保证 SRP Batcher 兼容
        #include "Ch11/MyPBR_Input.glsl"
        ENDGLSL

        Pass
        {
            Name "ForwardLit"
            Tags { "LightMode"="UniversalForward" }
            ZWrite On
            Cull Back

            GLSLPROGRAM
            // ---- 材质关键字(只编译用到的组合)----
            #pragma shader_feature_local _NORMALMAP
            #pragma shader_feature_local_fragment _MASKMAP
            #pragma shader_feature_local_fragment _EMISSION
            #pragma shader_feature_local_fragment _DFG_LUT
            #pragma shader_feature_local_fragment _ALPHATEST_ON

            // ---- URP 管线关键字:_MAIN_LIGHT_SHADOWS、_ADDITIONAL_LIGHTS、_SHADOWS_SOFT 等网页版引擎始终打开 ----
            // ---- 调试:网页版模拟的 Rendering Debugger ----
            #pragma multi_compile_fragment _ DEBUG_DISPLAY

            #include "Ch11/MyPBR_ForwardPass.glsl"
            #include "Ch11/DebugDisplay.glsl"

            #ifdef VERTEX
            void main() { MyPBRVert(); }
            #endif

            #ifdef FRAGMENT
            void main()
            {
            #if defined(DEBUG_DISPLAY)
                // 让 Rendering Debugger 的材质视图 / 材质校验也能作用于 MyPBR:
                // 把我们的表面参数交给调试函数(HLSL 版是翻译成 URP 的 SurfaceData 再交给 CanDebugOverrideOutputColor)
                MySurface s  = MySampleSurface(uv);
                InputData id = MyInitInputData(s.normalTS);
                MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);
                vec4 debugColor;
                if (CanDebugOverrideOutputColor(s.albedo, s.alpha, m.f0, s.metallic, 1.0 - s.perceptualRoughness,
                                                s.occlusion, s.emission, id.normalWS, debugColor))
                {
                    fragColor = debugColor;
                    return;
                }
            #endif
                fragColor = MyPBRFrag();
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On
            ZTest LEqual
            ColorMask 0
            Cull Back

            GLSLPROGRAM
            #pragma shader_feature_local _ALPHATEST_ON
            // 方向光与点/聚光的法线偏移公式不同,URP 用 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字区分(网页版是 uniform)
            #include "Ch11/MyPBR_DepthPasses.glsl"
            #ifdef VERTEX
            void main() { MyShadowVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = MyShadowFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On
            ColorMask R
            Cull Back

            GLSLPROGRAM
            #pragma shader_feature_local _ALPHATEST_ON
            #include "Ch11/MyPBR_DepthPasses.glsl"
            #ifdef VERTEX
            void main() { MyDepthVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = MyDepthFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthNormals"
            Tags { "LightMode"="DepthNormals" }
            ZWrite On
            Cull Back

            GLSLPROGRAM
            #pragma shader_feature_local _NORMALMAP
            #pragma shader_feature_local _ALPHATEST_ON
            #include "Ch11/MyPBR_DepthPasses.glsl"
            #ifdef VERTEX
            void main() { MyDepthNormalsVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = MyDepthNormalsFrag(); }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "Meta"
            Tags { "LightMode"="Meta" }
            Cull Off

            GLSLPROGRAM
            #pragma shader_feature_local_fragment _MASKMAP
            #pragma shader_feature_local_fragment _EMISSION
            #pragma shader_feature_local_fragment _ALPHATEST_ON
            #include "Ch11/MyPBR_MetaPass.glsl"
            #ifdef VERTEX
            void main() { MyMetaVert(); }
            #endif
            #ifdef FRAGMENT
            void main() { fragColor = MyMetaFrag(); }
            #endif
            ENDGLSL
        }
    }
}

几个设计选择:

  • 属性名沿用 URP Lit 的命名(_BaseMap、_BaseColor、_Metallic、_Smoothness、_BumpMap、_EmissionColor……)。好处是在材质面板上把 Lit 换成 MyPBR 时,同名属性的值会被保留;另外 URP 的一些工具代码(例如 Meta Pass 相关、材质升级器)会按这些名字找属性。
  • 功能开关用 [Toggle(关键字)]:勾选时 Unity 自动启用对应的 shader_feature_local 关键字。用代码创建材质时,SetFloat("_UseNormalMap", 1) 不会触发关键字,必须另外调用 EnableKeyword("_NORMALMAP")——本章配图脚本就是这么做的。
  • Mask Map 的约定与本书配套贴图一致:R 金属度、G 环境光遮蔽(AO)、A 光滑度,B 通道留空。c13 会讨论为什么这样打包,以及它和 URP Lit、HDRP Mask Map 约定的异同。
Assets/Cookbook/Ch11/MyPBR_Input.hlsl(节选:cbuffer)
// 所有材质属性(贴图除外)都放进 UnityPerMaterial,且每个 Pass 看到的布局完全一致,
// 这是 SRP Batcher 兼容的硬条件。贴图本身不进 CBUFFER,但 _ST 要进。
CBUFFER_START(UnityPerMaterial)
    float4 _BaseMap_ST;
    half4  _BaseColor;
    half   _Metallic;
    half   _Smoothness;
    half   _OcclusionStrength;
    half   _BumpScale;
    half4  _EmissionColor;
    half   _Cutoff;
    half   _EnergyCompensation;   // 0 = 只算单次散射,1 = 加多次散射能量补偿
    half   _SpecularOcclusion;    // 0 = 关,1 = 用 AO 推算镜面遮蔽
CBUFFER_END

TEXTURE2D(_BaseMap);      SAMPLER(sampler_BaseMap);
TEXTURE2D(_MaskMap);      // R 金属度  G AO  A 光滑度(与配套贴图约定一致)
TEXTURE2D(_BumpMap);      SAMPLER(sampler_BumpMap);
TEXTURE2D(_EmissionMap);
TEXTURE2D(_DFGLut);       // 程序生成的环境 BRDF 查找表(RG = A, B),用全局的 sampler_LinearClamp
// Mask 与自发光借用 _BaseMap 的采样器:平铺/过滤方式一样,省采样器槽位(部分平台上限 16 个)。
// 法线贴图单独一个采样器:DepthNormals Pass 只采法线不采基础色,
// 若借用 sampler_BaseMap,_BaseMap 被编译器优化掉后 Metal 会报“采样器找不到贴图”。
// 所有材质属性(贴图除外)都放进 UnityPerMaterial,且每个 Pass 看到的布局完全一致,
// 这是 SRP Batcher 兼容的硬条件。贴图本身不进 CBUFFER,但 _ST 要进。
// (WebGL 没有 SRP Batcher,这里都是普通 uniform)
uniform vec4  _BaseMap_ST;
uniform vec4  _BaseColor;
uniform float _Metallic;
uniform float _Smoothness;
uniform float _OcclusionStrength;
uniform float _BumpScale;
uniform vec4  _EmissionColor;
uniform float _Cutoff;
uniform float _EnergyCompensation;   // 0 = 只算单次散射,1 = 加多次散射能量补偿
uniform float _SpecularOcclusion;    // 0 = 关,1 = 用 AO 推算镜面遮蔽

uniform sampler2D _BaseMap;
uniform sampler2D _MaskMap;      // R 金属度  G AO  A 光滑度(与配套贴图约定一致)
uniform sampler2D _BumpMap;
uniform sampler2D _EmissionMap;
uniform sampler2D _DFGLut;       // 程序生成的环境 BRDF 查找表(RG = A, B),贴图自身是双线性 + Clamp
// HLSL 版里 Mask 与自发光借用 _BaseMap 的采样器以节省采样器槽位;GLSL 的 sampler2D 自带采样状态,没有这个问题。

SRP Batcher 兼容有两个硬条件:所有材质属性(贴图除外)都在名为 UnityPerMaterial 的 CBUFFER 里,并且同一个 Shader 的所有 Pass 看到的 CBUFFER 布局完全相同。最省心的做法是像这里一样,把 CBUFFER 放进一个 include 文件,再在 SubShader 的 HLSLINCLUDE 块里包含它,于是 ForwardLit、ShadowCaster、DepthOnly、DepthNormals、Meta 五个 Pass 自动共享同一份声明。选中 Shader 资产,Inspector 里会显示 SRP Batcher: compatible;运行时在 Frame Debugger 里能看到这些物体被归入同一个 “SRP Batch”。

踩坑记录为了省采样器,最初的版本让法线、Mask、自发光三张贴图都借用 sampler_BaseMap。结果在 Metal 上编译 DepthNormals Pass 时报错:Unrecognized sampler 'sampler_BaseMap' - does not match any texture。原因是 DepthNormals 只采样法线贴图,_BaseMap 被编译器当成无用代码删掉,于是 sampler_BaseMap 找不到“主人”。修法是给法线贴图单独声明 sampler_BumpMap。规律:借用的采样器所属的贴图,必须在每个用到它的 Pass 里都被采样,或者改用 sampler_LinearRepeat 这类内联采样器(名字里包含过滤与寻址模式即可)。
Assets/Cookbook/Ch11/MyPBR_Input.hlsl(节选:surface)
// 表面参数:全部在“线性空间、物理含义明确”的单位下
struct MySurface
{
    half3 albedo;               // 基础色(金属时就是 F0 的颜色)
    half  alpha;
    half  metallic;
    half  perceptualRoughness;  // 感知粗糙度 = 1 - smoothness,美术直接调的那个值
    half  occlusion;            // 材质 AO(只影响间接光)
    half3 normalTS;             // 切线空间法线
    half3 emission;
};

MySurface MySampleSurface(float2 uv)
{
    MySurface s;
    half4 base = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv) * _BaseColor;
    s.albedo = base.rgb;
    s.alpha  = base.a;
#if defined(_ALPHATEST_ON)
    clip(s.alpha - _Cutoff);
#endif

#if defined(_MASKMAP)
    half4 mask = SAMPLE_TEXTURE2D(_MaskMap, sampler_BaseMap, uv);
    s.metallic   = mask.r;                       // 有 Mask 时金属度完全由贴图决定(与 URP Lit 一致)
    half smooth  = mask.a * _Smoothness;         // 光滑度 = 贴图 × 滑条,滑条当总强度用
    s.occlusion  = LerpWhiteTo(mask.g, _OcclusionStrength);
#else
    s.metallic   = _Metallic;
    half smooth  = _Smoothness;
    s.occlusion  = 1.0h;
#endif
    s.perceptualRoughness = 1.0h - smooth;

#if defined(_NORMALMAP)
    s.normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, uv), _BumpScale);
#else
    s.normalTS = half3(0, 0, 1);
#endif

#if defined(_EMISSION)
    s.emission = SAMPLE_TEXTURE2D(_EmissionMap, sampler_BaseMap, uv).rgb * _EmissionColor.rgb;
#else
    s.emission = 0;
#endif
    return s;
}
// 表面参数:全部在“线性空间、物理含义明确”的单位下
struct MySurface
{
    vec3  albedo;               // 基础色(金属时就是 F0 的颜色)
    float alpha;
    float metallic;
    float perceptualRoughness;  // 感知粗糙度 = 1 - smoothness,美术直接调的那个值
    float occlusion;            // 材质 AO(只影响间接光)
    vec3  normalTS;             // 切线空间法线
    vec3  emission;
};

#ifdef FRAGMENT
MySurface MySampleSurface(vec2 uv)
{
    MySurface s;
    vec4 base = texture(_BaseMap, uv) * _BaseColor;
    s.albedo = base.rgb;
    s.alpha  = base.a;
#if defined(_ALPHATEST_ON)
    if (s.alpha - _Cutoff < 0.0) discard;
#endif

#if defined(_MASKMAP)
    vec4 mask = texture(_MaskMap, uv);
    // (smooth 是 GLSL 的关键字(插值限定符),变量改名为 smoothness)
    s.metallic   = mask.r;                       // 有 Mask 时金属度完全由贴图决定(与 URP Lit 一致)
    float smoothness = mask.a * _Smoothness;         // 光滑度 = 贴图 × 滑条,滑条当总强度用
    s.occlusion  = LerpWhiteTo(mask.g, _OcclusionStrength);
#else
    s.metallic   = _Metallic;
    float smoothness = _Smoothness;
    s.occlusion  = 1.0;
#endif
    s.perceptualRoughness = 1.0 - smoothness;

#if defined(_NORMALMAP)
    s.normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale);
#else
    s.normalTS = vec3(0, 0, 1);
#endif

#if defined(_EMISSION)
    s.emission = texture(_EmissionMap, uv).rgb * _EmissionColor.rgb;
#else
    s.emission = vec3(0.0);
#endif
    return s;
}
#endif

MySurface 是“材质在这个像素上的物理参数”,与光照无关。几个细节:

  • 有 Mask 时金属度完全由贴图决定,光滑度则是“贴图 × 滑条”——这与 URP Lit 的 _METALLICSPECGLOSSMAP 行为一致,滑条变成整体强度,方便美术微调而不用重画贴图。
  • AO 用 LerpWhiteTo(mask.g, _OcclusionStrength),强度为 0 时 AO 恒为 1。材质 AO 只用于间接光:直接光的遮挡由阴影负责,再乘 AO 会“双重变暗”。
  • 法线贴图用 core 包的 UnpackNormalScale,它会根据平台自动处理 DXT5nm(AG 通道)、BC5(RG 通道)和 ASTC 的不同存储方式,自己写 tex * 2 - 1 在 PC 上会出错。

案例 11.3Forward Pass:顶点、InputData 与片元进阶单独打开 ↗

目标:写出 ForwardLit Pass 的顶点、片元函数,并理解为什么我们要填 URP 的 InputData 结构体。

Assets/Cookbook/Ch11/MyPBR.shader(节选:pass_forward)
Pass
{
    Name "ForwardLit"
    Tags { "LightMode"="UniversalForward" }
    ZWrite On
    Cull Back

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex MyPBRVert
    #pragma fragment MyPBRFrag

    // ---- 材质关键字(只编译用到的组合)----
    #pragma shader_feature_local _NORMALMAP
    #pragma shader_feature_local_fragment _MASKMAP
    #pragma shader_feature_local_fragment _EMISSION
    #pragma shader_feature_local_fragment _DFG_LUT
    #pragma shader_feature_local_fragment _ALPHATEST_ON

    // ---- URP 管线关键字(抄自 URP 14 Lit.shader 的 ForwardLit Pass)----
    #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN
    #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
    #pragma multi_compile _ EVALUATE_SH_MIXED EVALUATE_SH_VERTEX
    #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
    #pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING
    #pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION
    #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH
    #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION
    #pragma multi_compile_fragment _ _LIGHT_COOKIES
    #pragma multi_compile _ _LIGHT_LAYERS
    #pragma multi_compile _ _FORWARD_PLUS
    #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl"

    // ---- Unity 内置关键字:光照贴图、雾、调试 ----
    #pragma multi_compile _ LIGHTMAP_SHADOW_MIXING
    #pragma multi_compile _ SHADOWS_SHADOWMASK
    #pragma multi_compile _ DIRLIGHTMAP_COMBINED
    #pragma multi_compile _ LIGHTMAP_ON
    #pragma multi_compile_fog
    #pragma multi_compile_fragment _ DEBUG_DISPLAY
    #pragma multi_compile_instancing

    #include "Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "ForwardLit"
    Tags { "LightMode"="UniversalForward" }
    ZWrite On
    Cull Back

    GLSLPROGRAM
    // ---- 材质关键字(只编译用到的组合)----
    #pragma shader_feature_local _NORMALMAP
    #pragma shader_feature_local_fragment _MASKMAP
    #pragma shader_feature_local_fragment _EMISSION
    #pragma shader_feature_local_fragment _DFG_LUT
    #pragma shader_feature_local_fragment _ALPHATEST_ON

    // ---- URP 管线关键字:_MAIN_LIGHT_SHADOWS、_ADDITIONAL_LIGHTS、_SHADOWS_SOFT 等网页版引擎始终打开 ----
    // ---- 调试:网页版模拟的 Rendering Debugger ----
    #pragma multi_compile_fragment _ DEBUG_DISPLAY

    #include "Ch11/MyPBR_ForwardPass.glsl"
    #include "Ch11/DebugDisplay.glsl"

    #ifdef VERTEX
    void main() { MyPBRVert(); }
    #endif

    #ifdef FRAGMENT
    void main()
    {
    #if defined(DEBUG_DISPLAY)
        // 让 Rendering Debugger 的材质视图 / 材质校验也能作用于 MyPBR:
        // 把我们的表面参数交给调试函数(HLSL 版是翻译成 URP 的 SurfaceData 再交给 CanDebugOverrideOutputColor)
        MySurface s  = MySampleSurface(uv);
        InputData id = MyInitInputData(s.normalTS);
        MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);
        vec4 debugColor;
        if (CanDebugOverrideOutputColor(s.albedo, s.alpha, m.f0, s.metallic, 1.0 - s.perceptualRoughness,
                                        s.occlusion, s.emission, id.normalWS, debugColor))
        {
            fragColor = debugColor;
            return;
        }
    #endif
        fragColor = MyPBRFrag();
    }
    #endif
    ENDGLSL
}

关键字列表对照 URP 14 的 Shaders/Lit.shader 的 ForwardLit Pass 抄写,然后删掉我们不支持的部分(视差、细节贴图、透明混合、清漆、DOTS Instancing、实时 GI 的 DYNAMICLIGHTMAP_ON)。每一行 multi_compile 都对应 URP Asset 或场景里的一个开关:主光阴影三选一、附加光逐像素/逐顶点、软阴影档位、SSAO、反射探针混合与盒体投影、Light Cookie、Light Layers、Forward+。少声明一个,对应功能在这个 Shader 上就“静默失效”;多声明一个,变体数翻倍。c29 会讲如何统计和剥离变体。

Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl(节选:structs)
struct Attributes
{
    float4 positionOS        : POSITION;
    float3 normalOS          : NORMAL;
    float4 tangentOS         : TANGENT;
    float2 texcoord          : TEXCOORD0;
    float2 staticLightmapUV  : TEXCOORD1;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 positionCS  : SV_POSITION;
    float2 uv          : TEXCOORD0;
    float3 positionWS  : TEXCOORD1;
    half3  normalWS    : TEXCOORD2;
#if defined(_NORMALMAP)
    half4  tangentWS   : TEXCOORD3;    // xyz 切线,w 副切线符号
#endif
    half   fogFactor   : TEXCOORD4;
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    half3  vertexLight : TEXCOORD5;
#endif
    DECLARE_LIGHTMAP_OR_SH(staticLightmapUV, vertexSH, 6);   // 有光照贴图时是 lightmap UV,否则是逐顶点 SH
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};
// WebGL 不允许结构体做 varying:HLSL 的 Varyings 拆成单独的变量(顶点输入 Attributes 由引擎固定命名:positionOS、normalOS……)
VARYING vec2  uv;
VARYING vec3  positionWS;
VARYING vec3  normalWS;
#if defined(_NORMALMAP)
VARYING vec4  tangentWS;      // xyz 切线,w 副切线符号
#endif
VARYING float fogFactor;
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
VARYING vec3  vertexLight;
#endif
// 光照贴图 UV / 逐顶点 SH:网页版没有光照贴图,SH 在片元里逐像素求
Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl(节选:vert)
Varyings MyPBRVert(Attributes input)
{
    Varyings o = (Varyings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

    VertexPositionInputs pos = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs   nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS);

    o.positionCS = pos.positionCS;
    o.positionWS = pos.positionWS;
    o.uv         = TRANSFORM_TEX(input.texcoord, _BaseMap);
    o.normalWS   = nrm.normalWS;
#if defined(_NORMALMAP)
    // 镜像缩放(负缩放)会翻转副切线方向,GetOddNegativeScale 负责修正
    o.tangentWS  = half4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale());
#endif
    o.fogFactor  = ComputeFogFactor(pos.positionCS.z);

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    // 逐顶点附加光:只累加 color * atten * NoL(漫反射的 E/π),镜面无法逐顶点
    half3 vl = 0;
    uint n = GetAdditionalLightsCount();
    for (uint i = 0u; i < n; ++i)
    {
        Light l = GetAdditionalLight(i, pos.positionWS);
        vl += l.color * l.distanceAttenuation * saturate(dot(nrm.normalWS, l.direction));
    }
    o.vertexLight = vl;
#endif

    OUTPUT_LIGHTMAP_UV(input.staticLightmapUV, unity_LightmapST, o.staticLightmapUV);
    OUTPUT_SH(o.normalWS, o.vertexSH);
    return o;
}
#ifdef VERTEX
void MyPBRVert()
{
    VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
    VertexNormalInputs   nrm = GetVertexNormalInputs(normalOS, tangentOS);

    gl_Position = pos.positionCS;
    positionWS  = pos.positionWS;
    uv          = TransformTex(texcoord0.xy, _BaseMap_ST);
    normalWS    = nrm.normalWS;
#if defined(_NORMALMAP)
    // 镜像缩放(负缩放)会翻转副切线方向,GetOddNegativeScale 负责修正
    tangentWS   = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale());
#endif
    fogFactor   = ComputeFogFactor(pos.positionCS.z);

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    // 逐顶点附加光:只累加 color * atten * NoL(漫反射的 E/π),镜面无法逐顶点
    vec3 vl = vec3(0.0);
    int n = GetAdditionalLightsCount();
    for (int i = 0; i < n; ++i)
    {
        Light l = GetAdditionalLight(i, pos.positionWS);
        vl += l.color * l.distanceAttenuation * saturate(dot(nrm.normalWS, l.direction));
    }
    vertexLight = vl;
#endif
}
#endif

顶点函数用 GetVertexPositionInputs / GetVertexNormalInputs 一次拿到各个空间的位置和 TBN。DECLARE_LIGHTMAP_OR_SH 是 URP 的宏:开了 LIGHTMAP_ON 时声明一个 lightmap UV,否则声明一个逐顶点 SH 插值器;配套的 OUTPUT_LIGHTMAP_UV / OUTPUT_SH 负责填值,在“SH 逐像素”模式下 OUTPUT_SH 什么都不做。逐顶点附加光(URP Asset 中 Additional Lights 设为 Per Vertex 时)我们自己写了一个 Lambert 循环——它只累加 color × 衰减 × NoL,到片元里再乘漫反射色,π 恰好与 1/π 抵消。

Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl(节选:input_data)
// 填 URP 的 InputData:它只是“像素的几何与环境数据”的容器,
// 填好它,阴影、SSAO、Forward+ 的分簇循环、Rendering Debugger 都能直接用
InputData MyInitInputData(Varyings i, half3 normalTS)
{
    InputData d = (InputData)0;
    d.positionWS = i.positionWS;
    d.positionCS = i.positionCS;
#if defined(_NORMALMAP)
    float  sgn = i.tangentWS.w;
    float3 bitangent = sgn * cross(i.normalWS, i.tangentWS.xyz);
    half3x3 tbn = half3x3(i.tangentWS.xyz, bitangent, i.normalWS);
    d.tangentToWorld = tbn;
    d.normalWS = TransformTangentToWorld(normalTS, tbn);
#else
    d.normalWS = i.normalWS;
#endif
    d.normalWS = NormalizeNormalPerPixel(d.normalWS);   // 插值后长度不为 1,必须重新归一化
    d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
    d.shadowCoord = TransformWorldToShadowCoord(i.positionWS); // 逐像素算级联索引,级联/屏幕空间阴影都适用
    d.fogCoord = InitializeInputDataFog(float4(i.positionWS, 1.0), i.fogFactor);
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    d.vertexLighting = i.vertexLight;
#endif
    d.bakedGI = SAMPLE_GI(i.staticLightmapUV, i.vertexSH, d.normalWS);   // 光照贴图或 SH
    d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS);
    d.shadowMask = SAMPLE_SHADOWMASK(i.staticLightmapUV);
#if defined(DEBUG_DISPLAY)
    #if defined(LIGHTMAP_ON)
    d.staticLightmapUV = i.staticLightmapUV;
    #else
    d.vertexSH = i.vertexSH;
    #endif
#endif
    return d;
}
// 填 URP 的 InputData:它只是“像素的几何与环境数据”的容器,
// 填好它,阴影、SSAO、附加光循环都能直接用
InputData MyInitInputData(vec3 normalTS)
{
    InputData d = InitInputData();
    d.positionWS = positionWS;
    d.positionCS = gl_FragCoord;
#if defined(_NORMALMAP)
    float sgn = tangentWS.w;
    vec3 bitangent = sgn * cross(normalWS, tangentWS.xyz);
    mat3 tbn = mat3(tangentWS.xyz, bitangent, normalWS);   // GLSL 的 mat3 按列构造:列就是 T/B/N
    d.tangentToWorld = tbn;
    d.normalWS = TransformTangentToWorld(normalTS, tbn);
#else
    d.normalWS = normalWS;
#endif
    d.normalWS = NormalizeNormalPerPixel(d.normalWS);   // 插值后长度不为 1,必须重新归一化
    d.viewDirectionWS = GetWorldSpaceNormalizeViewDir(positionWS);
    d.shadowCoord = TransformWorldToShadowCoord(positionWS);
    d.fogCoord = fogFactor;
#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    d.vertexLighting = vertexLight;
#endif
    d.bakedGI = SampleSH(d.normalWS);                     // 网页版没有光照贴图:逐像素 SH
    d.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(gl_FragCoord);
    d.shadowMask = vec4(1.0);
    return d;
}

InputData 是 URP 定义的“像素几何与环境数据”的容器,里面没有任何 BRDF 内容。填好它有三个直接收益:GetMainLight(inputData, shadowMask, aoFactor) 会自动处理阴影、Light Cookie 和 SSAO 的直接光部分;Forward+ 的 LIGHT_LOOP_BEGIN 宏内部会读 inputData.normalizedScreenSpaceUV 和 inputData.positionWS;Rendering Debugger 的调试函数也以它为输入。

阴影坐标我们逐像素调用 TransformWorldToShadowCoord。URP Lit 在“单级联”时会在顶点里算好再插值以省一点 ALU,但级联阴影必须逐像素选级联,屏幕空间阴影则需要屏幕坐标——TransformWorldToShadowCoord 内部已经按 _MAIN_LIGHT_SHADOWS_CASCADE、_MAIN_LIGHT_SHADOWS_SCREEN 分好了支,逐像素调用在所有模式下都正确,代价是一次矩阵乘法。

Assets/Cookbook/Ch11/MyPBR_ForwardPass.hlsl(节选:frag)
half4 MyPBRFrag(Varyings i
#if defined(_WRITE_RENDERING_LAYERS)
    , out float4 outRenderingLayers : SV_Target1
#endif
    ) : SV_Target0
{
    UNITY_SETUP_INSTANCE_ID(i);
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);

    MySurface s  = MySampleSurface(i.uv);
    InputData id = MyInitInputData(i, s.normalTS);
    MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);

#if defined(DEBUG_DISPLAY)
    // 让 Rendering Debugger 的材质视图 / 材质校验也能作用于 MyPBR:
    // 把我们的表面参数翻译成 URP 的 SurfaceData,交给它的调试函数
    SurfaceData sd = (SurfaceData)0;
    sd.albedo = s.albedo;  sd.metallic = s.metallic;  sd.specular = m.f0;
    sd.smoothness = 1.0h - s.perceptualRoughness;     sd.normalTS = s.normalTS;
    sd.emission = s.emission;  sd.occlusion = s.occlusion;  sd.alpha = s.alpha;
    id.brdfDiffuse = m.diffuseColor;  id.brdfSpecular = m.f0;
    half4 debugColor;
    if (CanDebugOverrideOutputColor(id, sd, debugColor))
        return debugColor;
#endif

    MyLightingTerms t = MyComputeLighting(s, m, id);
    float3 color = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission;
    color = MixFog(color, id.fogCoord);

#if defined(_WRITE_RENDERING_LAYERS)
    outRenderingLayers = float4(EncodeMeshRenderingLayer(GetMeshRenderingLayer()), 0, 0, 0);
#endif
    return half4(color, 1.0);
}
vec4 MyPBRFrag()
{
    MySurface s  = MySampleSurface(uv);
    InputData id = MyInitInputData(s.normalTS);
    MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);

    // HLSL 版在 DEBUG_DISPLAY 下把表面参数交给 Rendering Debugger;网页版的调试视图见 Ch11/MyPBRDebug.glsl
    MyLightingTerms t = MyComputeLighting(s, m, id);
    vec3 color = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission;
    color = MixFog(color, id.fogCoord);
    return vec4(color, 1.0);
}

片元函数的主干只有四行:读表面 → 填 InputData → 推导 BRDF 参数 → 计算光照。DEBUG_DISPLAY 分支把我们的 MySurface 翻译成 URP 的 SurfaceData,交给 CanDebugOverrideOutputColor(摘自 URP 14 ShaderLibrary/Debug/Debugging3D.hlsl,我们用的是不需要 BRDFData 的三参数版本)。有了这几行,Window › Analysis › Rendering Debugger 里的 Albedo、Smoothness、法线等材质视图和材质校验对 MyPBR 同样有效——c13 会大量用到这个功能。_WRITE_RENDERING_LAYERS 来自 RenderingLayers.hlsl 的 pragma,开启 Decal Layers / Light Layers 的某些组合时,URP 要求不透明物体额外输出一个 Rendering Layer 目标。

图 11-5Rendering Debugger 作用于 MyPBR。左:正常着色;中:Material Override = Albedo;右:Material Override = Smoothness(刷漆环面上漆面与金属的光滑度差异一目了然)。若没有 DEBUG_DISPLAY 分支,中、右两格里的 MyPBR 物体会照常着色。
提示检查“平时不开”的变体:unity_check 和 Inspector 的编译按钮默认只编译当前关键字组合,光照贴图、调试视图、逐顶点光这类“平时不开”的分支即使有语法错误也发现不了。配套工程的 Editor/MyPBRVariantCheck.cs(菜单 Cookbook › Ch11 › Check MyPBR Variants)用六组典型关键字组合强制编译每个 Pass,并把编译错误打印出来;本章配图时它报告的错误数为 0。

案例 11.4多光源与阴影:Forward 与 Forward+进阶单独打开 ↗

目标:在两种渲染路径下都正确遍历附加光,接收主光与附加光阴影。

Assets/Cookbook/Ch11/MyPBR_Lighting.hlsl(节选:evaluate_light)
// 一盏灯对出射辐射率的贡献:Lo = f(l,v) * E⊥ * NoL
// Unity 的光源约定:light.color 是“白色 Lambert 表面正对灯时看到的亮度”,
// 即 Lo = albedo/π * E⊥  =>  E⊥ = π * light.color。所以这里乘 π,与 BRDF 里的 1/π 互相抵消。
void MyAccumulateLight(MyMaterial m, float3 N, float3 V, Light light, inout MyLightingTerms t)
{
    float3 fd, fs; float NoL;
    MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL);
    float3 E = PI * light.color * (light.distanceAttenuation * light.shadowAttenuation) * NoL;
    t.directDiffuse  += fd * E;
    t.directSpecular += fs * E;
}
// 一盏灯对出射辐射率的贡献:Lo = f(l,v) * E⊥ * NoL
// Unity 的光源约定:light.color 是“白色 Lambert 表面正对灯时看到的亮度”,
// 即 Lo = albedo/π * E⊥  =>  E⊥ = π * light.color。所以这里乘 π,与 BRDF 里的 1/π 互相抵消。
void MyAccumulateLight(MyMaterial m, vec3 N, vec3 V, Light light, inout MyLightingTerms t)
{
    vec3 fd, fs; float NoL;
    MyDirectBRDF(m, N, V, light.direction, fd, fs, NoL);
    vec3 E = PI * light.color * (light.distanceAttenuation * light.shadowAttenuation) * NoL;
    t.directDiffuse  += fd * E;
    t.directSpecular += fs * E;
}

每盏灯的贡献都走 MyAccumulateLight:先算 BRDF,再乘 E⊥=π·color、距离衰减、阴影衰减和 NoL。light.distanceAttenuation 对点光、聚光已经包含了 URP 的平滑距离衰减和聚光锥角衰减(c06 讲过公式),对方向光是 1。

Assets/Cookbook/Ch11/MyPBR_Lighting.hlsl(节选:compute_lighting)
MyLightingTerms MyComputeLighting(MySurface s, inout MyMaterial m, InputData inputData)
{
    MyLightingTerms t = (MyLightingTerms)0;
    float3 N = inputData.normalWS;
    float3 V = inputData.viewDirectionWS;
    float NoV = saturate(dot(N, V)) + 1e-5;

    // ---- 环境 BRDF 与能量补偿:只和 NoV、粗糙度有关,先算好给直接光也用 ----
    float2 dfg = MyEnvDFG(NoV, m.perceptualRoughness);
    m.energyCompensation = lerp(1.0, MyEnergyCompensation(dfg, m.f0), _EnergyCompensation);

    // ---- 遮蔽:材质 AO 与 SSAO 取最小值(URP 的约定)----
    AmbientOcclusionFactor ao = CreateAmbientOcclusionFactor(inputData.normalizedScreenSpaceUV, s.occlusion);
    half4 shadowMask = CalculateShadowMask(inputData);
    uint meshLayers = GetMeshRenderingLayer();

    // ---- 主光 ----
    Light mainLight = GetMainLight(inputData, shadowMask, ao);   // 已含阴影、Light Cookie、SSAO 直接光部分
    MixRealtimeAndBakedGI(mainLight, N, inputData.bakedGI);      // Subtractive 光照贴图模式下扣掉主光
#if defined(_LIGHT_LAYERS)
    if (IsMatchingLightLayer(mainLight.layerMask, meshLayers))
#endif
        MyAccumulateLight(m, N, V, mainLight, t);

    // ---- 附加光:Forward 逐物体列表 / Forward+ 分簇列表 ----
#if defined(_ADDITIONAL_LIGHTS)
    uint lightCount = GetAdditionalLightsCount();

    #if USE_FORWARD_PLUS
    // Forward+ 下,除主光外的方向光不进分簇,排在附加光数组最前面,要单独循环
    for (uint lightIndex = 0; lightIndex < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); lightIndex++)
    {
        FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, ao);
        #if defined(_LIGHT_LAYERS)
        if (IsMatchingLightLayer(light.layerMask, meshLayers))
        #endif
            MyAccumulateLight(m, N, V, light, t);
    }
    #endif

    // LIGHT_LOOP_BEGIN 在 Forward 下展开成 for 循环,在 Forward+ 下展开成分簇迭代器,
    // 宏内部会用到名为 inputData 的变量(normalizedScreenSpaceUV、positionWS)
    LIGHT_LOOP_BEGIN(lightCount)
        Light light = GetAdditionalLight(lightIndex, inputData, shadowMask, ao);
        #if defined(_LIGHT_LAYERS)
        if (IsMatchingLightLayer(light.layerMask, meshLayers))
        #endif
            MyAccumulateLight(m, N, V, light, t);
    LIGHT_LOOP_END
#endif

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    // 逐顶点附加光只有漫反射:顶点里累加的是 color * atten * NoL,等价于 E/π
    t.directDiffuse += inputData.vertexLighting * m.diffuseColor;
#endif

    // ---- 间接漫反射:SH(或光照贴图)----
    // Unity 的 SH / 光照贴图里存的是 E/π(“已经除过 π 的辐照度”),直接乘漫反射色即可
    t.indirectDiffuse = inputData.bakedGI * m.diffuseColor * ao.indirectAmbientOcclusion;

    // ---- 间接镜面:split-sum = 预滤波环境 × 环境 BRDF ----
    float3 R = reflect(-V, N);
    float3 prefiltered = MySamplePrefilteredEnv(R, m.perceptualRoughness, inputData.positionWS, inputData.normalizedScreenSpaceUV);
    float3 envBRDF = m.f0 * dfg.x + dfg.y;     // F90 = 1
    float specOcc = lerp(ao.indirectAmbientOcclusion,
                         MySpecularOcclusion(NoV, ao.indirectAmbientOcclusion, m.perceptualRoughness),
                         _SpecularOcclusion);
    t.indirectSpecular = prefiltered * envBRDF * m.energyCompensation * specOcc;

    t.emission = s.emission;
    return t;
}
MyLightingTerms MyComputeLighting(MySurface s, inout MyMaterial m, InputData inputData)
{
    MyLightingTerms t;
    t.directDiffuse = vec3(0.0); t.directSpecular = vec3(0.0);
    t.indirectDiffuse = vec3(0.0); t.indirectSpecular = vec3(0.0); t.emission = vec3(0.0);
    vec3 N = inputData.normalWS;
    vec3 V = inputData.viewDirectionWS;
    float NoV = saturate(dot(N, V)) + 1e-5;

    // ---- 环境 BRDF 与能量补偿:只和 NoV、粗糙度有关,先算好给直接光也用 ----
    vec2 dfg = MyEnvDFG(NoV, m.perceptualRoughness);
    m.energyCompensation = mix(vec3(1.0), MyEnergyCompensation(dfg, m.f0), _EnergyCompensation);

    // ---- 遮蔽:材质 AO 与 SSAO 取最小值(URP 的约定)----
    AmbientOcclusionFactor ao = GetScreenSpaceAmbientOcclusion(inputData.normalizedScreenSpaceUV);
    ao.indirectAmbientOcclusion = min(ao.indirectAmbientOcclusion, s.occlusion);
    vec4 shadowMask = inputData.shadowMask;

    // ---- 主光 ----
    Light mainLight = MyGetMainLight(inputData, shadowMask, ao);   // 已含阴影、SSAO 直接光部分
    // (HLSL 版这里还有 MixRealtimeAndBakedGI 与 Light Layers 判断;网页版没有光照贴图和灯光层)
    MyAccumulateLight(m, N, V, mainLight, t);

    // ---- 附加光:Forward 逐物体列表 / Forward+ 全部可见光 ----
#if defined(_ADDITIONAL_LIGHTS)
    // HLSL 版用 LIGHT_LOOP_BEGIN/END(Forward+ 下展开成分簇迭代器);
    // 网页版的 GetAdditionalLightsCount() 已经按渲染路径返回本像素要算的灯数,普通 for 循环即可
    int lightCount = GetAdditionalLightsCount();
    for (int lightIndex = 0; lightIndex < lightCount; lightIndex++)
    {
        Light light = MyGetAdditionalLight(lightIndex, inputData, shadowMask, ao);
        MyAccumulateLight(m, N, V, light, t);
    }
#endif

#if defined(_ADDITIONAL_LIGHTS_VERTEX)
    // 逐顶点附加光只有漫反射:顶点里累加的是 color * atten * NoL,等价于 E/π
    t.directDiffuse += inputData.vertexLighting * m.diffuseColor;
#endif

    // ---- 间接漫反射:SH(或光照贴图)----
    // Unity 的 SH / 光照贴图里存的是 E/π(“已经除过 π 的辐照度”),直接乘漫反射色即可
    t.indirectDiffuse = inputData.bakedGI * m.diffuseColor * ao.indirectAmbientOcclusion;

    // ---- 间接镜面:split-sum = 预滤波环境 × 环境 BRDF ----
    vec3 R = reflect(-V, N);
    vec3 prefiltered = MySamplePrefilteredEnv(R, m.perceptualRoughness, inputData.positionWS, inputData.normalizedScreenSpaceUV);
    vec3 envBRDF = m.f0 * dfg.x + dfg.y;     // F90 = 1
    float specOcc = mix(ao.indirectAmbientOcclusion,
                        MySpecularOcclusion(NoV, ao.indirectAmbientOcclusion, m.perceptualRoughness),
                        _SpecularOcclusion);
    t.indirectSpecular = prefiltered * envBRDF * m.energyCompensation * specOcc;

    t.emission = s.emission;
    return t;
}

附加光部分是这一节的重点。LIGHT_LOOP_BEGIN 在两种路径下展开成完全不同的代码(摘自 URP 14 ShaderLibrary/RealtimeLights.hlsl):

#if USE_FORWARD_PLUS
    #define LIGHT_LOOP_BEGIN(lightCount) { \
    uint lightIndex; \
    ClusterIterator _urp_internal_clusterIterator = ClusterInit(inputData.normalizedScreenSpaceUV, inputData.positionWS, 0); \
    [loop] while (ClusterNext(_urp_internal_clusterIterator, lightIndex)) { \
        lightIndex += URP_FP_DIRECTIONAL_LIGHTS_COUNT; \
        FORWARD_PLUS_SUBTRACTIVE_LIGHT_CHECK
#elif !_USE_WEBGL1_LIGHTS
    #define LIGHT_LOOP_BEGIN(lightCount) \
    for (uint lightIndex = 0u; lightIndex < lightCount; ++lightIndex) {
Forward:每个物体一张灯光表 地板(一个物体) 最多 N 盏(默认 4) 灰色的灯超出每物体上限,被整片丢掉 Forward+:屏幕分块 × 深度分段 2 盏 每个像素只遍历自己所在格子里的灯,没有每物体上限
图 11-6两种路径的附加光组织方式。Forward 下灯光表按“物体”分配,Forward+ 按“屏幕格子 + 深度段(Z-Bin)”分配。

Forward 下,URP 在 CPU 端给每个物体挑出影响它的灯(按重要性排序,数量受 URP Asset 的 Lighting › Additional Lights › Per Object Limit 限制),GetAdditionalLightsCount() 返回这个数量,GetAdditionalLight(i, …) 内部再把循环序号 i 换成全局灯光索引。Forward+ 下,GetAdditionalLightsCount() 直接返回 0(宏忽略这个参数),循环靠分簇迭代器驱动;但除主光之外的方向光不参与分簇,它们排在附加光数组最前面,数量是 URP_FP_DIRECTIONAL_LIGHTS_COUNT,必须像代码里那样在 LIGHT_LOOP 之前单独循环一遍——漏掉这段,第二盏方向光在 Forward+ 下就不亮。这段写法与 URP 的 UniversalFragmentPBR 完全对应。

阴影方面,主光阴影来自 GetMainLight(inputData, shadowMask, ao),附加光阴影来自 GetAdditionalLight(i, inputData, shadowMask, ao),二者内部根据 _MAIN_LIGHT_SHADOWS*、_ADDITIONAL_LIGHT_SHADOWS、_SHADOWS_SOFT* 关键字选择采样方式;shadowMask 用于 Shadowmask 光照模式下烘焙阴影与实时阴影的混合。我们需要做的只是:声明好关键字、给出 ShadowCaster Pass(案例 11.9)、用 light.shadowAttenuation 乘到光照里。

图 11-7同一个 MyPBR 场景、8 盏彩色点光源。左:Forward,每物体上限 4(URP 默认值),整块地板只收到 4 盏灯;中:Forward,上限改为 8,灯光完整;右:Forward+,没有每物体上限,结果与中图一致。

这张对比图揭示了 Forward 路径一个常被忽视的问题:一个大物体(地板、地形、大墙面)上的灯数受“每物体上限”约束,超出的灯不会逐渐变暗,而是整盏消失,移动相机或物体时还可能跳变。Forward+ 没有这个限制,所以本书的配图默认使用 Forward+。如果项目必须用 Forward(例如要支持不支持 Forward+ 的老设备),就要把大物体切块,或者提高上限。

11.4 IBL:split-sum 的两半

直接光只是“几个方向上的光”;真实环境中,光从四面八方来。对整个半球积分环境光,就是基于图像的光照(Image-Based Lighting,IBL)。漫反射和镜面反射的处理方式完全不同,原因在于 BRDF 的形状:Lambert 是常数,积分只和法线有关,可以预先算好存起来;镜面 BRDF 依赖视线、法线、粗糙度、F0,一个四维以上的函数,没法直接预计算。

漫反射:球谐

对 Lambert,出射辐射率只是“环境光的余弦加权平均 × 反照率”。这个余弦加权平均(辐照度)是法线方向的光滑函数,用 L2 球谐(9 个系数 × RGB)就能很好地近似。URP 把环境光(天空盒或渐变/纯色设置)和光照探针都投影成 SH,存在 unity_SHAr 等 7 个 float4 里,SampleSH(normalWS) 负责求值(第 8 章讲过它的原理)。我们这里要关心的只有一件事:Unity 的 SH 求值结果是 E/π,也就是已经除过 π 的辐照度。所以间接漫反射直接写成 bakedGI * diffuseColor,不再除以 π。光照贴图同理,SAMPLE_GI 宏在有光照贴图时读贴图、否则读 SH,返回值的约定相同。

SH 在哪里求值由 URP Asset 的设置决定,对应关键字 EVALUATE_SH_MIXED(L2 项逐顶点、L0/L1 逐像素)和 EVALUATE_SH_VERTEX(全部逐顶点)。我们用 URP 的 OUTPUT_SH + SAMPLE_GI 组合,三种模式都自动适配。

镜面:split-sum 近似

镜面 IBL 要算的是:

Lspec=∫ΩLi(l)fs(l,v)(n·l)dl≈∑Li(lk)(n·lk)∑(n·lk)⏟预滤波环境图·∫Ωfs(n·l)dl⏟环境 BRDF(DFG)

这就是 Karis 在 2013 年 SIGGRAPH 课程中提出的 split-sum 近似:把“环境光 × BRDF”的积分拆成两个积分的乘积。左半边只和环境图、反射方向、粗糙度有关(假设 n=v=r),可以对每个粗糙度把 cubemap 用 GGX 波瓣卷积一遍,存进 mip 链;右半边只和 NoV、粗糙度、F0 有关,而 F0 可以提出来,于是剩下一个二维函数,可以做成查找表,也可以用解析式拟合。

左半:预滤波 cubemap 的 mip 链 mip 0镜面 mip 6最粗糙 mip = r(1.7 − 0.7r) × 6 × 右半:DFG 查找表 NoV r R = A(乘 F0),G = B(乘 F90) 镜面 IBL = 预滤波颜色 × (F0·A + B)
图 11-8split-sum 的两半。左半按粗糙度在 mip 链中查找,右半按 (NoV, 粗糙度) 查二维表或用解析式计算。

案例 11.5预滤波环境图与 mip 选择进阶单独打开 ↗

Assets/Cookbook/Ch11/MyPBR_Lighting.hlsl(节选:env_sample)
// 取预滤波环境图(split-sum 的第一项)
float3 MySamplePrefilteredEnv(float3 R, float perceptualRoughness, float3 positionWS, float2 screenUV)
{
#if USE_FORWARD_PLUS || defined(_REFLECTION_PROBE_BLENDING)
    // Forward+ 把所有反射探针打包进一张八面体图集,天空反射走 _GlossyEnvironmentCubeMap;
    // 探针混合也有一套权重逻辑。这些都是“取样”,与 BRDF 无关,直接复用 URP 的取样函数
    // (它内部同样用感知粗糙度选 mip,映射公式与 MyRoughnessToMip 相同)。
    return GlossyEnvironmentReflection(R, positionWS, perceptualRoughness, 1.0h, screenUV);
#else
    #if defined(_REFLECTION_PROBE_BOX_PROJECTION)
    R = BoxProjectedCubemapDirection(R, positionWS, unity_SpecCube0_ProbePosition,
                                     unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax);
    #endif
    float mip = MyRoughnessToMip(perceptualRoughness);
    half4 encoded = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, R, mip);
    return DecodeHDREnvironment(encoded, unity_SpecCube0_HDR);   // 解 RGBM / HDR 编码
#endif
}
// 取预滤波环境图(split-sum 的第一项)
// HLSL 版在 Forward+ / 探针混合时改用 URP 的 GlossyEnvironmentReflection(反射探针图集);
// 网页版只有一个反射立方体图 unity_SpecCube0,两条路径结果相同,这里只保留手写的那条。
vec3 MySamplePrefilteredEnv(vec3 R, float perceptualRoughness, vec3 positionWS, vec2 screenUV)
{
    float mip = MyRoughnessToMip(perceptualRoughness);
    vec4 encoded = textureLod(unity_SpecCube0, R, mip);
    return DecodeHDREnvironment(encoded, unity_SpecCube0_HDR);   // 解 RGBM / HDR 编码
}

Unity 在烘焙反射探针、或者从天空盒生成默认反射时,会对 cubemap 做模拟镜面波瓣、按 mip 级递增模糊的卷积(具体滤波核以引擎实现为准),结果存在 mip 链里(core 包把最后一级的序号定义为 UNITY_SPECCUBE_LOD_STEPS = 6)。我们要做的就是把感知粗糙度映射到 mip 级:

mip=r(1.7−0.7r)×6

为什么不是线性的 r×6?因为 GGX 波瓣的张角与粗糙度不是线性关系:低粗糙度区间里,波瓣宽度变化很快,需要更多的 mip 级来区分;高粗糙度时波瓣已经很宽,几级 mip 差别不大。r(1.7−0.7r) 是一条在 r=0 时斜率为 1.7、r=1 时恰好为 1 的抛物线,把更多的 mip 分配给光滑端。这个映射与 URP 的 PerceptualRoughnessToMipmapLevel 相同,我们自己写一遍是为了看清它,同时保证和 URP Lit 采样同一级 mip,便于对比。

#if 的第一个分支是本章唯一“复用 URP 取样逻辑”的地方,值得解释为什么:Forward+ 下 URP 不再给每个物体绑定 unity_SpecCube0/1,而是把场景里所有反射探针的各级 mip 打包进一张八面体映射的二维图集,再加上分簇查找和按体积/重要性的权重混合;剩余权重落到天空反射 _GlossyEnvironmentCubeMap。开启 Reflection Probe Blending 时 Forward 路径也有一套双探针权重逻辑。这些都是“去哪里取环境光”的问题,和 BRDF 无关,自己重写只会制造与 URP 不一致的风险。第二个分支是传统 Forward 的单探针路径,我们手写了 mip 选择、盒体投影和 HDR 解码,作为理解 split-sum 左半的最小实现。

深入严格来说,split-sum 左半的预滤波应该按 GGX 波瓣做重要性采样卷积,并且卷积时还要乘 NoL 作权重(这正是公式里分子分母都有 n·l 的原因)。本书配图脚本里的程序化天空 cubemap 只做了普通的盒式降采样 mip,并非严格卷积,所以配图中粗糙球的反射模糊程度只具有示意意义。项目中请使用烘焙或实时更新的反射探针。

案例 11.6环境 BRDF:解析近似与程序生成的 DFG LUT高阶单独打开 ↗

目标:实现 split-sum 的右半,给出两种方案,并量化它们的差异。

把 Schlick 菲涅尔代入右半积分,F0 可以提到积分外面:

∫Ωfs(n·l)dl=F0∫fsF(1−Fc)(n·l)dl⏟A+F90∫fsFFc(n·l)dl⏟B,Fc=(1−v·h)5

A、B 只是 NoV 和粗糙度的函数。方案一是 Karis 2014 年在《Physically Based Shading on Mobile》中给出的解析拟合,十几条 ALU 指令,不需要贴图;方案二是自己把积分算出来,存成一张 64×64 的 RG 贴图。

Assets/Cookbook/Ch11/MyPBR_BRDF.hlsl(节选:env_brdf)
// 环境 BRDF(split-sum 的第二项):∫ f_s cosθ dω = F0 * A + F90 * B
// 解析近似:Karis 2014《Physically Based Shading on Mobile》中的拟合,输入感知粗糙度
float2 MyEnvDFGApprox(float NoV, float perceptualRoughness)
{
    const float4 c0 = float4(-1.0, -0.0275, -0.572, 0.022);
    const float4 c1 = float4( 1.0,  0.0425,  1.040, -0.040);
    float4 r = perceptualRoughness * c0 + c1;
    float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
    return float2(-1.04, 1.04) * a004 + r.zw;          // x = A(乘 F0),y = B(乘 F90)
}

// 程序生成的 LUT:u = NoV,v = 感知粗糙度;生成代码见 DFGLut.cs
float2 MyEnvDFG(float NoV, float perceptualRoughness)
{
#if defined(_DFG_LUT)
    return SAMPLE_TEXTURE2D_LOD(_DFGLut, sampler_LinearClamp, float2(NoV, perceptualRoughness), 0).rg;
#else
    return MyEnvDFGApprox(NoV, perceptualRoughness);
#endif
}

// 多次散射能量补偿(Kulla-Conty 思路的廉价版本,Fdez-Agüero 2019 / Filament 的做法):
// 单次散射的方向反照率 Ess = A + B(取 F0 = 1),丢掉的能量是 1 - Ess。
// 把镜面整体放大 1 + F0 (1/Ess - 1),粗糙金属不再“越粗越暗”。
float3 MyEnergyCompensation(float2 dfg, float3 f0)
{
    float Ess = max(dfg.x + dfg.y, 1e-4);
    return 1.0 + f0 * (1.0 / Ess - 1.0);
}

// 镜面遮蔽:由 AO 推一个对镜面也合理的遮蔽量(Lagarde & de Rousiers 2014 的经验公式)
float MySpecularOcclusion(float NoV, float ao, float perceptualRoughness)
{
    float a = perceptualRoughness * perceptualRoughness;
    return saturate(pow(abs(NoV + ao), exp2(-16.0 * a - 1.0)) - 1.0 + ao);
}

// 感知粗糙度 -> 预滤波 cubemap 的 mip 级。UNITY_SPECCUBE_LOD_STEPS = 6(最后一级的序号)。
// 非线性映射让低粗糙度区间分到更多 mip 级(与 URP 的取法一致,便于对照)。
float MyRoughnessToMip(float perceptualRoughness)
{
    return perceptualRoughness * (1.7 - 0.7 * perceptualRoughness) * UNITY_SPECCUBE_LOD_STEPS;
}
// 环境 BRDF(split-sum 的第二项):∫ f_s cosθ dω = F0 * A + F90 * B
// 解析近似:Karis 2014《Physically Based Shading on Mobile》中的拟合,输入感知粗糙度
vec2 MyEnvDFGApprox(float NoV, float perceptualRoughness)
{
    const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022);
    const vec4 c1 = vec4( 1.0,  0.0425,  1.040, -0.040);
    vec4 r = perceptualRoughness * c0 + c1;
    float a004 = min(r.x * r.x, exp2(-9.28 * NoV)) * r.x + r.y;
    return vec2(-1.04, 1.04) * a004 + r.zw;          // x = A(乘 F0),y = B(乘 F90)
}

// 程序生成的 LUT:u = NoV,v = 感知粗糙度;生成代码见 DFGLut.cs
vec2 MyEnvDFG(float NoV, float perceptualRoughness)
{
#if defined(_DFG_LUT)
    return textureLod(_DFGLut, vec2(NoV, perceptualRoughness), 0.0).rg;
#else
    return MyEnvDFGApprox(NoV, perceptualRoughness);
#endif
}

// 多次散射能量补偿(Kulla-Conty 思路的廉价版本,Fdez-Agüero 2019 / Filament 的做法):
// 单次散射的方向反照率 Ess = A + B(取 F0 = 1),丢掉的能量是 1 - Ess。
// 把镜面整体放大 1 + F0 (1/Ess - 1),粗糙金属不再“越粗越暗”。
vec3 MyEnergyCompensation(vec2 dfg, vec3 f0)
{
    float Ess = max(dfg.x + dfg.y, 1e-4);
    return 1.0 + f0 * (1.0 / Ess - 1.0);
}

// 镜面遮蔽:由 AO 推一个对镜面也合理的遮蔽量(Lagarde & de Rousiers 2014 的经验公式)
float MySpecularOcclusion(float NoV, float ao, float perceptualRoughness)
{
    float a = perceptualRoughness * perceptualRoughness;
    return saturate(pow(abs(NoV + ao), exp2(-16.0 * a - 1.0)) - 1.0 + ao);
}

// 感知粗糙度 -> 预滤波 cubemap 的 mip 级。UNITY_SPECCUBE_LOD_STEPS = 6(最后一级的序号)。
// 非线性映射让低粗糙度区间分到更多 mip 级(与 URP 的取法一致,便于对照)。
float MyRoughnessToMip(float perceptualRoughness)
{
    return perceptualRoughness * (1.7 - 0.7 * perceptualRoughness) * UNITY_SPECCUBE_LOD_STEPS;
}

LUT 的生成用 C# 在 CPU 上完成,算法是标准的 GGX 重要性采样:

Assets/Cookbook/Ch11/DFGLut.cs(节选:generate)
public static Texture2D Generate(int size = 64, int sampleCount = 512)
{
    var tex = new Texture2D(size, size, TextureFormat.RGBAHalf, false, true)   // 线性、无 mip
    {
        name = "MyPBR_DFG_LUT",
        wrapMode = TextureWrapMode.Clamp,
        filterMode = FilterMode.Bilinear
    };
    var px = new Color[size * size];
    for (int y = 0; y < size; y++)
    {
        float perceptualRoughness = (y + 0.5f) / size;   // 取像素中心,双线性采样时才对得上
        float a = perceptualRoughness * perceptualRoughness;
        for (int x = 0; x < size; x++)
        {
            float NoV = (x + 0.5f) / size;
            Vector2 ab = Integrate(NoV, a, sampleCount);
            px[y * size + x] = new Color(ab.x, ab.y, 0, 1);
        }
    }
    tex.SetPixels(px);
    tex.Apply(false, false);
    return tex;
}

static Vector2 Integrate(float NoV, float a, int sampleCount)
{
    // 切线空间里 N = (0,0,1),V 放在 xz 平面
    var V = new Vector3(Mathf.Sqrt(1f - NoV * NoV), 0f, NoV);
    float a2 = a * a;
    float A = 0, B = 0;
    for (int i = 0; i < sampleCount; i++)
    {
        Vector2 xi = Hammersley(i, sampleCount);
        Vector3 H = ImportanceSampleGGX(xi, a2);
        Vector3 L = 2f * Vector3.Dot(V, H) * H - V;
        float NoL = Mathf.Clamp01(L.z);
        float NoH = Mathf.Clamp01(H.z);
        float VoH = Mathf.Clamp01(Vector3.Dot(V, H));
        if (NoL <= 0f) continue;

        // pdf(L) = D · NoH / (4 VoH);被积函数 f_s·NoL = D·V·F·NoL
        // 两者相除 D 消掉:F · V · 4 · NoL · VoH / NoH
        float vis = SmithGGXCorrelated(NoV, NoL, a2);
        float gv = vis * 4f * NoL * VoH / Mathf.Max(NoH, 1e-6f);
        float fc = Mathf.Pow(1f - VoH, 5f);
        A += (1f - fc) * gv;
        B += fc * gv;
    }
    return new Vector2(A / sampleCount, B / sampleCount);
}

// 与 HLSL 的 MyV_SmithGGXCorrelated 逐字对应
static float SmithGGXCorrelated(float NoV, float NoL, float a2)
{
    float lv = NoL * Mathf.Sqrt((NoV - NoV * a2) * NoV + a2);
    float ll = NoV * Mathf.Sqrt((NoL - NoL * a2) * NoL + a2);
    return 0.5f / Mathf.Max(lv + ll, 1e-5f);
}

// GGX 重要性采样:按 D(h)·NoH 的分布抽半角向量
static Vector3 ImportanceSampleGGX(Vector2 xi, float a2)
{
    float phi = 2f * Mathf.PI * xi.x;
    float cosTheta = Mathf.Sqrt((1f - xi.y) / (1f + (a2 - 1f) * xi.y));
    float sinTheta = Mathf.Sqrt(1f - cosTheta * cosTheta);
    return new Vector3(sinTheta * Mathf.Cos(phi), sinTheta * Mathf.Sin(phi), cosTheta);
}

// 低差异序列:比纯随机数收敛快得多,512 个样本就很平滑
static Vector2 Hammersley(int i, int n)
{
    uint bits = (uint)i;
    bits = (bits << 16) | (bits >> 16);
    bits = ((bits & 0x55555555u) << 1) | ((bits & 0xAAAAAAAAu) >> 1);
    bits = ((bits & 0x33333333u) << 2) | ((bits & 0xCCCCCCCCu) >> 2);
    bits = ((bits & 0x0F0F0F0Fu) << 4) | ((bits & 0xF0F0F0F0u) >> 4);
    bits = ((bits & 0x00FF00FFu) << 8) | ((bits & 0xFF00FF00u) >> 8);
    return new Vector2((float)i / n, bits * 2.3283064365386963e-10f);
}

几个要点。第一,被积函数里的 D 被重要性采样的概率密度消掉了:按 D(h)(n·h) 抽取半角向量,换算到入射方向的概率密度是 D(n·h)/(4v·h),被积函数除以它,剩下 V·4(n·l)(v·h)/(n·h)。第二,可见性项用的是与直接光完全相同的 SmithGGXCorrelated,这样直接光和环境光的“镜面模型”是同一个。第三,用 Hammersley 低差异序列代替随机数,512 个样本就足够平滑。第四,采样坐标取像素中心 (i + 0.5) / size,与 GPU 双线性采样的约定一致。菜单 Cookbook › Ch11 › Bake DFG LUT(Editor/DFGLutBaker.cs)会把它存成资产,拖进材质的 DFG LUT 槽并勾选 Use DFG LUT 即可。

图 11-9DFG 表的可视化(横轴 NoV 从 0 到 1,纵轴感知粗糙度从下 0 到上 1;红色 = A,绿色 = B)。左:程序生成的 LUT(高度相关 Smith);中:Karis 解析近似;右:两者之差 ×4,暗线是二者恰好相等的位置。

两张表大体形状一致:光滑且正视时 A≈1、B≈0(几乎全部由 F0 决定);掠射时 B 变大(菲涅尔把反射推向白色);粗糙时整体变暗。但差异图告诉我们,两者并不相同。配图脚本把逐像素误差统计打印到了日志里:在 64×64 的网格上,A 的最大差约 0.46、平均差约 0.11,B 的最大差约 0.09、平均差约 0.01,最大差出现在“高粗糙度 + 掠射角”一角。原因不是拟合不准那么简单——按 Karis 的课程笔记,这个拟合的对象是 UE4 当年的 LUT,那张表用的是可分离的 Schlick-Smith 几何项(环境光取 k = α/2);我们的表用的是高度相关 Smith,遮挡估计更少,粗糙表面在掠射时保留了更多能量。两种模型都“对”,但直接光和环境光最好用同一个模型,否则同一个材质在灯下和在环境里会显得不像同一种东西。所以 MyPBR 默认在配图中启用 LUT,解析近似留给不想多一张贴图、多一次采样的移动端。

提示项目中可以只生成一张 LUT,用 Shader.SetGlobalTexture 设成全局贴图,并把 _DFGLut 从 Properties 里删掉(材质属性会覆盖同名全局变量)。这样不用每个材质都拖一次。本章为了让材质自包含、便于对比两种方案,保留了材质属性的写法。

案例 11.7多次散射能量补偿高阶单独打开 ↗

目标:让粗糙金属不再“越粗糙越暗”,并用白炉测试验证。

Cook-Torrance 模型只考虑了光在微表面上反射一次就离开的情况。被 G2 判为“被挡住”的光,在现实中并没有消失,而是撞到相邻的微平面上再次反射,其中相当一部分最终也离开了表面。单次散射模型把这部分能量直接丢掉了,表面越粗糙、遮挡越多,丢得越多。

✕ 单次散射:撞墙即丢弃 Cook-Torrance(单次散射) 第二次反射后离开 真实表面(多次散射)
图 11-10单次散射模型丢掉了在微表面之间多次反弹后离开的能量,粗糙表面因此偏暗。

Kulla 与 Conty(SIGGRAPH 2017)给出了完整的补偿方法:用一个额外的 BRDF 项 fms 把丢失的 1−Ess(μ) 加回来,其中 Ess 是单次散射的方向反照率。实时渲染里常用它的一个廉价变体(Fdez-Agüero 2019,Filament 采用):注意到 F0 = 1 时环境 BRDF 恰好就是 Ess=A+B,DFG 表里已经有了,于是把整个镜面项放大:

fs′=fs·(1+F0(1Ess−1))

对白色金属(F0 = 1),放大系数是 1/Ess,环境镜面恰好补回到 1;对电介质(F0 = 0.04)放大量很小,因为电介质的镜面本来就弱,丢失的能量大部分被菲涅尔“吸收”了。代码里这个系数在 MyComputeLighting 开头算一次,存进 m.energyCompensation,直接光和环境光的镜面都乘上它。_EnergyCompensation 滑条在 0 和 1 之间插值,方便对比。

图 11-11白炉测试(White Furnace Test):背景、球谐、反射全部是同一个均匀亮度,关闭主光。上排白色金属(F0 = 1),下排金色金属,光滑度从左 1.0 到右 0.0。左:只有单次散射,越粗糙越暗;右:开启能量补偿,白色金属完全“消失”在背景里,金色金属在粗糙端更饱和。

白炉测试是检验 BRDF 能量守恒的标准方法:在一个各方向亮度相同的环境里,一个不吸收任何能量的材质应该与背景无法区分。左图上排从左数第二个球开始就能看到轮廓,越往右越暗,这就是单次散射丢失的能量;右图补偿后上排完全隐形。下排的现象同样值得注意:彩色金属多次反弹时每次都乘一次 F0,颜色会越来越饱和——这正是 Kulla-Conty 指出的“粗糙彩色金属应该更饱和”,补偿项里的 F0 因子模拟了这一点。

注意在 IBL 上,补偿系数与 DFG 表来自同一个 Ess,白球“完全消失”在数学上是必然的,因此白炉测试在这里检验的是“补偿实现有没有写错”(例如乘错位置、漏乘 F0)。真正的物理正确性取决于 DFG 表本身是否准确,这一点由 LUT 的生成代码保证。

案例 11.8法线贴图、Mask Map 与自发光入门单独打开 ↗

目标:把表面细节接进来,看每一张贴图贡献了什么。

三张贴图的读取都已在 MySampleSurface 里完成,法线变换在 MyInitInputData 里完成:用插值后的法线、切线和 tangentWS.w 重建副切线,构成 TBN 矩阵,把切线空间法线转到世界空间后重新归一化。tangentWS.w 携带的是“副切线方向符号”,它来自模型导入时的切线计算(镜像 UV 时为 −1),顶点函数里还乘了 GetOddNegativeScale(),处理物体被负缩放镜像的情况。这些和第 7 章的内容一致。

自发光直接加到最终颜色上,不参与任何 BRDF 计算;HDR 颜色属性允许强度大于 1,配合 Bloom 产生光晕。Meta Pass(案例 11.9)会把自发光交给烘焙器,让它照亮周围的静态物体。

图 11-12逐项加入贴图(MyPBR + 配套 paintedmetal 贴图)。从左到右:只有 Base Map(统一光滑度 0.55,非金属);+ 法线贴图(漆皮剥落边缘出现台阶);+ Mask Map(剥落处变成金属、光滑度与 AO 随贴图变化);+ 自发光(程序生成的面板缝与指示灯)。

第三格的变化最大:剥落处从“灰白色塑料”变成了反射环境的金属,漆面保持塑料感。这正是金属度工作流的意义——同一张基础色贴图,金属度告诉 Shader “这里的颜色该放进漫反射还是放进 F0”。c13 会专门讨论这三张数据贴图怎么做、怎么检查。

案例 11.9其余四个 Pass进阶单独打开 ↗

目标:给 MyPBR 补齐投射阴影、深度预渲染、深度法线与烘焙用的 Pass,让它在 URP 的所有功能里都是“一等公民”。

Assets/Cookbook/Ch11/MyPBR.shader(节选:pass_shadow)
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On
    ZTest LEqual
    ColorMask 0
    Cull Back

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex MyShadowVert
    #pragma fragment MyShadowFrag
    #pragma shader_feature_local _ALPHATEST_ON
    #pragma multi_compile_instancing
    // 方向光与点/聚光的法线偏移公式不同,URP 用这个关键字区分
    #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW
    #include "Assets/Cookbook/Ch11/MyPBR_DepthPasses.hlsl"
    ENDHLSL
}
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    ZWrite On
    ZTest LEqual
    ColorMask 0
    Cull Back

    GLSLPROGRAM
    #pragma shader_feature_local _ALPHATEST_ON
    // 方向光与点/聚光的法线偏移公式不同,URP 用 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字区分(网页版是 uniform)
    #include "Ch11/MyPBR_DepthPasses.glsl"
    #ifdef VERTEX
    void main() { MyShadowVert(); }
    #endif
    #ifdef FRAGMENT
    void main() { fragColor = MyShadowFrag(); }
    #endif
    ENDGLSL
}
Assets/Cookbook/Ch11/MyPBR_DepthPasses.hlsl(节选:shadow_caster)
// URP 在渲染阴影图时设置这两个变量:方向光用 _LightDirection,点/聚光用 _LightPosition
float3 _LightDirection;
float3 _LightPosition;

DepthVaryings MyShadowVert(DepthAttributes input)
{
    DepthVaryings o = (DepthVaryings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, o);

    float3 positionWS = TransformObjectToWorld(input.positionOS.xyz);
    float3 normalWS   = TransformObjectToWorldNormal(input.normalOS);
#if defined(_CASTING_PUNCTUAL_LIGHT_SHADOW)
    float3 lightDirWS = normalize(_LightPosition - positionWS);
#else
    float3 lightDirWS = _LightDirection;
#endif
    // 深度偏移 + 法线偏移(数值来自灯光的 Bias 设置),对抗 Shadow Acne
    float4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS));
    // 把被近裁剪面切掉的投射体压到近平面上(Shadow Pancaking),否则阴影会出洞
#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#endif
    o.positionCS = positionCS;
    o.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);
    return o;
}

half4 MyShadowFrag(DepthVaryings i) : SV_Target
{
    UNITY_SETUP_INSTANCE_ID(i);
    MyAlphaClip(i.uv);
    return 0;
}
// URP 在渲染阴影图时设置这两个变量:方向光用 _LightDirection,点/聚光用 _LightPosition
// (网页版核心库 Lighting.glsl 已声明 _LightDirection、_LightPosition、_CastingPunctualLightShadow)
#ifdef VERTEX
void MyShadowVert()
{
    vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
    vec3 nWS        = TransformObjectToWorldNormal(normalOS);
    // HLSL 里是 _CASTING_PUNCTUAL_LIGHT_SHADOW 关键字;网页版是 uniform
    vec3 lightDirWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection;
    // 深度偏移 + 法线偏移(数值来自灯光的 Bias 设置),对抗 Shadow Acne
    vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, nWS, lightDirWS));
    // 把被近裁剪面切掉的投射体压到近平面上(Shadow Pancaking),否则阴影会出洞
#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w);   // GL 的近平面在 z = -w
#endif
    gl_Position = positionCS;
    uv = TransformTex(texcoord0.xy, _BaseMap_ST);
}
#endif

#ifdef FRAGMENT
vec4 MyShadowFrag()
{
    MyAlphaClip(uv);
    return vec4(0.0);
}
#endif

ShadowCaster 的核心是 ApplyShadowBias:沿光线方向推远一点(Depth Bias)、沿法线方向往里缩一点(Normal Bias),两者的数值来自灯光组件的 Bias 设置,由 URP 通过 _ShadowBias 传入。_LightDirection 和 _LightPosition 不在任何 URP 头文件里声明,而是 ShadowCasterPass.hlsl 自己声明、由 C# 端 ShadowUtils.SetupShadowCasterConstantBuffer 设置的普通全局变量,所以我们也要自己声明。点光和聚光的光线方向在每个顶点都不同,所以用 _CASTING_PUNCTUAL_LIGHT_SHADOW 区分。最后把被近裁剪面切掉的顶点压到近平面上(Shadow Pancaking):方向光阴影的正交投影只覆盖级联范围附近的一段深度,位于近平面之前的投射体如果被直接裁掉,就会在阴影里留下洞。

Assets/Cookbook/Ch11/MyPBR.shader(节选:pass_depth)
Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On
    ColorMask R
    Cull Back

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex MyDepthVert
    #pragma fragment MyDepthFrag
    #pragma shader_feature_local _ALPHATEST_ON
    #pragma multi_compile_instancing
    #include "Assets/Cookbook/Ch11/MyPBR_DepthPasses.hlsl"
    ENDHLSL
}

Pass
{
    Name "DepthNormals"
    Tags { "LightMode"="DepthNormals" }
    ZWrite On
    Cull Back

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex MyDepthNormalsVert
    #pragma fragment MyDepthNormalsFrag
    #pragma shader_feature_local _NORMALMAP
    #pragma shader_feature_local _ALPHATEST_ON
    #pragma multi_compile_instancing
    #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl"
    #include "Assets/Cookbook/Ch11/MyPBR_DepthPasses.hlsl"
    ENDHLSL
}
Pass
{
    Name "DepthOnly"
    Tags { "LightMode"="DepthOnly" }
    ZWrite On
    ColorMask R
    Cull Back

    GLSLPROGRAM
    #pragma shader_feature_local _ALPHATEST_ON
    #include "Ch11/MyPBR_DepthPasses.glsl"
    #ifdef VERTEX
    void main() { MyDepthVert(); }
    #endif
    #ifdef FRAGMENT
    void main() { fragColor = MyDepthFrag(); }
    #endif
    ENDGLSL
}

Pass
{
    Name "DepthNormals"
    Tags { "LightMode"="DepthNormals" }
    ZWrite On
    Cull Back

    GLSLPROGRAM
    #pragma shader_feature_local _NORMALMAP
    #pragma shader_feature_local _ALPHATEST_ON
    #include "Ch11/MyPBR_DepthPasses.glsl"
    #ifdef VERTEX
    void main() { MyDepthNormalsVert(); }
    #endif
    #ifdef FRAGMENT
    void main() { fragColor = MyDepthNormalsFrag(); }
    #endif
    ENDGLSL
}
Assets/Cookbook/Ch11/MyPBR_DepthPasses.hlsl(节选:depth_normals)
DepthVaryings MyDepthNormalsVert(DepthAttributes input)
{
    DepthVaryings o = (DepthVaryings)0;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    o.positionCS = TransformObjectToHClip(input.positionOS.xyz);
    o.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);
    VertexNormalInputs nrm = GetVertexNormalInputs(input.normalOS, input.tangentOS);
    o.normalWS = nrm.normalWS;
#if defined(_NORMALMAP)
    o.tangentWS = half4(nrm.tangentWS, input.tangentOS.w * GetOddNegativeScale());
#endif
    return o;
}

void MyDepthNormalsFrag(DepthVaryings i
    , out half4 outNormalWS : SV_Target0
#if defined(_WRITE_RENDERING_LAYERS)
    , out float4 outRenderingLayers : SV_Target1
#endif
    )
{
    UNITY_SETUP_INSTANCE_ID(i);
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
    MyAlphaClip(i.uv);
#if defined(_NORMALMAP)
    // SSAO、贴花等读 _CameraNormalsTexture,带上法线贴图细节它们才对得上
    half3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, i.uv), _BumpScale);
    float3 bitangent = i.tangentWS.w * cross(i.normalWS, i.tangentWS.xyz);
    float3 normalWS = TransformTangentToWorld(normalTS, half3x3(i.tangentWS.xyz, bitangent, i.normalWS));
#else
    float3 normalWS = i.normalWS;
#endif
    outNormalWS = half4(NormalizeNormalPerPixel(normalWS), 0.0);   // URP 14 存未编码的世界法线
#if defined(_WRITE_RENDERING_LAYERS)
    outRenderingLayers = float4(EncodeMeshRenderingLayer(GetMeshRenderingLayer()), 0, 0, 0);
#endif
}
#ifdef VERTEX
void MyDepthNormalsVert()
{
    gl_Position = TransformObjectToHClip(positionOS.xyz);
    uv = TransformTex(texcoord0.xy, _BaseMap_ST);
    VertexNormalInputs nrm = GetVertexNormalInputs(normalOS, tangentOS);
    normalWS = nrm.normalWS;
#if defined(_NORMALMAP)
    tangentWS = vec4(nrm.tangentWS, tangentOS.w * GetOddNegativeScale());
#endif
}
#endif

#ifdef FRAGMENT
vec4 MyDepthNormalsFrag()
{
    MyAlphaClip(uv);
#if defined(_NORMALMAP)
    // SSAO、贴花等读 _CameraNormalsTexture,带上法线贴图细节它们才对得上
    vec3 normalTS = UnpackNormalScale(texture(_BumpMap, uv), _BumpScale);
    vec3 bitangent = tangentWS.w * cross(normalWS, tangentWS.xyz);
    vec3 n = TransformTangentToWorld(normalTS, mat3(tangentWS.xyz, bitangent, normalWS));
#else
    vec3 n = normalWS;
#endif
    return vec4(NormalizeNormalPerPixel(n), 0.0);   // URP 14 存未编码的世界法线
}
#endif

DepthOnly 用于深度预渲染(Depth Prepass)和生成 _CameraDepthTexture(当 URP 判断不能直接拷贝深度时);DepthNormals 用于生成 _CameraNormalsTexture,SSAO、屏幕空间贴花、某些描边效果都依赖它。如果一个不透明 Shader 缺少 DepthNormals Pass,它在法线图里就是一个“洞”,SSAO 会在它周围产生错误的遮蔽。注意 DepthNormals 里同样要采样法线贴图——SSAO 读到的法线应当和着色用的法线一致,否则砖缝等细节处的遮蔽会和光照对不上。URP 14 Lit 的 DepthNormals Pass 输出的是未编码的世界空间法线(八面体编码 _GBUFFER_NORMALS_OCT 只在 GBuffer Pass 里声明),我们保持一致。

图 11-13MyPBR 场景开启 URP 自带的 SSAO(来源设为 Depth Normals)。左:关闭;右:开启,墙角、方块之间、球与地面接触处出现遮蔽。SSAO 正确作用于全部 MyPBR 物体,说明 DepthNormals Pass 工作正常。

SSAO 的结果通过 _SCREEN_SPACE_OCCLUSION 关键字回到 Forward Pass:CreateAmbientOcclusionFactor 把屏幕空间 AO 和材质 AO 取最小值作为间接光遮蔽,再按 SSAO 设置中的 Direct Lighting Strength 得到直接光遮蔽(GetMainLight(inputData, …, aoFactor) 内部会乘到灯光颜色上)。

Assets/Cookbook/Ch11/MyPBR.shader(节选:pass_meta)
Pass
{
    Name "Meta"
    Tags { "LightMode"="Meta" }
    Cull Off

    HLSLPROGRAM
    #pragma target 2.0
    #pragma vertex MyMetaVert
    #pragma fragment MyMetaFrag
    #pragma shader_feature_local_fragment _MASKMAP
    #pragma shader_feature_local_fragment _EMISSION
    #pragma shader_feature_local_fragment _ALPHATEST_ON
    #pragma shader_feature EDITOR_VISUALIZATION
    #include "Assets/Cookbook/Ch11/MyPBR_MetaPass.hlsl"
    ENDHLSL
}
Pass
{
    Name "Meta"
    Tags { "LightMode"="Meta" }
    Cull Off

    GLSLPROGRAM
    #pragma shader_feature_local_fragment _MASKMAP
    #pragma shader_feature_local_fragment _EMISSION
    #pragma shader_feature_local_fragment _ALPHATEST_ON
    #include "Ch11/MyPBR_MetaPass.glsl"
    #ifdef VERTEX
    void main() { MyMetaVert(); }
    #endif
    #ifdef FRAGMENT
    void main() { fragColor = MyMetaFrag(); }
    #endif
    ENDGLSL
}
Assets/Cookbook/Ch11/MyPBR_MetaPass.hlsl(节选:meta)
MetaVaryings MyMetaVert(MetaAttributes input)
{
    MetaVaryings o = (MetaVaryings)0;
    // 烘焙时 unity_MetaVertexControl 指示用哪套 UV 当“屏幕位置”
    o.positionCS = UnityMetaVertexPosition(input.positionOS.xyz, input.uv1, input.uv2);
    o.uv = TRANSFORM_TEX(input.uv0, _BaseMap);
#if defined(EDITOR_VISUALIZATION)
    UnityEditorVizData(input.positionOS.xyz, input.uv0, input.uv1, input.uv2, o.VizUV, o.LightCoord);
#endif
    return o;
}

half4 MyMetaFrag(MetaVaryings i) : SV_Target
{
    MySurface s = MySampleSurface(i.uv);
    MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);

    UnityMetaInput meta = (UnityMetaInput)0;
    // 反弹光的颜色:漫反射色为主;粗糙金属的镜面反射在烘焙器看来也像“漫反射”,
    // 按粗糙度加一部分 F0 进去(与 URP Lit 的经验做法相同)
    meta.Albedo = m.diffuseColor + m.f0 * (m.perceptualRoughness * m.perceptualRoughness) * 0.5;
    meta.Emission = s.emission;
#if defined(EDITOR_VISUALIZATION)
    meta.VizUV = i.VizUV;
    meta.LightCoord = i.LightCoord;
#endif
    return UnityMetaFragment(meta);
}
#ifdef VERTEX
// UnityMetaVertexPosition:用光照贴图 UV 当“屏幕位置”,把网格摊平到贴图空间
void MyMetaVert()
{
    vec2 p = unity_MetaVertexControl.y > 0.5 ? texcoord2.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw
                                            : texcoord1.xy * unity_LightmapST.xy + unity_LightmapST.zw;
    gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
    uv = TransformTex(texcoord0.xy, _BaseMap_ST);
}
#endif

#ifdef FRAGMENT
vec4 MyMetaFrag()
{
    MySurface s = MySampleSurface(uv);
    MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);

    // 反弹光的颜色:漫反射色为主;粗糙金属的镜面反射在烘焙器看来也像“漫反射”,
    // 按粗糙度加一部分 F0 进去(与 URP Lit 的经验做法相同)
    vec3 albedo = m.diffuseColor + m.f0 * (m.perceptualRoughness * m.perceptualRoughness) * 0.5;
    vec3 emission = s.emission;
    // UnityMetaFragment:按烘焙器的请求输出反照率或自发光
    if (unity_MetaFragmentControl.x > 0.5) return vec4(saturate(albedo), 1.0);
    if (unity_MetaFragmentControl.y > 0.5) return vec4(emission, 1.0);
    return vec4(0.0);
}
#endif

Meta Pass 只在烘焙光照贴图(或在编辑器中显示某些可视化模式)时被调用。烘焙器把物体按 lightmap UV 摊开渲染,每个纹素读两样东西:反照率(决定光线在这里反弹时带走什么颜色)和自发光(作为面光源参与烘焙)。UnityMetaVertexPosition 根据 unity_MetaVertexControl 决定用第二套还是第三套 UV 作为“屏幕位置”,UnityMetaFragment 根据 unity_MetaFragmentControl 决定输出反照率还是自发光,并处理 Lighting 窗口里的 Albedo Boost 等设置。反照率我们用漫反射色加上一部分 F0:完全光滑的金属在烘焙器看来几乎不反弹漫射光,但粗糙金属的镜面波瓣很宽,表现得像漫反射体,按 α/2 混入是 URP Lit 也采用的经验做法。没有 Meta Pass 的物体在烘焙时被当作黑色,既不反弹光,自发光也不起作用。

关于 DeferredURP 的 Deferred 路径只为带 UniversalGBuffer Pass 的材质写 G-Buffer,其余不透明物体只有 LightMode 为 UniversalForwardOnly(或 SRPDefaultUnlit)的 Pass 会在延迟光照之后以前向方式绘制;UniversalForward 在 Deferred 下不会被绘制。MyPBR 的光照模型无法塞进 URP 固定格式的 G-Buffer(G-Buffer 只存材质参数,光照由 URP 的延迟光照 Shader 统一计算,用的是 URP 自己的 BRDF),所以如果项目用 Deferred,请把 ForwardLit Pass 的 LightMode 改成 UniversalForwardOnly——URP 的 Complex Lit 正是这样做的。

案例 11.10调试视图:把光照拆开看入门单独打开 ↗

目标:写一个与 MyPBR 共用全部 HLSL 的调试 Shader,单独输出 BRDF 的每一项、光照的每一部分。

Assets/Cookbook/Ch11/MyPBRDebug.shader(节选:debug_frag)
half4 DebugFrag(Varyings i) : SV_Target
{
    MySurface s  = MySampleSurface(i.uv);
    InputData id = MyInitInputData(i, s.normalTS);
    MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);
    int view = (int)_View;

    // 1~3:只看主光方向上的单项,便于和第 9 章的公式图对照
    if (view >= 1 && view <= 3)
    {
        Light L = GetMainLight();
        float3 N = id.normalWS, V = id.viewDirectionWS, H = SafeNormalize(L.direction + V);
        float NoV = abs(dot(N, V)) + 1e-5, NoL = saturate(dot(N, L.direction));
        float NoH = saturate(dot(N, H)), LoH = saturate(dot(L.direction, H));
        if (view == 1)   // 归一化的 D:除以峰值 1/(π α²),范围 0~1
            return MyD_GGX(NoH, m.a2) * PI * m.a2 * (NoL > 0);
        if (view == 2)   // G2 = V * 4 NoL NoV,范围 0~1
            return MyV_SmithGGXCorrelated(NoV, NoL, m.a2) * 4.0 * NoL * NoV;
        return half4(MyF_Schlick(m.f0, 1.0, LoH) * (NoL > 0), 1);
    }

    MyLightingTerms t = MyComputeLighting(s, m, id);
    float3 c;
    switch (view)
    {
        case 4:  c = t.directSpecular; break;
        case 5:  c = t.directDiffuse; break;
        case 6:  c = t.indirectDiffuse; break;
        case 7:  c = t.indirectSpecular; break;
        case 8:  c = t.directDiffuse + t.directSpecular; break;
        case 9:  c = t.indirectDiffuse + t.indirectSpecular; break;
        case 10: c = MySamplePrefilteredEnv(reflect(-id.viewDirectionWS, id.normalWS), m.perceptualRoughness,
                                            id.positionWS, id.normalizedScreenSpaceUV); break;
        case 11:
        {
            float NoV = saturate(dot(id.normalWS, id.viewDirectionWS));
            float2 dfg = MyEnvDFG(NoV, m.perceptualRoughness);
            c = m.f0 * dfg.x + dfg.y; break;
        }
        default: c = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission; break;
    }
    return half4(c, 1);
}
vec4 DebugFrag()
{
    MySurface s  = MySampleSurface(uv);
    InputData id = MyInitInputData(s.normalTS);
    MyMaterial m = MyInitMaterial(s.albedo, s.metallic, s.perceptualRoughness);
    int view = int(_View);

    // 1~3:只看主光方向上的单项,便于和第 9 章的公式图对照
    if (view >= 1 && view <= 3)
    {
        Light L = GetMainLight();
        vec3 N = id.normalWS, V = id.viewDirectionWS, H = SafeNormalize(L.direction + V);
        float NoV = abs(dot(N, V)) + 1e-5, NoL = saturate(dot(N, L.direction));
        float NoH = saturate(dot(N, H)), LoH = saturate(dot(L.direction, H));
        float lit = NoL > 0.0 ? 1.0 : 0.0;
        if (view == 1)   // 归一化的 D:除以峰值 1/(π α²),范围 0~1
            return vec4(MyD_GGX(NoH, m.a2) * PI * m.a2 * lit);
        if (view == 2)   // G2 = V * 4 NoL NoV,范围 0~1
            return vec4(MyV_SmithGGXCorrelated(NoV, NoL, m.a2) * 4.0 * NoL * NoV);
        return vec4(MyF_Schlick(m.f0, 1.0, LoH) * lit, 1.0);
    }

    MyLightingTerms t = MyComputeLighting(s, m, id);
    vec3 c;
    switch (view)
    {
        case 4:  c = t.directSpecular; break;
        case 5:  c = t.directDiffuse; break;
        case 6:  c = t.indirectDiffuse; break;
        case 7:  c = t.indirectSpecular; break;
        case 8:  c = t.directDiffuse + t.directSpecular; break;
        case 9:  c = t.indirectDiffuse + t.indirectSpecular; break;
        case 10: c = MySamplePrefilteredEnv(reflect(-id.viewDirectionWS, id.normalWS), m.perceptualRoughness,
                                            id.positionWS, id.normalizedScreenSpaceUV); break;
        case 11:
        {
            float NoV = saturate(dot(id.normalWS, id.viewDirectionWS));
            vec2 dfg = MyEnvDFG(NoV, m.perceptualRoughness);
            c = m.f0 * dfg.x + dfg.y; break;
        }
        default: c = t.directDiffuse + t.directSpecular + t.indirectDiffuse + t.indirectSpecular + t.emission; break;
    }
    return vec4(c, 1.0);
}

调试视图用一个 _View 浮点属性加运行时分支切换,不产生变体。前三种模式只看主光方向上的单项(图 11-3 就是用它拍的);之后的模式调用与正式 Shader 完全相同的 MyComputeLighting,只是选择输出哪一部分。这样的调试 Shader 在开发 BRDF 时非常有用:任何一项出错(例如忘了乘 π、NoL 符号反了、环境图 mip 选错),都能在对应视图里一眼看出来。_View 被故意放在 CBUFFER 之外——调试 Shader 不需要 SRP Batcher 兼容,放进去反而要同步修改 MyPBR_Input.hlsl。

图 11-14MyPBRDebug 拆分显示。从左到右:直接光(漫反射 + 镜面,含阴影);间接漫反射(SH,金球因为是金属所以全黑);间接镜面(预滤波环境 × DFG,地面砖块的亮度来自菲涅尔);四项合成的最终结果。

第三格值得多看一眼:砖墙地面是电介质,F0 只有 0.04,但远处掠射角的地面反射出了明显的天空颜色——这是菲涅尔效应,DFG 表里 B 项的贡献。很多“手写 PBR”漏掉环境镜面或者只乘 F0 而不乘 DFG,场景远处的地面就会显得“发干”。

11.5 与 URP Lit 并排对比

写完一个 PBR Shader,最直接的检验方法是和一个公认可靠的实现放在同一盏灯、同一个环境下比较。下面两张图使用完全相同的场景:方向光、程序化天空提供的 SH 与反射,6 列光滑度(0 → 1)× 3 行金属度(0、0.5、1),基础色相同。

图 11-15粗糙度 × 金属度矩阵。上:MyPBR(DFG LUT + 能量补偿);下:URP Lit。每张图从左到右光滑度 0、0.2、0.4、0.6、0.8、1.0,从上到下金属度 0、0.5、1。

整体亮度、高光大小、菲涅尔边缘几乎一致,说明 π 的约定、D 项和 mip 选择都对上了。可见的差异集中在左下角——粗糙的金属:MyPBR 更亮、更饱和。原因有两个,都是有意为之:

环节URP Lit(BRDF.hlsl / GlobalIllumination.hlsl)MyPBR
DGGX,常数项合并进归一化GGX,完整形式
V × F(直接光)V 与 F 的角度变化合并成一个近似 1/(LoH2(α+0.5)),结果再乘 F0高度相关 Smith V × Schlick F(随 LoH 变化)
漫反射Lambert,albedo×0.96(1−m)相同
环境 BRDF1α2+1lerp(F0,grazing,(1−NoV)4)F0A+B(LUT 或 Karis 近似)
多次散射无1+F0(1/Ess−1)
镜面遮蔽AO 直接乘可选 Lagarde 公式

URP Lit 的环境 BRDF 是一个非常便宜的经验公式(第 10 章逐项分析过),在粗糙端给出偏低的反射;再加上没有多次散射补偿,粗糙金属会明显偏暗。MyPBR 在这里更接近“物理上该有的亮度”。这不意味着 URP Lit 是错的——它在移动端的预算下做了合理取舍;而且如果你的美术已经在 URP Lit 下调好了一批材质,换成 MyPBR 后粗糙金属会变亮,需要重新评估。这也是为什么 MyPBR 把能量补偿做成 0~1 的滑条:设为 0 并改用解析近似,就能和 URP Lit 更接近。

图 11-16配套贴图的实物效果。左:MyPBR;右:URP Lit(同一套 paintedmetal 与 bricks 贴图,Mask 的 R/A 进 MetallicGlossMap,G 进 OcclusionMap)。主光加一盏暖色点光源,阴影、法线、AO、金属剥落都一致。

11.6 性能、移动端与常见坑

开销在哪里

与 URP Lit 相比,MyPBR 每盏灯多出的开销主要是可见性项里的两次 sqrt 和 Schlick 的几次乘法;环境部分多出一次 LUT 采样(解析近似则是十几条 ALU)和能量补偿的一次除法。对于桌面和主机,这点差异通常不是瓶颈;对于低端移动设备、尤其是灯多的场景,可以考虑:

  • 关闭 _DFG_LUT 用解析近似,省一次采样和一个采样器;
  • 把 _EnergyCompensation 设为 0 后,用 #if 而不是滑条控制,让编译器完全删掉补偿代码;
  • 把 V 项换成 Filament 提出的 V_SmithGGXCorrelatedFast(去掉 sqrt 的线性近似)或 URP 的 V×F 合并近似;
  • BRDF 用 half 计算时注意 D 项的分母:α2 很小时 (NoH * a2 - NoH) * NoH + 1 会因为半精度舍入而变成 0,这就是 URP 在分母里写 1.00001f 并对 half 平台把结果 clamp 到 1000 的原因。MyPBR 全程用 float,桌面上没有问题;移植到移动端如果改成 half,需要加上类似保护。

变体数量

ForwardLit Pass 的 multi_compile 组合在理论上是几十万个变体,实际构建时 URP 会根据 URP Asset 的设置剥离用不到的(例如项目没开 Light Cookie,所有带 _LIGHT_COOKIES 的变体都会被剥掉),材质关键字用 shader_feature_local 也只编译被材质用到的组合。这和 URP Lit 是同一套机制。c29 会给出统计与剥离变体的工具。

与 Built-in 管线的差异

在 Built-in 管线里,多光源用 ForwardBase + ForwardAdd 多个 Pass 叠加,每盏附加灯都要把物体重画一遍;URP 在一个 Pass 里循环所有灯。Built-in 的环境光也走 SH 和反射探针(UNITY_SAMPLE_TEXCUBE_LOD、ShadeSH9),但没有 Forward+ 的探针图集。Built-in 的 Standard Shader 也采用“Lambert 白板亮度等于灯光颜色”的约定(其漫反射同样不除以 π),所以从 Built-in 迁移到 URP 不需要重新打光;HDRP 则使用勒克斯、流明等物理光照单位,约定不同,不能照搬本章的 π 处理。

在项目中使用 MyPBR

  1. 把 Assets/Cookbook/Ch11/ 下的 MyPBR*.hlsl、MyPBR.shader、DFGLut.cs 和 Editor/DFGLutBaker.cs 拷进项目(include 路径是 Assets/Cookbook/Ch11/…,换目录时要同步修改)。
  2. 执行 Cookbook › Ch11 › Bake DFG LUT,得到 MyPBR_DFG_LUT.asset。如果不想多一张贴图,跳过这一步,材质上不勾 Use DFG LUT 即使用解析近似。
  3. 新建材质选择 Cookbook › Ch11 › MyPBR,或者在已有的 URP Lit 材质上直接切换 Shader:同名属性(基础色、法线、光滑度、自发光)会保留,Lit 的 Metallic Gloss Map 与 Occlusion Map 需要合并成一张 Mask(R 金属、G AO、A 光滑)后放进 Mask 槽。
  4. 按需勾选 Use Mask Map、Use Normal Map、Use Emission、Use DFG LUT。勾选框背后是 shader_feature_local 关键字,没有勾选的功能不会进入编译出的变体。
  5. 选中 Shader 资产确认 Inspector 显示 SRP Batcher compatible;打开 Frame Debugger 确认这些物体被合进 SRP Batch。
  6. 如果项目用 Deferred 渲染路径,按案例 11.9 末尾“关于 Deferred”的说明把 ForwardLit 的 LightMode 改为 UniversalForwardOnly。

换 Shader 后建议在同一个场景里保留几颗 URP Lit 的参照球(中性灰电介质、白色金属、粗糙金属各一颗),与 MyPBR 材质对照检查。差异应当只出现在 11.5 节表格所列的环节;如果整体亮度不同,先检查 π 的处理,其次检查是否误开了后处理曝光。

常见坑

✗ 避免
  • 漫反射除以 π,但灯光没有乘 π:整体暗了约 3 倍。
  • 菲涅尔用 NoV 计算直接光:高光边缘颜色和强度都不对。
  • 环境镜面只乘 F0:远处地面没有掠射反射,场景“发干”。
  • Forward+ 下只写 LIGHT_LOOP,漏了额外方向光的循环。
  • 缺 DepthNormals Pass:开 SSAO 后物体周围出现奇怪的遮蔽或没有遮蔽。
  • 材质 AO 同时乘到直接光上:阴影里叠阴影,暗部发脏。
✓ 推荐
  • 先写 Step1 那样的最小 Shader,用中性灰球和纯色背景验证 BRDF。
  • 做一个调试 Shader,单独显示 D、G、F、直接光、间接光。
  • 写完和 URP Lit 放在同一场景里比较,差异要能解释。
  • 做白炉测试检查能量守恒。
  • 关键字照抄 Lit.shader,再删去不支持的部分,并在注释里写明删了什么。

本章小结

  • 手写 PBR 的边界:BRDF(D、V、F、Lambert、DFG、能量补偿)自己写;灯光、阴影、分簇、SH、反射探针等“管线数据”用 URP 提供的取数函数。
  • Unity 灯光颜色对应 E⊥/π;SH 与光照贴图存的是 E/π。保持 BRDF 的物理形式,在乘灯光时乘 π,环境漫反射直接乘漫反射色。
  • 高度相关 Smith 可见性项把 G2 与 1/(4NoLNoV) 合并,数值稳定;菲涅尔用 v·h。
  • Forward 的附加光受每物体上限约束;Forward+ 用分簇迭代器,并需单独循环额外的方向光。
  • split-sum 把镜面 IBL 拆成“按粗糙度选 mip 的预滤波环境图”和“DFG 环境 BRDF”;后者可用解析近似或程序生成的 LUT,两者基于不同的几何项模型,差异集中在粗糙掠射区。
  • 多次散射补偿系数 1+F0(1/Ess−1) 来自 DFG 表本身,几乎零成本,白炉测试可验证。
  • 一个“能用”的 PBR Shader 还需要 ShadowCaster、DepthOnly、DepthNormals、Meta Pass,统一的 CBUFFER,以及对 Rendering Debugger 的支持。

练习

  1. 把 MyV_SmithGGXCorrelated 换成可分离的 Schlick-GGX(k=α/2),同时修改 DFGLut.cs,重新生成 LUT,观察它与 Karis 解析近似的差异是否变小。
  2. 在 MyInitMaterial 中实现 Specular 工作流:用 _SpecColor 直接给出 F0,漫反射乘 1−max(F0),用一个 shader_feature_local 关键字切换。
  3. 实现 Specular Anti-Aliasing:用 ddx/ddy 估计法线在屏幕上的变化量,按 Kaplanyan 等人的方法增大 α2,对比高光闪烁是否减轻。
  4. 把 MyPBR 改造成支持 Deferred:把 ForwardLit 的 LightMode 改为 UniversalForwardOnly,在 Deferred 渲染路径下验证它仍能接收附加光与阴影。
  5. 为调试 Shader 增加一个“能量补偿系数”视图(输出 energyCompensation - 1),观察它在粗糙度和 NoV 上的分布。