YuyeyyyGraphics

Shader / PBR

自定义 PBR 与 Unity Lit 的差异拆解

返回文章

本文记录从 Unity Lit 迁到自定义 PBR 时踩过的坑与差异点,主要关注 BRDF 项、能量守恒、几何可见性函数的差异。

引言

Unity URP 的 Lit shader 用的是简化的金属流 PBR(基于 Disney / GGX),多数场景下够用, 但一旦想做更「物理正确」的渲染管线,自定义 PBR 就躲不开。本文整理我从 Unity Lit 迁到 自写 PBR 时遇到的几个核心差异:

  • 法线分布 D 项:URP 用的 GGX/Smith,自己实现时要注意 alpha = roughness^2 还是 roughness^4
  • 几何遮挡 G 项:Smith vs Schlick-GGX 近似,可见性项 V = G / (4 N·L N·V) 的合并写法。
  • 菲涅尔 F 项:Schlick 近似的 F0 取值,金属流和非金属流的处理。
  • 能量守恒:漫反射项要不要乘 (1 - F),金属流要不要清零漫反射。

法线分布 D 项:GGX 与 α 的平方链

GGX(Trowbridge-Reitz)法线分布函数的核心公式:

D(h)=α2/(π((Nh)2(α21)+1)2)D(h) = α² / ( π · ((N·h)² · (α² − 1) + 1)² )

其中 h 是半角向量,α 是粗糙度相关参数。这里的坑在于 α 与 roughness 的映射关系。 行业里存在两层「平方」:

  1. PerceptualRoughnessToRoughness(p) = p² —— 把感知粗糙度映射到物理粗糙度;
  2. GGX 公式里的 α = roughness²(Disney 的约定)。

合起来,如果用户拖动的滑块是 perceptualRoughness,那么 GGX 里真正用到的 α² = perceptualRoughness⁴。 这就是很多人说的「roughness^4」来源。

Unity URP 的 Lit.hlsl 实际选择:

real roughness2 = roughness * roughness;       // roughness 这里已经是物理 roughness
real roughness4 = roughness2 * roughness2;
// GGX D 项里传入的是 roughness4,即 α²
real D_GGX(real NdotH, real roughness4)
{
    real a2 = roughness4;
    real d = (NdotH * a2 - NdotH) * NdotH + 1.0;
    return a2 / (PI * d * d + 1e-7);
}

注意 roughness 在 URP 里已经经过 PerceptualRoughnessToRoughness 平方过一次,所以 roughness2 = αroughness4 = α²。如果你自写 PBR 时把用户滑块直接当 α 喂进去(少平方一次),高光会明显比 Unity 窄且硬, 这是迁移时最常踩的坑。

我自己实现的版本,把两层平方都显式写出,避免歧义:

real D_GGX(float3 N, float3 H, float perceptualRoughness)
{
    float a = perceptualRoughness * perceptualRoughness; // α
    float a2 = a * a;                                    // α²
    float NdotH = saturate(dot(N, H));
    float d = (NdotH * a2 - NdotH) * NdotH + 1.0;
    return a2 * rcp(PI * d * d + 1e-7);
}

几何遮挡 G 项:Smith-Joint vs Schlick-GGX

几何项描述微表面自遮挡。精确形式是 Smith-GGX:

G(l,v,α)=G1(l)G1(v)G1(x)=2(Nx)/((Nx)+sqrt(α2+(1α2)(Nx)2))G(l, v, α) = G₁(l) · G₁(v) G₁(x) = 2 (N·x) / ( (N·x) + sqrt(α² + (1 − α²)(N·x)²) )

但精确 Smith 带开方,移动端常用 Schlick-GGX 近似

G1(x)=(Nx)/((Nx)(1k)+k),k=α/2G₁(x) = (N·x) / ( (N·x)(1 − k) + k ), k = α/2

k 对直接光和 IBL 取值不同(直接光 k = (α+1)² / 8,IBL k = α²/2),这是 UE4 的约定。

工程上更常见的写法是把 G / (4·N·L·N·V) 合并成可见性项 V,省一次除法。Unity URP 用的是 Smith-Joint GGX 可见性近似:

real Vis_SmithJointGGX(real NdotL, real NdotV, real roughness2)
{
    // roughness2 这里是 α
    real a = roughness2;
    real lambdaV = NdotL * (NdotV * (1.0 - a) + a);
    real lambdaL = NdotV * (NdotL * (1.0 - a) + a);
    return 0.5 / (lambdaV + lambdaL + 1e-5);
}

注意返回的是 V = G/(4·N·L·N·V),所以最终高光只需 D * V * F,不用再除一次。 Schlick-GGX 的 V 形式更便宜:

real Vis_SchlickGGX(real NdotL, real NdotV, real k)
{
    real VisV = NdotV * (1.0 - k) + k;
    real VisL = NdotL * (1.0 - k) + k;
    return 0.25 / (VisV * VisL); // 即 (N·V)(N·L) / ((N·V)(1-k)+k)((N·L)(1-k)+k)
}

实测 Smith-Joint 比 Schlick 在掠射角更接近精确 Smith,亮度过渡更自然;Schlick 在低粗糙度时偏高, 会让边缘高光略爆。URP 默认走 Smith-Joint,自己实现时建议对齐。

菲涅尔 F 项:Schlick 近似与 F0

菲涅尔用 Schlick 近似就够了,Marschner 那种精确 Fresnel-Conductor 在实时里没必要:

F(v,h)=F0+(1F0)(1(vh))5F(v, h) = F0 + (1 − F0) · (1 − (v·h))⁵

关键是 F0(垂直入射反射率)的取值,分金属流和非金属流:

  • 非金属F0 = 0.04(介电质典型值,对应 IOR≈1.5);
  • 金属F0 = baseColor(albedo 直接当反射率,因为金属没有漫反射)。

金属流 PBR 用 metallic 在两者之间插值:

real3 F0 = lerp(0.04, baseColor, metallic);
real3 F_Schlick(real VdotH, real3 F0)
{
    real f = Pow5(1.0 - VdotH);
    return F0 + (1.0 - F0) * f;
}

URP 的 F_Schlick 实现一致。差异点在 VdotH 还是用 LdotH:理论上菲涅尔是观测方向对微表面法线(即半角)的夹角, 所以用 VdotH(或等价的 LdotH)才对,不要用 NdotV,否则掠射角菲涅尔不会正确增强。

能量守恒:漫反射与 (1 − F)

能量守恒的朴素做法是让漫反射乘以 (1 − F),因为被镜面反射走的那部分能量不能再算进漫反射:

real3 kd = (1.0 - F) * (1.0 - metallic);
real3 diffuse = kd * baseColor / PI;

(1 - metallic) 是金属流清零漫反射的关键:金属的所有反射都是镜面的,没有次表面散射。 (1 - F) 让漫反射随菲涅尔增强而衰减,保证总能量不爆。

但这里有个细节:Unity URP 的 Lit 用的是 Lambert + (1-F) 的简化,没有用 Disney 的 BurleyDiffuseHanrahan-Krueger。迁移时如果想升级到更正确的漫反射模型,可以换成 Burley 漫反射:

real3 BurleyDiffuse(real NdotL, real NdotV, real VdotH, real roughness, real3 baseColor)
{
    real fd90 = 0.5 + 2.0 * VdotH * VdotH * roughness;
    real2 f90 = real2(fd90, fd90);
    real2 f0  = real2(1.0, 1.0);
    real lightScatter = f0.x + (f90.x - f0.x) * Pow5(1.0 - NdotL);
    real viewScatter  = f0.y + (f90.y - f0.y) * Pow5(1.0 - NdotV);
    return baseColor * rcp(PI) * lightScatter * viewScatter;
}

Burley 会让粗糙表面的背光面带一点泛红过渡,比纯 Lambert 自然,但成本高一档。移动端建议保持 Lambert。

总结

迁移过程中踩到的几个关键差异点:

  1. α 的平方链perceptualRoughness → roughness (²) → α (²) → α² (²),少平方一次高光就窄硬。
  2. V 项合并:URP 用 Smith-Joint 的 V 形式,自写时对齐 0.5 / (λV + λL),别再额外除 4·N·L·N·V
  3. F0 金属流lerp(0.04, baseColor, metallic),菲涅尔用 VdotH 不是 NdotV
  4. 能量守恒kd = (1-F)(1-metallic),金属漫反射清零;要更好的过渡可换 Burley 漫反射。

整体上 Unity Lit 的实现是「物理上够用、性能上克制」的取舍,自写 PBR 时把这些项对齐后, 差异主要出现在掠射角和金属高光形态上,正对视角几乎看不出区别。

公式速查

GGX 法线分布 D 项:

DGGX(n,h)=α2π((nh)2(α21)+1)2D_{GGX}(\mathbf{n},\mathbf{h}) = \frac{\alpha^2}{\pi \left( (\mathbf{n}\cdot\mathbf{h})^2 (\alpha^2 - 1) + 1 \right)^2}

Smith 几何可见性 G 项:

GSmith(l,v,α)=G1(l)G1(v),G1(x)=2(nx)(nx)+α2+(1α2)(nx)2G_{Smith}(\mathbf{l},\mathbf{v},\alpha) = G_1(\mathbf{l}) \cdot G_1(\mathbf{v}), \quad G_1(\mathbf{x}) = \frac{2 (\mathbf{n}\cdot\mathbf{x})}{(\mathbf{n}\cdot\mathbf{x}) + \sqrt{\alpha^2 + (1-\alpha^2)(\mathbf{n}\cdot\mathbf{x})^2}}

Schlick 菲涅尔 F 项:

F(v,h)=F0+(1F0)(1vh)5F(\mathbf{v},\mathbf{h}) = F_0 + (1 - F_0)(1 - \mathbf{v}\cdot\mathbf{h})^5

下面是用默认 shader 展示上述 BRDF 项叠加效果的实时演示:

评论

昵称留言,审核后显示

加载中…