GameTech/URP 渲染实战

案例 5.7粗糙漫反射:Oren-Nayar 与 Minnaert进阶

目标:理解为什么月亮、陶土、粗布在正面光照下“不够立体”,实现 Oren-Nayar 定性模型(并推导出一个不含三角函数的版本),以及更简单的 Minnaert 模型。

原理

满月看起来像一个亮度均匀的圆盘,边缘并没有像 Lambert 球那样变暗。原因在于月壤非常粗糙:表面由无数小坑和小斜面组成。在 Oren 和 Nayar 的模型里,粗糙表面被看成大量 V 形凹槽,每个小斜面本身是 Lambert 的。当光源和观察者在同一方向时,我们看到的恰好是那些朝向光源、被照亮的小斜面,而背光的斜面被挡在后面——即使宏观上表面已经倾斜到球的边缘,看到的仍然是亮面。于是粗糙表面呈现出“逆反射”(Retro-reflection)的特性:光从哪来,就更多地往哪里反射回去。

光源方向 观察方向(与光几乎相同) 粗糙表面 = 大量 V 形凹槽;朝光的小斜面(橙)被照亮,也正是观察者看到的面 宏观法线倾斜, 但看到的仍是 被照亮的小面
图 5-16Oren-Nayar 的 V 形凹槽模型。光源与视线方向接近时,可见的小斜面大多是受光面,所以粗糙表面的边缘不像 Lambert 那样变暗

完整的 Oren-Nayar 模型包含遮挡、阴影和凹槽之间的相互反射,很复杂。它的论文同时给出了一个常用的“定性模型”(Qualitative Model):

DON=cosθi⋅[A+B⋅max(0,cos(φi−φr))⋅sinα⋅tanβ]
A=1−0.5σ2σ2+0.33B=0.45σ2σ2+0.09α=max(θi,θr),β=min(θi,θr)

θi、θr 是入射与出射方向的极角,φi − φr 是它们在切平面上投影的夹角,σ 是微斜面坡度的标准差(弧度),表示粗糙程度。σ = 0 时 A = 1、B = 0,退化成 Lambert。

去掉三角函数

直接照公式写,需要两次 acos、一次 sin、一次 tan,还要把 L、V 投影到切平面上求夹角,在 Shader 里很贵。其实可以完全用点积表达。记 s = L·V − (N·L)(N·V),把 L、V 用极坐标展开:

L⋅V=cosθicosθr+sinθisinθrcos(φi−φr)⇒s=sinθisinθrcos(φi−φr)

而 sin α · tan β = sin α · sin β / cos β = sin θi sin θr / max(cos θi, cos θr)(β 是较小的角,余弦较大)。两式相除:

max(0,cos(φi−φr))⋅sinα⋅tanβ=max(0,s)max(N⋅L,N⋅V)

这是一个精确的恒等变换,不是近似,结果只需要三次点积和一次除法。

Assets/Cookbook/Ch05/RoughDiffuse.shader(节选:orennayar)
// Oren-Nayar 定性模型,返回值已乘 N·L(URP 约定,不含 1/π)。
// 原式 A + B·max(0, cos(φi-φr))·sinα·tanβ 里的三角函数可以全部消掉:
//   s = L·V - (N·L)(N·V) = sinθi·sinθr·cos(φi-φr)
//   sinα·tanβ = sinθi·sinθr / max(cosθi, cosθr)
// 于是 cos(φi-φr)·sinα·tanβ = s / max(N·L, N·V)
float OrenNayar(float3 N, float3 L, float3 V, float sigma)
{
    float NdotL = saturate(dot(N, L));
    float NdotV = saturate(dot(N, V));
    float s2 = sigma * sigma;
    float A = 1.0 - 0.5 * s2 / (s2 + 0.33);
    float B = 0.45 * s2 / (s2 + 0.09);
    float s = dot(L, V) - NdotL * NdotV;
    float t = max(max(NdotL, NdotV), 1e-4);
    return NdotL * (A + B * max(0.0, s) / t);
}
// Oren-Nayar 定性模型,返回值已乘 N·L(URP 约定,不含 1/π)。
// 原式 A + B·max(0, cos(φi-φr))·sinα·tanβ 里的三角函数可以全部消掉:
//   s = L·V - (N·L)(N·V) = sinθi·sinθr·cos(φi-φr)
//   sinα·tanβ = sinθi·sinθr / max(cosθi, cosθr)
// 于是 cos(φi-φr)·sinα·tanβ = s / max(N·L, N·V)
float OrenNayar(vec3 N, vec3 L, vec3 V, float sigma)
{
    float NdotL = saturate(dot(N, L));
    float NdotV = saturate(dot(N, V));
    float s2 = sigma * sigma;
    float A = 1.0 - 0.5 * s2 / (s2 + 0.33);
    float B = 0.45 * s2 / (s2 + 0.09);
    float s = dot(L, V) - NdotL * NdotV;
    float t = max(max(NdotL, NdotV), 1e-4);
    return NdotL * (A + B * max(0.0, s) / t);
}

Minnaert 模型更早,也更简单,最初用来描述月面的亮度分布。它只有一个参数 k:

DMinnaert=(N⋅L)k⋅(N⋅V)k−1
Assets/Cookbook/Ch05/RoughDiffuse.shader(节选:minnaert)
// Minnaert:k = 1 时就是 Lambert;k < 1 边缘变亮(天鹅绒、月面感),k > 1 边缘更暗
float Minnaert(float3 N, float3 L, float3 V, float k)
{
    float NdotL = saturate(dot(N, L));
    float NdotV = max(saturate(dot(N, V)), 1e-3);
    return pow(NdotL, k) * pow(NdotV, k - 1.0);
}
// Minnaert:k = 1 时就是 Lambert;k < 1 边缘变亮(天鹅绒、月面感),k > 1 边缘更暗
float Minnaert(vec3 N, vec3 L, vec3 V, float k)
{
    float NdotL = saturate(dot(N, L));
    float NdotV = max(saturate(dot(N, V)), 1e-3);
    return pow(NdotL, k) * pow(NdotV, k - 1.0);
}

k = 1 时 N·V 的指数为 0,就是 Lambert;k < 1 时 (N·V)k−1 在边缘(N·V → 0)变大,轮廓变亮,适合天鹅绒、桃子绒毛一类材质;k > 1 则让边缘更暗。由于 k < 1 时边缘处会趋于无穷,代码里给 N·V 设了一个下限。

图 5-17光源几乎在相机身后(正面光)。从左到右:Lambert、Oren-Nayar σ = 0.25、σ = 0.5、σ = 1.0、Minnaert k = 0.7。Lambert 边缘明显变暗;Oren-Nayar 随 σ 增大越来越“平”,像陶土和月球;Minnaert 边缘反而更亮
图 5-18同样五个球,侧光照射。粗糙模型让受光面更平、交界线更硬,差异比正面光时小

从图 5-17 还能看出一个问题:σ 越大,Oren-Nayar 球整体越暗。这是因为定性模型丢掉了凹槽之间的相互反射,能量并不守恒。对于需要精确能量的场合,可以考虑后来的改进模型;在游戏里,更常见的选择是第 9 章的 Disney(Burley)漫反射,它用很小的代价同时考虑了粗糙度对边缘亮度的影响。

何时值得用多数情况下 Lambert 已经足够,因为环境光、法线贴图和高光会掩盖这些细微差异。Oren-Nayar 的价值主要在大面积、低反光、强正面光的材质上:岩石、沙地、粗布、陶器,以及需要“去塑料感”的风格化场景。