6.3 能量守恒的验证

6.1 的 BRDF 和 6.2 的参数映射都默认反射能量不超过入射能量,但 BRDF 本身给不出这个保证。本节把 ∫f·cosθ·dω≤1 变成一个可跑的整数实验:GGX 各粗糙度的残差表、alpha=1 处的解析锚点 1-ln2、单次弹射丢掉 69.3% 能量、Kulla-Conty 补偿把它补回 1,以及 kD=1-F 为什么守不住掠射角。

6.3 能量守恒的验证

6.1 的 BRDF 和 6.2 的参数映射都建立在一个不成文的前提上:反射出去的能量不能超过入射的。BRDF 的公式本身给不出这个保证,只能实测。那篇讲实时 PBR 与阴影贴图的工程实现见 实时 PBR 与阴影贴图 ;本节把"能量不超 1"变成一个能跑、能断言的整数实验。

上界是什么

BRDF 的物理约束只有一条:对任意入射方向 $l$,反射到整个半球的总能量不超过入射能量,

$$ E(\mu_v)=\int_{\Omega} f_s(l,v),\cos\theta_l,d\omega_l\ \le\ 1,\qquad \mu_v=n\cdot v $$

这个量叫方向反射率。注意 $\le1$ 不是等式:真实材质会吸收,$E$ 通常小于 1,而差值就是被材质吃掉的部分。本节的全部工作就是把这个积分算出来,看它离 1 有多远。

为什么单次弹射必然丢能量

微表面 BRDF 的 $G$ 项只统计了"入射和出射各自不被挡住"的概率,它默认一次碰撞之后光就离开了。真实表面上光会在微面之间多次弹射——一次没反射出去的光可能撞第二次、第三次。所以单次弹射的 GGX 天然欠能量,欠多少取决于 $\alpha$。

有一个可以手算的锚点。取 $\alpha=1$、$v=n$、$F\equiv1$:此时 $D\equiv1/\pi$,$G_1(n)=1$,$G_1(l)=2\mu_l/(\mu_l+1)$,而 $h=\text{normalize}(l+n)$ 给出 $n\cdot h=\sqrt{(1+\mu_l)/2}$。代进 $f_s=DGF/\big(4(n\cdot l)(n\cdot v)\big)$ 之后这些项会成片消掉,只剩

$$ f_s=\frac{1}{2\pi,(1+\cos\theta_l)} $$

再积一次(换元 $u=\cos\theta$):

$$ E=\int_0^{2\pi}!!\int_0^{\pi/2}\frac{\cos\theta}{2\pi(1+\cos\theta)}\sin\theta,d\theta,d\phi=\int_0^1\frac{u}{1+u},du=1-\ln 2=0.306854 $$

所以 $\alpha=1$ 的完全粗糙表面只反射了 30.7% 的能量,剩下 69.3% 全丢在"只碰一次"这个假设里。这是一个闭式解,拿来给整数实现当靶子最合适。

实测

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_06_3_energy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_06_3_energy.py
"""6.3 能量守恒的验证:半球整数求和、GGX 残差表、单次 vs 多次弹射、kD = 1 - F。

测度走投影圆盘:面积元 dr ds 已含 cos(theta_l) d omega_l,故 ∫ f cosθ dω = Σ f · 格面积。
四个实验:方向反射率 E(mu)、半球平均 E_avg、Kulla-Conty 多次弹射补偿、kD 归一化。

产物:out/cg_shade_06_3_residual.csv、_ms.csv、_kd.csv、_energy.svg、_energy.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_pbr import cell_of, disk_dirs, schlick_f, spec_brdf  # noqa: E402
from cg_shade_06_util import svg  # noqa: E402
from cglib import ONE, fdiv, fmul, fsqrt  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PI, GRID, M, LN2 = 205887, 64, 8, 45426     # ln2 = round(ln 2 * 2^16)
ALPHAS = [("0.05", 164), ("0.10", 655), ("0.20", 2621), ("0.30", 5898),
          ("0.50", 16384), ("0.75", 36864), ("1.00", 65536)]
MUS = [("1.00", ONE), ("0.50", 32768), ("0.25", 16384), ("0.10", 6554)]
KD_ALPHAS, F0_KD = [("0.25", 4096), ("0.50", 16384), ("1.00", 65536)], 2621


def e_spec(a2, f0, dirs, n, mu):
    """方向反射率 E(mu_v) = ∫ f_s cos(theta_l) d omega_l;v = (sqrt(1-mu^2), 0, mu)。"""
    zv, total = fsqrt(ONE - fmul(mu, mu)), 0
    for lx, ly, lz in dirs:
        hx, hz = lx + zv, lz + mu
        hl = fsqrt(fmul(hx, hx) + fmul(ly, ly) + fmul(hz, hz))
        if hl:
            total += spec_brdf(a2, lz, mu, fdiv(hz, hl),
                               fdiv(fmul(zv, hx) + fmul(mu, hz), hl), f0)
    return fmul(total, cell_of(n))


def lambert_kd(f0, dirs, n, mu, from_f):
    """漫反射项 ∫ (kD/pi) cos(theta_l) d omega_l,albedo = 1。

    from_f 为真时 kD = 1 - F(v·h)(方向相关),否则 kD = 1 - f0(常数)。
    注意圆盘度量已经含 cos,这里只补 1/pi——多乘一个 cos 会让 0.96 掉到 0.64。
    """
    zv, total = fsqrt(ONE - fmul(mu, mu)), 0
    for lx, ly, lz in dirs:
        hx, hz = lx + zv, lz + mu
        hl = fsqrt(fmul(hx, hx) + fmul(ly, ly) + fmul(hz, hz))
        vh = fdiv(fmul(zv, hx) + fmul(mu, hz), hl)
        total += fdiv(ONE - (schlick_f(f0, vh) if from_f else f0), PI)
    return fmul(total, cell_of(n))


def e_avg(a2, dirs, n, m=M, f0=ONE):
    """半球平均反射率 E_avg = 2 ∫_0^1 E(mu) mu d mu(mu 中点法)。"""
    total = 0
    for j in range(m):
        mu = (2 * j + 1) * ONE // (2 * m)
        total += fmul(e_spec(a2, f0, dirs, n, mu), mu)
    return fmul(2 * ONE // m, total)


def e_ms(a2, dirs, n, mu, eavg):
    """Kulla-Conty 多次弹射项的方向反射率:f_ms = (1-E(mu_l))(1-E(mu_v)) / (pi (1-E_avg))。

    恒等式:E_ms(mu) 应当恰好等于 1 - E(mu)。E(mu_l) 按 l 的 z 分量查表,同一 z 只算一次。
    """
    ev, total, tab = e_spec(a2, ONE, dirs, n, mu), 0, {}
    for _, _, lz in dirs:
        if lz not in tab:
            tab[lz] = e_spec(a2, ONE, dirs, n, lz)
        total += ONE - tab[lz]
    return fdiv(fmul(fmul(total, cell_of(n)), ONE - ev), fmul(PI, ONE - eavg))


def main():
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    dirs = disk_dirs(GRID)

    resid = [(lab, e_spec(a2, ONE, dirs, GRID, ONE)) for lab, a2 in ALPHAS]
    assert all(e <= ONE + ONE // 100 for _, e in resid), "E 超过能量上界 1% 以上"
    assert abs(resid[-1][1] - (ONE - LN2)) < 328, "alpha = 1 未落到解析值 1 - ln2"

    ms = [(lab, e_avg(a2, dirs, GRID)) for lab, a2 in ALPHAS]
    assert all(ms[i][1] > ms[i + 1][1] for i in range(len(ms) - 1)), "E_avg 未随粗糙度下降"
    assert all(ea <= ONE for _, ea in ms), "E_avg 超过 1"

    # 补上多次弹射项后回到 1:同一个 mu 上比,别拿平均量去比方向量
    comp = []
    for (lab, ea), (_, a2) in zip(ms, ALPHAS):
        e1 = e_spec(a2, ONE, dirs, GRID, ONE)
        add = e_ms(a2, dirs, GRID, ONE, ea)
        comp.append((lab, ea, e1, add, e1 + add))
    assert all(abs(t - ONE) < ONE // 100 for *_, t in comp), "多次弹射未把总能量补回 1"

    kd = []
    for alab, a2 in KD_ALPHAS:
        for mlab, mu in MUS:
            es = e_spec(a2, F0_KD, dirs, GRID, mu)
            df, dc = lambert_kd(F0_KD, dirs, GRID, mu, True), lambert_kd(F0_KD, dirs, GRID, mu, False)
            kd.append((alab, mlab, es, df, dc, es + df, es + dc))
    assert all(kd[i][5] <= ONE and kd[i][6] <= ONE for i in (0, 4, 8)), "法线入射就该守住"
    assert all(kd[i][5] <= kd[i][6] for i in range(len(kd))), "kD = 1-F 应比 1-F0 更紧"
    assert kd[3][5] > ONE, "alpha = 0.25 掠射本该超界——这正是要报的缺陷"

    cglib.write_csv(os.path.join(out, "cg_shade_06_3_residual.csv"),
                    ["alpha", "E_q16", "residual_q16"], [(lab, e, e - ONE) for lab, e in resid])
    cglib.write_csv(os.path.join(out, "cg_shade_06_3_ms.csv"),
                    ["alpha", "E_avg_q16", "deficit_q16", "E_mu1_q16", "E_ms_q16", "total_q16"],
                    [(lab, ea, ONE - ea, e1, ad, t) for lab, ea, e1, ad, t in comp])
    cglib.write_csv(os.path.join(out, "cg_shade_06_3_kd.csv"),
                    ["alpha", "mu_v", "E_spec_q16", "D_kd_from_F_q16", "D_kd_from_F0_q16",
                     "total_from_F_q16", "total_from_F0_q16"], kd)
    size = svg(os.path.join(out, "cg_shade_06_3_energy.svg"),
               [("E(mu=1)  单次弹射", "#ff8a5c", ONE,
                 [(i * (ONE // (len(resid) - 1)), e) for i, (_, e) in enumerate(resid)]),
                ("E_avg  半球平均", "#5cd6ff", ONE,
                 [(i * (ONE // (len(ms) - 1)), e) for i, (_, e) in enumerate(ms)])],
               "6.3  GGX 的方向反射率与半球平均(F = 1,能量上界 = 1)")
    data = {"grid": GRID, "m": M, "directions": len(dirs), "ln2": LN2,
            "residual": [{"alpha": lab, "E_q16": e} for lab, e in resid],
            "multiple_scatter": [{"alpha": lab, "E_avg_q16": ea, "E_mu1_q16": e1,
                                  "E_ms_q16": ad, "total_q16": t} for lab, ea, e1, ad, t in comp],
            "kd": [{"alpha": al, "mu_v": ml, "E_spec_q16": es, "D_from_F_q16": df,
                    "D_from_F0_q16": dc, "total_from_F_q16": tf, "total_from_F0_q16": tc}
                   for al, ml, es, df, dc, tf, tc in kd],
            "svg_bytes": size}
    with open(os.path.join(out, "cg_shade_06_3_energy.json"), "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)

    print(f"投影圆盘 {len(dirs)} 个方向,格面积 {cell_of(GRID)}/65536")
    print("残差表(mu=1,F=1,上界 1.0000):")
    for lab, e in resid:
        print(f"  alpha={lab}  E={e / ONE:.4f}  残差={((e - ONE) / ONE):+.4f}")
    print(f"  解析锚点 alpha=1:1 - ln2 = {(ONE - LN2) / ONE:.6f},实算 {resid[-1][1] / ONE:.6f}")
    print("单次 vs 多次弹射:")
    for lab, ea, e1, ad, t in comp:
        print(f"  alpha={lab}  单次 E_avg={ea / ONE:.4f}(缺 {((ONE - ea) / ONE):.4f})"
              f"  E(mu=1)={e1 / ONE:.4f} + 多次 {ad / ONE:.4f} = {t / ONE:.4f}")
    print("kD 归一化(F0 = 0.04,albedo = 1,上界 1.0000):")
    for al, ml, es, _, _, tf, tc in kd:
        print(f"  alpha={al}  mu={ml}  E_spec={es / ONE:.4f}  kD=1-F {tf / ONE:.4f}"
              f"  kD=1-F0 {tc / ONE:.4f}" + ("  <- 超界" if tf > ONE else ""))
    print(f"svg : out/cg_shade_06_3_energy.svg ({size} bytes)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
投影圆盘 3228 个方向,格面积 64/65536
残差表(mu=1,F=1,上界 1.0000):
  alpha=0.05  E=1.0003  残差=+0.0003
  alpha=0.10  E=0.9887  残差=-0.0113
  alpha=0.20  E=0.9486  残差=-0.0514
  alpha=0.30  E=0.8789  残差=-0.1211
  alpha=0.50  E=0.6899  残差=-0.3101
  alpha=0.75  E=0.4659  残差=-0.5341
  alpha=1.00  E=0.3084  残差=-0.6916
  解析锚点 alpha=1:1 - ln2 = 0.306854,实算 0.308426
单次 vs 多次弹射:
  alpha=0.05  单次 E_avg=0.9932(缺 0.0068)  E(mu=1)=1.0003 + 多次 -0.0007 = 0.9995
  alpha=0.10  单次 E_avg=0.9752(缺 0.0248)  E(mu=1)=0.9887 + 多次 0.0114 = 1.0000
  alpha=0.20  单次 E_avg=0.9176(缺 0.0824)  E(mu=1)=0.9486 + 多次 0.0515 = 1.0001
  alpha=0.30  单次 E_avg=0.8463(缺 0.1537)  E(mu=1)=0.8789 + 多次 0.1214 = 1.0002
  alpha=0.50  单次 E_avg=0.6924(缺 0.3076)  E(mu=1)=0.6899 + 多次 0.3108 = 1.0007
  alpha=0.75  单次 E_avg=0.5172(缺 0.4828)  E(mu=1)=0.4659 + 多次 0.5357 = 1.0016
  alpha=1.00  单次 E_avg=0.3792(缺 0.6208)  E(mu=1)=0.3084 + 多次 0.6938 = 1.0022
kD 归一化(F0 = 0.04,albedo = 1,上界 1.0000):
  alpha=0.25  mu=1.00  E_spec=0.0367  kD=1-F 0.9998  kD=1-F0 0.9999
  alpha=0.25  mu=0.50  E_spec=0.0564  kD=1-F 1.0147  kD=1-F0 1.0196  <- 超界
  alpha=0.25  mu=0.25  E_spec=0.1096  kD=1-F 1.0602  kD=1-F0 1.0729  <- 超界
  alpha=0.25  mu=0.10  E_spec=0.1568  kD=1-F 1.0996  kD=1-F0 1.1201  <- 超界
  alpha=0.50  mu=1.00  E_spec=0.0276  kD=1-F 0.9908  kD=1-F0 0.9908
  alpha=0.50  mu=0.50  E_spec=0.0371  kD=1-F 0.9955  kD=1-F0 1.0004
  alpha=0.50  mu=0.25  E_spec=0.0580  kD=1-F 1.0085  kD=1-F0 1.0212  <- 超界
  alpha=0.50  mu=0.10  E_spec=0.0780  kD=1-F 1.0208  kD=1-F0 1.0412  <- 超界
  alpha=1.00  mu=1.00  E_spec=0.0123  kD=1-F 0.9755  kD=1-F0 0.9756
  alpha=1.00  mu=0.50  E_spec=0.0188  kD=1-F 0.9772  kD=1-F0 0.9821
  alpha=1.00  mu=0.25  E_spec=0.0270  kD=1-F 0.9776  kD=1-F0 0.9903
  alpha=1.00  mu=0.10  E_spec=0.0356  kD=1-F 0.9783  kD=1-F0 0.9988
svg : out/cg_shade_06_3_energy.svg (1026 bytes)

残差表($F$ 取 1,剥掉菲涅尔的影响)里有两处要解释。$\alpha=0.05$ 读到 1.0003,超了 0.0003——这不是模型越界,而是方向集的网格分辨率:那一档的 NDF 半高角只有 0.76 度(6.2 量过),$64\times64$ 的圆盘格采不到这个峰,6.1 的 $D$ 归一化在同一个 $\alpha$ 上读到 0.9927 是同一个原因的另一面。$\alpha=1$ 的实算 0.308426 与解析值 0.306854 差 0.0016(0.5%),剩下的差全部来自 3228 个方向对圆盘面积的离散近似。

单次 vs 多次弹射那一组是本节的主结果。$\alpha=0.5$ 时半球平均只剩 0.6924,缺 30.8%;补上 Kulla-Conty 的多次弹射项

$$ f_{ms}=\frac{\big(1-E(\mu_l)\big)\big(1-E(\mu_v)\big)}{\pi\big(1-E_{avg}\big)} $$

之后,方向反射率回到 1.0007。这条恒等式 $E_{ms}(\mu)=1-E(\mu)$ 是解析的,数值验证落在 0.2% 以内——“欠能量"不是模型的缺陷,只是漏了一项。

kD = 1 − F 守得住上界吗

漫反射项一般写成 $k_D\cdot\text{albedo}/\pi$,$k_D$ 取多少有两种流派:常数 $k_D=1-F_0$,或方向相关的 $k_D=1-F(v\cdot h)$。直觉上后者更对,因为它把掠射角变大的菲涅尔反射也算进去了。实测结论有四条:

  • 法线入射都守得住:三档 $\alpha$ 的合计分别是 0.9998、0.9908、0.9755。
  • 掠射角都会破:$\alpha=0.25$、$\mu_v=0.10$ 时 $k_D=1-F$ 读到 1.0996,$k_D=1-F_0$ 读到 1.1201。
  • $k_D=1-F$ 始终更紧:12 行里没有一次例外,最大能省下 2 个百分点。
  • 越光滑越危险:$\alpha=1$ 全程不超界,$\alpha=0.25$ 从 $\mu_v=0.5$ 就开始超。

原因在 6.1 那个分母。$1-F$ 只扣掉了"这次没反射出去的能量”,但单次弹射的 GGX 在掠射角还被 $G$ 削掉一大块,$k_D$ 没有为 $G$ 留任何余量。所以严格说:$k_D=1-F$ 是更好的近似,不是能量上界的保证。要保住上界,要么给 $k_D$ 再乘一个遮蔽因子,要么走上面那条多次弹射补偿的路。

P 层

// file: lantern/core/src/lantern/cg_shade_06_3_energy.ts
// tier: P
// verify: node --test 'core/test/cg_shade_06_*.test.ts'
// 6.3 能量守恒验证的 TypeScript 侧:方向反射率、漫反射项、半球平均、Kulla-Conty
// 多次弹射补偿。与 sim/cg_shade_06_3_energy.py 逐字对齐。
//
// 测度全部走投影圆盘:面积元 dr ds 已经含了 cos(theta_l) d omega_l,所以
// ∫ f cos(theta) d omega = Σ f * 格面积——积分式里不再出现余弦因子。
// 这一条是本模块最容易写错的地方:多乘一个 cos 会让漫反射项从 0.96 掉到 0.64。

import { ONE, fdiv, fmul, fsqrt } from "./fixed.ts";
import { type Dir, PI, cellOf, schlickF, specBrdf } from "./cg_shade_06_pbr.ts";

/** 方向反射率 E(mu_v) = ∫ f_s cos(theta_l) d omega_l。v = (sqrt(1-mu^2), 0, mu)。 */
export function eSpec(a2: bigint, f0: bigint, dirs: Dir[], n: number, mu: bigint): bigint {
  const zv = fsqrt(ONE - fmul(mu, mu));
  let total = 0n;
  for (const [lx, ly, lz] of dirs) {
    const hx = lx + zv;
    const hz = lz + mu;
    const hl = fsqrt(fmul(hx, hx) + fmul(ly, ly) + fmul(hz, hz));
    if (hl === 0n) continue;
    const vh = fdiv(fmul(zv, hx) + fmul(mu, hz), hl);
    total += specBrdf(a2, lz, mu, fdiv(hz, hl), vh, f0);
  }
  return fmul(total, cellOf(n));
}

/** 漫反射项 ∫ (kD / pi) cos(theta_l) d omega_l(albedo = 1);fromF 为真时 kD = 1 - F。 */
export function lambertKd(
  f0: bigint, dirs: Dir[], n: number, mu: bigint, fromF: boolean,
): bigint {
  const zv = fsqrt(ONE - fmul(mu, mu));
  let total = 0n;
  for (const [lx, ly, lz] of dirs) {
    const hx = lx + zv;
    const hz = lz + mu;
    const hl = fsqrt(fmul(hx, hx) + fmul(ly, ly) + fmul(hz, hz));
    const vh = fdiv(fmul(zv, hx) + fmul(mu, hz), hl);
    total += fdiv(ONE - (fromF ? schlickF(f0, vh) : f0), PI);
  }
  return fmul(total, cellOf(n));
}

/** 半球平均反射率 E_avg = 2 ∫_0^1 E(mu) mu d mu(mu 中点法)。 */
export function eAvg(
  a2: bigint, dirs: Dir[], n: number, m: number = 8, f0: bigint = ONE,
): bigint {
  let total = 0n;
  for (let j = 0; j < m; j += 1) {
    const mu = ((2n * BigInt(j) + 1n) * ONE) / BigInt(2 * m);
    total += fmul(eSpec(a2, f0, dirs, n, mu), mu);
  }
  return fmul((2n * ONE) / BigInt(m), total);
}

/** Kulla-Conty 多次弹射项:f_ms = (1-E(mu_l))(1-E(mu_v)) / (pi (1-E_avg))。 */
export function eMs(
  a2: bigint, dirs: Dir[], n: number, mu: bigint, eavg: bigint,
): bigint {
  const ev = eSpec(a2, ONE, dirs, n, mu);
  const tab = new Map<bigint, bigint>();
  let total = 0n;
  for (const [, , lz] of dirs) {
    let e = tab.get(lz);
    if (e === undefined) {
      e = eSpec(a2, ONE, dirs, n, lz);
      tab.set(lz, e);
    }
    total += ONE - e;
  }
  return fdiv(fmul(fmul(total, cellOf(n)), ONE - ev), fmul(PI, ONE - eavg));
}

P 层把四个积分函数逐位复现。测试里有一条专门盯着本节最容易写错的那件事:“圆盘度量已经含余弦”——lambertKd 在法线入射的结果必须落在 0.9 以上;一旦有人补了第二次 $\cos$,它会掉到 0.64 附近,测试立刻红。

阅读导航:上一节:6.2 金属度-粗糙度工作流 · 下一节:7.1 线性空间与 sRGB 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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