GameTech/URP 渲染实战

案例 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。