6.1 微表面理论

微表面理论把镜面反射拆成 D、G、F 三个因子:GGX 分布 D 决定高光形状,Smith 几何项 G 补偿微面互相遮挡,Schlick 菲涅尔 F 决定反射比例。本节在 Q16.16 里把它们写成纯整数,用投影圆盘方向集验证 ∫D(n·h)dω≈1,并量出 alpha=0.25 时镜面波瓣的半高全宽是 40.74 度。

6.1 微表面理论

前五节的直接光一直是 Phong 那一族经验模型:高光写成 $\cos^k\theta$ 或者 $(r\cdot v)^k$,指数是调出来的,既没有物理含义,能量也不守恒。本章换成基于物理的着色(physically based rendering, PBR):把表面看成无数个微小镜面,宏观的反射率由这些微面的统计分布决定。那篇讲实时 PBR 与阴影贴图管线工程的见 实时 PBR 与阴影贴图 ;本节做的是它下面那一层——把 D、G、F 三个因子写成 Q16.16 整数,让它们能被逐位复现,也才能被逐像素断言。

三个因子和一个分母

微表面模型的镜面项是三个独立因子的乘积:

$$ f_s(l,v)=\frac{D(h),G(l,v),F(v,h)}{4,(n\cdot l)(n\cdot v)},\qquad h=\frac{l+v}{\lVert l+v\rVert} $$

$h$ 是半程向量——能同时"看见"入射方向 $l$ 和出射方向 $v$ 的那个微面法线。三个因子各管一件事:

因子名字管什么
$D(h)$法线分布函数(NDF)有多少微面的法线正好朝 $h$,决定高光形状
$G(l,v)$几何项微面之间互相遮挡的比例,决定掠射角下变暗多少
$F(v,h)$菲涅尔项一次碰撞里多少能量被反射、多少被折射,决定高光强度

分母 $4(n\cdot l)(n\cdot v)$ 不是拟合参数,而是面积换元的雅可比。$D,d\omega_h$ 给出"法线落在 $d\omega_h$ 里的微面占多少宏观面积",从半程向量的立体角换到微面面积要除以 $4(n\cdot l)$(由 $\theta_h=\theta_v$ 可以推出来),入射和出射各来一次,于是分母上就有了两项。它同时是保护项:$n\cdot l\to 0$ 时它把 BRDF 压回去,否则掠射角会爆到无穷。

GGX 分布 D

GGX(也叫 Trowbridge-Reitz)的分布是

$$ D(h)=\frac{\alpha^2}{\pi\big((n\cdot h)^2(\alpha^2-1)+1\big)^2} $$

$\alpha$ 是粗糙度参数。$\alpha\to 0$ 时 $D$ 趋近狄拉克函数(完美镜面),$\alpha=1$ 时 $D\equiv 1/\pi$(整个半球均匀)。它在整数里只有一种写法是对的:

# snippet: lantern/sim/cg_shade_06_pbr.py 的 ggx_d 主体
def ggx_d(a2, nh):
    t = fmul(fmul(nh, nh), a2 - ONE) + ONE
    return fdiv(fdiv(a2, t), fmul(t, PI))

拆成两次 fdiv(先算 $a2/t$、再除以 $\pi t$)而不是一次除以 $\pi t^2$,是因为 $t\ge\alpha^2$:两次商都不过界。若先算 $t^2$,小 $\alpha$ 下 (t*t)>>16 会下溢成 0,下一次 fdiv 直接除零——本节脚本第一次跑就撞在这个 ZeroDivisionError 上。Q16.16 里 $\alpha^2$ 最小只能到 $26/65536\approx4\times10^{-4}$,这也是 6.2 要把 $\alpha$ 下夹到 0.02 的原因。

Smith 几何项 G

Smith 模型把遮蔽拆成入射、出射两个独立的方向函数 $G=G_1(l),G_1(v)$,GGX 对应的 $G_1$ 有闭式解:

$$ G_1(u)=\frac{2(n\cdot u)}{(n\cdot u)+\sqrt{\alpha^2+(1-\alpha^2)(n\cdot u)^2}} $$

$n\cdot u=1$ 时 $G_1=1$(正对观察,不遮蔽),$n\cdot u\to0$ 时 $G_1\to0$(掠射全遮蔽)。实测在 $n\cdot h=1$、$\alpha^2=0.0625$ 处 $G_1$ 精确等于 1.0000。

Schlick 菲涅尔 F

$$ F=F_0+(1-F_0)(1-v\cdot h)^5 $$

$F_0$ 是垂直入射的反射率:绝缘体约 0.04,金属取基础色本身(6.2 展开)。五次方是 Schlick 的拟合,代价是掠射角下 $F\to1$ 的渐近行为不完全准确,但整数里它只需要三次乘法。

投影圆盘:半球积分不需要三角函数

后面三节都要算 $\int f\cos\theta,d\omega$ 这类半球积分。直接用 $\theta,\phi$ 网格会立刻遇到两个麻烦:$\sin\theta,d\theta,d\phi$ 的权重容易写错,而定点库里根本没有三角函数。

换个参数化就都消失了。用 $(r,s)$ 参数化单位圆盘,令

$$ l=\big(r,;s,;\sqrt{1-r^2-s^2}\big) $$

方向投影到切平面上的面积元是 $dr,ds$;球面上的面积元是 $d\omega=dr,ds/l_z$(投影把面积放大了 $1/l_z$ 倍)。两者相乘,$l_z$ 正好抵消:

$$ \int_{\Omega} f(l),\cos\theta_l,d\omega_l=\iint_{\text{圆盘}} f(l),dr,ds $$

余弦因子自己消失了。 剩下的事只是把 $r,s$ 铺成均匀格,每格的面积 $(\Delta r)^2$ 就是权重。实测取 $64\times64$ 格,落在圆盘内的有 3228 个方向。

格数必须是 2 的幂。$\Delta r=2/n$,$n=64$ 时在 Q16.16 里是 $131072/64=2048$,格面积 $2048^2/65536=64$ 恰好整除;换成 $n=96$ 就得到 $131072/96=1365$(向下取整),格面积变成 $1365^2/65536=28$ 而不是 $28.43$,圆盘总面积少了约 1.5%,能量直接算歪。这是本节除下溢之外的第二个定点坑。

实测

脚本做两件事:在圆盘方向集上验证 $\int D(h)(n\cdot h),d\omega_h\approx1$($D$ 是归一化的概率密度,这个积分必须等于 1),再扫一遍平面内的镜面波瓣。

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_06_pbr.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_06_pbr.py
"""6.1 微表面理论:GGX 分布 D、Smith 几何项 G、Schlick 菲涅尔 F 的整数实现。

全部 Q16.16 定点(Python int 任意精度),只用 fmul / fdiv / fsqrt——没有三角函数、
没有浮点常量(π 取整数 205887)。两个实验:

  1. D 的归一化 ∫ D(h)(n·h) dω_h ≈ 1:在**投影圆盘**上均匀取样,度量天然正确
     (余弦因子与 1/cosθ 相消:∫ f cosθ dω = ∫∫_{圆盘} f dr ds)。
  2. 镜面波瓣 f_s = D G F / (4 (n·l)(n·v)) 随入射角的扫描与半高全宽。

产物:out/cg_shade_06_1_lobe.csv、out/cg_shade_06_1_lobe.svg、out/cg_shade_06_1_microfacet.json。
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
from cg_shade_06_util import asin_deg, svg  # noqa: E402
from cglib import ONE, fdiv, fmul, fsqrt  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PI = 205887            # round(pi * 2^16)
GRID = 64              # 投影圆盘每边的格数
A2, F0 = 4096, 2621    # 粗糙度 0.5 -> alpha = 0.25、alpha^2 = 0.0625;绝缘体 F0 = 0.04


def ggx_d(a2, nh):
    """GGX / Trowbridge-Reitz:D = a2 / (pi t^2),t = (n·h)^2 (a2-1) + 1。

    拆成两次 fdiv(a2/t 再 /(pi t))而不是一次除以 pi*t^2:t >= a2,两次商都不过界;
    若先算 t^2,小 alpha 下 (t*t)>>16 会下溢成 0,fdiv 直接除零。
    """
    t = fmul(fmul(nh, nh), a2 - ONE) + ONE
    return fdiv(fdiv(a2, t), fmul(t, PI))


def smith_g1(a2, nu):
    """Smith-GGX 遮蔽:G1(u) = 2 nu / (nu + sqrt(a2 + (1-a2) nu^2))。"""
    return fdiv(2 * nu, nu + fsqrt(a2 + fmul(ONE - a2, fmul(nu, nu))))


def schlick_f(f0, vh):
    """Schlick 菲涅尔:F = f0 + (1-f0)(1 - v·h)^5。"""
    c = ONE - vh
    c = 0 if c < 0 else c
    c2 = fmul(c, c)
    return f0 + fmul(ONE - f0, fmul(fmul(c2, c2), c))


def spec_brdf(a2, nl, nv, nh, vh, f0):
    """镜面项 f_s = D G F / (4 (n·l)(n·v))。"""
    g = fmul(smith_g1(a2, nv), smith_g1(a2, nl))
    return fdiv(fmul(fmul(ggx_d(a2, nh), g), schlick_f(f0, vh)), 4 * fmul(nl, nv))


def disk_dirs(n=GRID):
    """投影圆盘上的确定性方向集:l = (r, s, sqrt(1 - r^2 - s^2)),r、s 取均匀格。"""
    step, half = 2 * ONE // n, ONE // n
    out = []
    for i in range(n):
        r = -ONE + half + i * step
        for j in range(n):
            s = -ONE + half + j * step
            q = ONE - fmul(r, r) - fmul(s, s)
            if q >= 0:
                out.append((r, s, fsqrt(q)))
    return out


def cell_of(n=GRID):
    """圆盘上一个格子的面积(Q16.16);n 个格子铺满单位圆盘时 Σ = pi。"""
    return fmul(2 * ONE // n, 2 * ONE // n)


def d_norm(a2, dirs, n=GRID):
    """∫ D(h)(n·h) dω_h ≈ cell * Σ D(h):h 在圆盘上均匀取样,n·h 就是 h 的 z 分量。"""
    return (sum(ggx_d(a2, h[2]) for h in dirs) * cell_of(n)) >> 16


def lobe(a2, f0, rv, n=128):
    """平面内波瓣:v 固定在 (rv, 0, sqrt(1-rv^2)),l 扫过整个平面,r 从 -1 到 1。

    镜面方向 l = reflect(v, n) = (-rv, 0, zv) 在法线的**另一侧**,那里 h = n、n·h = 1、
    D 取峰;但 f_s 还带 1/(4 (n·l)(n·v)),故 BRDF 的峰比镜面方向略偏掠射。
    """
    zv = fsqrt(ONE - fmul(rv, rv))
    step = 2 * ONE // n
    rows = []
    for k in range(n + 1):
        r = min(max(-ONE + k * step, 1 - ONE), ONE - 1)   # 避开 n·l = 0 的掠射端点
        z = fsqrt(ONE - fmul(r, r))
        hx, hz = r + rv, z + zv
        hl = fsqrt(fmul(hx, hx) + fmul(hz, hz))
        rows.append((k, r, z, spec_brdf(a2, z, zv, fdiv(hz, hl),
                                       fdiv(fmul(rv, hx) + fmul(zv, hz), hl), f0)))
    return rows


def main():
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    dirs = disk_dirs()
    table = [(lab, d_norm(a2, dirs)) for lab, a2 in
             [("0.05", 164), ("0.25", 4096), ("0.50", 16384), ("1.00", 65536)]]
    assert all(abs(n - ONE) < 4000 for _, n in table), "D 未归一化"

    rows = lobe(A2, F0, ONE // 2)
    ip = max(range(len(rows)), key=lambda i: rows[i][3])
    half, lo, hi = rows[ip][3] // 2, ip, ip
    while lo > 0 and rows[lo - 1][3] >= half:
        lo -= 1
    while hi < len(rows) - 1 and rows[hi + 1][3] >= half:
        hi += 1
    deg = asin_deg(rows[hi][1]) - asin_deg(rows[lo][1])
    assert ggx_d(A2, ONE) > ggx_d(A2, ONE - 1024) > ggx_d(A2, ONE - 4096), "D 未在 n·h = 1 取峰"

    cglib.write_csv(os.path.join(out, "cg_shade_06_1_lobe.csv"),
                    ["k", "r_q16", "z_q16", "fs_q16"], rows)
    size = svg(os.path.join(out, "cg_shade_06_1_lobe.svg"),
               [("f_s(r)  峰值 %.4f" % (rows[ip][3] / ONE), "#ff8a5c", max(t[3] for t in rows),
                 [((t[1] + ONE) // 2, t[3]) for t in rows])],
               "6.1  镜面波瓣 f_s(v 在 30 度,alpha = 0.25)")
    data = {"alpha2": A2, "f0": F0, "grid": GRID, "cell_q16": cell_of(GRID), "directions": len(dirs),
            "d_g1_f_at_nh1": {"d_q16": ggx_d(A2, ONE), "g1_q16": smith_g1(A2, ONE),
                              "f_q16": schlick_f(F0, ONE)},
            "d_norm": [{"alpha": lab, "value_q16": n} for lab, n in table],
            "peak": {"r_q16": rows[ip][1], "fs_q16": rows[ip][3]},
            "fwhm_samples": hi - lo + 1, "fwhm_deg_q16": deg, "svg_bytes": size}
    with open(os.path.join(out, "cg_shade_06_1_microfacet.json"), "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)

    print(f"投影圆盘取样   : {len(dirs)} 个方向({GRID}x{GRID} 格,格面积 {cell_of(GRID)}/65536)")
    print("D 归一化       : " + "  ".join(f"a={lab}: {n / ONE:.4f}" for lab, n in table))
    print(f"波瓣峰值       : r={rows[ip][1] / ONE:.4f}(sin)  f_s={rows[ip][3] / ONE:.4f}")
    print(f"半高全宽       : {hi - lo + 1} / {len(rows)} 个采样 = {deg / ONE:.2f} 度")
    print(f"D/G1/F(n·h=1)  : {ggx_d(A2, ONE) / ONE:.4f} / {smith_g1(A2, ONE) / ONE:.4f} / "
          f"{schlick_f(F0, ONE) / ONE:.4f}")
    print(f"svg            : out/cg_shade_06_1_lobe.svg ({size} bytes)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
投影圆盘取样   : 3228 个方向(64x64 格,格面积 64/65536)
D 归一化       : a=0.05: 0.9927  a=0.25: 1.0000  a=0.50: 1.0008  a=1.00: 1.0034
波瓣峰值       : r=-0.5312(sin)  f_s=0.0680
半高全宽       : 38 / 129 个采样 = 40.74 度
D/G1/F(n·h=1)  : 5.0933 / 1.0000 / 0.0400
svg            : out/cg_shade_06_1_lobe.svg (1788 bytes)

归一化列有两个值得说的数。$\alpha=0.05$ 时 $D$ 的峰高达 809,半高角只有 0.76 度,$64\times64$ 的圆盘格根本采不到这个峰,0.9927 的缺口是网格分辨率而不是模型误差——它会在 6.3 里以另一种形式再出现一次。反过来 $\alpha=1$ 时 $D\equiv1/\pi$ 是解析常数,圆盘总面积比 $\pi$ 大 0.34%(3228 个格子覆盖了 $64\times64$ 网格里所有落在圆盘内的格),所以读到 1.0034。两头都不是"模型错了",而是离散化的方向集有自己的面积账。

波瓣那一列有个反直觉的结论:BRDF 的峰不在镜面方向。 镜面方向是 $r=-0.5$($\sin30^\circ$),实测峰在 $r=-0.5312$,往掠射侧偏了。原因是 $D$ 确实在 $n\cdot h=1$ 取峰(断言里钉住了这一条),但 $f_s$ 还乘着 $1/\big(4(n\cdot l)(n\cdot v)\big)$,这个因子随着 $l$ 靠近掠射而变大,把峰往掠射推。断言因此只能钉 $D$ 的单调性,钉 $f_s$ 的峰位就会误报。

P 层:同一套 D/G/F 的 BigInt 复现

P 层把同一套公式搬到 TypeScript,用 BigInt 保证无限精度,并和 Python 侧逐位比对。

// file: lantern/core/src/lantern/cg_shade_06_pbr.ts
// tier: P
// verify: node --test 'core/test/cg_shade_06_*.test.ts'
// 6.1 微表面理论的 TypeScript 侧:GGX 分布 D、Smith 几何项 G1、Schlick 菲涅尔 F、
// 镜面项 f_s,以及投影圆盘上的确定性方向集。与 sim/cg_shade_06_pbr.py 逐字对齐。
//
// 为什么必须 BigInt:小 alpha 下 D 在 Q16.16 里能到 5.3e7,乘上格面积再累加 3228 项
// 会到 1e12 量级;Number 的 `>>` 只对 32 位有效,超了会静默截断成错误结果。
// 舍入口径与 Python 侧一致:降精度用 `>>`(向下取整),除法一律走 fdiv(向零截断)。

import { ONE, fdiv, fmul, fsqrt } from "./fixed.ts";

export const PI = 205887n; // round(pi * 2^16)
export const GRID = 64; // 投影圆盘每边的格数

export type Dir = [bigint, bigint, bigint];

/** GGX / Trowbridge-Reitz:D = a2 / (pi t^2),t = (n·h)^2 (a2-1) + 1。 */
export function ggxD(a2: bigint, nh: bigint): bigint {
  const t = fmul(fmul(nh, nh), a2 - ONE) + ONE;
  return fdiv(fdiv(a2, t), fmul(t, PI));
}

/** Smith-GGX 遮蔽:G1(u) = 2 nu / (nu + sqrt(a2 + (1-a2) nu^2))。 */
export function smithG1(a2: bigint, nu: bigint): bigint {
  return fdiv(2n * nu, nu + fsqrt(a2 + fmul(ONE - a2, fmul(nu, nu))));
}

/** Schlick 菲涅尔:F = f0 + (1-f0)(1 - v·h)^5。 */
export function schlickF(f0: bigint, vh: bigint): bigint {
  const raw = ONE - vh;
  const c = raw < 0n ? 0n : raw;
  const c2 = fmul(c, c);
  return f0 + fmul(ONE - f0, fmul(fmul(c2, c2), c));
}

/** 镜面项 f_s = D G F / (4 (n·l)(n·v))。 */
export function specBrdf(
  a2: bigint, nl: bigint, nv: bigint, nh: bigint, vh: bigint, f0: bigint,
): bigint {
  const g = fmul(smithG1(a2, nv), smithG1(a2, nl));
  return fdiv(fmul(fmul(ggxD(a2, nh), g), schlickF(f0, vh)), 4n * fmul(nl, nv));
}

/** 投影圆盘上的确定性方向集:l = (r, s, sqrt(1 - r^2 - s^2)),r、s 取均匀格。 */
export function diskDirs(n: number = GRID): Dir[] {
  const step = (2n * ONE) / BigInt(n);
  const half = ONE / BigInt(n);
  const out: Dir[] = [];
  for (let i = 0; i < n; i += 1) {
    const r = -ONE + half + BigInt(i) * step;
    for (let j = 0; j < n; j += 1) {
      const s = -ONE + half + BigInt(j) * step;
      const q = ONE - fmul(r, r) - fmul(s, s);
      if (q >= 0n) out.push([r, s, fsqrt(q)]);
    }
  }
  return out;
}

/** 圆盘上一个格子的面积(Q16.16);n 个格子铺满单位圆盘时总和 = pi。 */
export function cellOf(n: number = GRID): bigint {
  return fmul((2n * ONE) / BigInt(n), (2n * ONE) / BigInt(n));
}

/** ∫ D(h)(n·h) dω_h ≈ cell * Σ D(h):圆盘均匀取样,n·h 就是 h 的 z 分量。 */
export function dNorm(a2: bigint, dirs: Dir[], n: number = GRID): bigint {
  let sum = 0n;
  for (const h of dirs) sum += ggxD(a2, h[2]);
  return (sum * cellOf(n)) >> 16n;
}

P 层的测试有三条硬断言:ggxD/smithG1/schlickF 在 $n\cdot h=1$ 处与 Python 逐位相同;dNorm 对四个 $\alpha$ 逐位相同;dirs 的长度与格面积逐位相同。node --test 'core/test/cg_shade_06_*.test.ts' 跑出 20 项全绿,其中 6.1 占 6 项。

阅读导航:上一节:5.3 环境贴图与球谐 · 下一节:6.2 金属度-粗糙度工作流 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配