6.2 金属度-粗糙度工作流

金属度-粗糙度工作流只用两个标量描述一个表面:基础色在绝缘体时是漫反射 albedo、在金属时是镜面 F0,粗糙度平方后映射成 GGX 的 alpha。本节扫过粗糙度 0/0.25/0.5/0.75/1,量出 NDF 峰值从 809 掉到 0.32、半高角从 0.76 度张到 90 度,并说明为什么高光宽度要用 NDF 半高角而不是 BRDF 的半高全宽。

6.2 金属度-粗糙度工作流

6.1 的 BRDF 里有四个参数:$\alpha$、$F_0$,再加上反照率。美术不可能逐个去调它们,所以实时管线把它们压成两个通道——基础色(base color)和粗糙度(roughness),金属度(metalness)通常只是一个 0/1 的开关或一张黑白遮罩。那篇讲实时 PBR 与阴影贴图的工程实现见 实时 PBR 与阴影贴图 ;本节把这两个通道到 BRDF 参数的映射写成整数,并量出它对高光形状的影响。

基础色的两重身份

同一张贴图,在不同金属度下读法完全不同:

  • 绝缘体(metalness $=0$,如塑料、木头):基础色是漫反射反照率,镜面项固定取 $F_0=0.04$。
  • 金属(metalness $=1$,如铁、金):金属没有漫反射——入射光要么被反射要么被吸收,不存在次表面散射——所以漫反射反照率为 0,基础色直接当镜面 $F_0$。

中间值用线性插值:

$$ \text{albedo}=(1-m)\cdot\text{base},\qquad F_0=0.04+m\cdot(\text{base}-0.04) $$

用基础色 RGB $(220,90,70)$ 扫一遍金属度:

$m$漫反射 albedo$F_0$
0$(220,90,70)$$(0.040,0.040,0.040)$
0.25$(165,67,52)$$(0.246,0.118,0.099)$
0.5$(110,45,35)$$(0.451,0.196,0.157)$
0.75$(55,22,17)$$(0.657,0.275,0.216)$
1$(0,0,0)$$(0.863,0.353,0.275)$

这条插值带来一个常被忽略的后果:金属的 $F_0$ 是有颜色的(红通道 0.863、绿 0.353、蓝 0.275),所以金属的高光带色;而绝缘体的 $F_0$ 是灰色的 0.04,高光永远不带色。看到"金色高光"和"塑料白高光"的差别,来源就在这一列。

粗糙度到 alpha

GGX 的 $\alpha$ 不是粗糙度本身。工业界统一用平方映射:

$$ \alpha=\text{roughness}^2 $$

平方让粗糙度滑块的手感线性化——$D$ 的形状对 $\alpha$ 高度非线性,直接拿粗糙度当 $\alpha$ 会让滑块前 90% 几乎没变化、最后 10% 突然从镜面糊成一片。平方之后 $\alpha$ 的取值范围被压缩到低端,正好对上人眼对高光宽度的感知。

整数里还有一道额外的下夹。粗糙度 0 意味着完美镜面,$\alpha=0$、$\alpha^2=0$,6.1 的 ggx_d 会立刻除零;Q16.16 里 $\alpha^2$ 最小能表示 $26/65536\approx4\times10^{-4}$,对应 $\alpha\approx0.02$。所以本节把 $\alpha$ 下夹到 1311/65536 = 0.020。这不是近似,是定点表示的硬下限:再小的 $\alpha$ 在这个格式里根本不存在。

参数扫描:强度掉得比宽度涨得快

扫描取五个粗糙度档,每档量三个数:NDF 峰值 $D(0)=1/(\pi\alpha^2)$(高光强度)、NDF 半高角(高光宽度)、镜面方向上的实测 $f_s$。

半高角是解 $D(n\cdot h)=D(1)/2$ 得到的。把 $D(1)=1/(\pi\alpha^2)$ 代进去,令 $t=\sqrt2,\alpha^2$,再代回 $t=(n\cdot h)^2(\alpha^2-1)+1$:

$$ (n\cdot h)^2_{\text{半高}}=\frac{\sqrt2,\alpha^2-1}{\alpha^2-1},\qquad \theta_{\text{半高}}=\arcsin\sqrt{1-(n\cdot h)^2_{\text{半高}}} $$

$\alpha=1$ 时 $D$ 是常数,整个半球都在半高以内,半高角退化成 90 度——这不是数值故障,而是"完全粗糙"的准确含义。

为什么不用 $f_s$ 的半高全宽? 因为高光越糙,$f_s$ 里那个 $1/\big(4(n\cdot l)(n\cdot v)\big)$ 因子越把能量推向掠射,$f_s$ 的全局峰会从镜面方向一路跑到地平线上去,半高全宽就不再是"高光宽度"了。NDF 的半高角只描述分布本身,与观察方向无关,才是可比的宽度口径。6.1 已经量到过这个偏移(峰在 $\sin=-0.5312$ 而不是 $-0.5$),到了粗糙端它会彻底吃掉峰位。

实测

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_06_2_metalness.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_06_2_metalness.py
"""6.2 金属度-粗糙度工作流:基础色的两重身份、粗糙度到 alpha 的映射、参数扫描。

  1. 粗糙度 -> alpha = roughness^2 -> alpha^2;roughness = 0 是完美镜面,alpha 下夹到
     0.02——Q16.16 里 alpha^2 再小会下溢成 0,NDF 直接除零,这是定点表示的硬下限。
  2. 基础色的两重身份:绝缘体时它是漫反射 albedo(乘 1-m),金属时它是镜面 F0。
  3. 粗糙度扫描 {0, 0.25, 0.5, 0.75, 1}:NDF 峰值(强度)+ NDF 半高角(宽度)
     + 镜面方向实测 f_s。宽度用半高角而不是 f_s 的半高全宽——高光越糙,f_s 的
     1/(4 (n·l)(n·v)) 因子越把能量推向掠射,f_s 的全局峰会跑到地平线上去,
     半高全宽就不再是「高光宽度」了。

产物:out/cg_shade_06_2_sweep.csv、out/cg_shade_06_2_metal.csv、
      out/cg_shade_06_2_sweep.svg、out/cg_shade_06_2_metalness.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 ggx_d, lobe  # 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__)))
ALPHA_MIN = 1311          # alpha 的下夹 0.02:alpha^2 = 26,再小 Q16.16 就装不下
DIEL_F0 = 2621            # 绝缘体 F0 = 0.04
SQRT2 = 92682             # round(sqrt(2) * 2^16)
BASE8 = (220, 90, 70)     # 基础色(8 位通道)
ROUGH = [("0", 0), ("0.25", 16384), ("0.5", 32768), ("0.75", 49152), ("1", 65536)]
METAL = [("0", 0), ("0.25", 16384), ("0.5", 32768), ("0.75", 49152), ("1", 65536)]


def d_half_angle(a2):
    """NDF 半高角(度,Q16.16):解 D(n·h) = D(1)/2。

    D(1) = 1/(pi a2),令 a2/(pi t^2) = 1/(2 pi a2) 得 t = sqrt(2) a2,代回
    t = (n·h)^2 (a2-1) + 1 解出 (n·h)^2 = (sqrt(2) a2 - 1)/(a2 - 1);
    sin(半高角) = sqrt(1 - (n·h)^2),再 arcsin 回角度。
    """
    if a2 >= ONE:
        return 90 * ONE                      # alpha = 1:D 是常数,整个半球都是半高以内
    nh2 = fdiv(fmul(SQRT2, a2) - ONE, a2 - ONE)
    return 90 * ONE if nh2 <= 0 else asin_deg(fsqrt(ONE - nh2))


def mirror_peak(a2, f0, rv=ONE // 2):
    """镜面方向(r = -rv)处的 f_s:高光中心的实测强度,从 lobe 的采样网格里取回。"""
    rows = lobe(a2, f0, rv)
    return rows[(ONE - rv) // (2 * ONE // (len(rows) - 1))]


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

    # 粗糙度 -> alpha = r^2 -> alpha^2;F0 固定 0.04,只看波瓣形状随 alpha 的变化
    sweep = []
    for lab, r in ROUGH:
        alpha = max(fmul(r, r), ALPHA_MIN)
        a2 = fmul(alpha, alpha)
        fs = mirror_peak(a2, DIEL_F0)[3]
        sweep.append((lab, alpha, a2, ggx_d(a2, ONE), d_half_angle(a2), fs))
    assert sweep[2][2] == 4096, "粗糙度 0.5 应落到 6.1 用的 alpha^2 = 4096"
    assert all(sweep[i][3] > sweep[i + 1][3] for i in range(4)), "NDF 峰值未随粗糙度下降"
    assert all(sweep[i][4] < sweep[i + 1][4] for i in range(4)), "半高角未随粗糙度变宽"

    # 基础色的两重身份:漫反射 albedo(乘 1-m)与镜面 F0(0.04 与基础色按 m 插值)
    base_q16 = [(c << 16) // 255 for c in BASE8]
    metal = [(lab, [((c * (ONE - m)) >> 16) for c in BASE8],
              [DIEL_F0 + fmul(b - DIEL_F0, m) for b in base_q16]) for lab, m in METAL]
    assert metal[0][1] == list(BASE8) and metal[-1][1] == [0, 0, 0], "绝缘体 albedo 应为基础色本身"
    assert metal[-1][2] == base_q16, "金属的 F0 应等于基础色"

    cglib.write_csv(os.path.join(out, "cg_shade_06_2_sweep.csv"),
                    ["roughness", "alpha_q16", "alpha2_q16", "d_peak_q16",
                     "half_angle_deg_q16", "mirror_fs_q16"],
                    [(lab, a, a2, dp, ha, fs) for lab, a, a2, dp, ha, fs in sweep])
    cglib.write_csv(os.path.join(out, "cg_shade_06_2_metal.csv"),
                    ["metalness", "diffuse_r", "diffuse_g", "diffuse_b",
                     "f0_r_q16", "f0_g_q16", "f0_b_q16"],
                    [(lab, a[0], a[1], a[2], f[0], f[1], f[2]) for lab, a, f in metal])
    top = max(s[3] for s in sweep)
    wide = max(s[4] for s in sweep)
    size = svg(os.path.join(out, "cg_shade_06_2_sweep.svg"),
               [("NDF 峰值 D(0)  (max %.0f)" % (top / ONE), "#ff8a5c", top,
                 [(i * (ONE // (len(sweep) - 1)), s[3]) for i, s in enumerate(sweep)]),
                ("NDF 半高角  (max %.1f 度)" % (wide / ONE), "#5cd6ff", wide,
                 [(i * (ONE // (len(sweep) - 1)), s[4]) for i, s in enumerate(sweep)])],
               "6.2  高光强度与宽度随粗糙度的变化(各自按最大值归一化)")
    data = {"alpha_min": ALPHA_MIN, "diel_f0": DIEL_F0, "base8": list(BASE8),
            "sweep": [{"roughness": lab, "alpha_q16": a, "alpha2_q16": a2,
                       "d_peak_q16": dp, "half_angle_deg_q16": ha, "mirror_fs_q16": fs}
                      for lab, a, a2, dp, ha, fs in sweep],
            "metal": [{"metalness": lab, "diffuse8": alb, "f0_q16": f0} for lab, alb, f0 in metal],
            "svg_bytes": size}
    with open(os.path.join(out, "cg_shade_06_2_metalness.json"), "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)

    print(f"alpha = roughness^2(下夹 {ALPHA_MIN}/65536 = {ALPHA_MIN / ONE:.3f})")
    for lab, a, a2, dp, ha, fs in sweep:
        print(f"  roughness={lab:4s}  alpha^2={a2:6d}  D(0)={dp / ONE:9.2f}  "
              f"半高角={ha / ONE:6.2f} 度(全宽 {2 * ha / ONE:6.2f} 度)  "
              f"镜面 f_s={fs / ONE:.4f}")
    print("基础色的两重身份(金属度 m):")
    for lab, alb, f0 in metal:
        print(f"  m={lab:4s}  漫反射 albedo={alb}  F0={[round(v / ONE, 3) for v in f0]}")
    print(f"svg : out/cg_shade_06_2_sweep.svg ({size} bytes)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
alpha = roughness^2(下夹 1311/65536 = 0.020)
  roughness=0     alpha^2=    26  D(0)=   809.09  半高角=  0.76 度(全宽   1.52 度)  镜面 f_s=10.7899
  roughness=0.25  alpha^2=   256  D(0)=    81.51  半高角=  2.37 度(全宽   4.74 度)  镜面 f_s=1.0864
  roughness=0.5   alpha^2=  4096  D(0)=     5.09  半高角=  9.64 度(全宽  19.27 度)  镜面 f_s=0.0672
  roughness=0.75  alpha^2= 20736  D(0)=     1.01  半高角= 25.88 度(全宽  51.76 度)  镜面 f_s=0.0127
  roughness=1     alpha^2= 65536  D(0)=     0.32  半高角= 90.00 度(全宽 180.00 度)  镜面 f_s=0.0036
基础色的两重身份(金属度 m):
  m=0     漫反射 albedo=[220, 90, 70]  F0=[0.04, 0.04, 0.04]
  m=0.25  漫反射 albedo=[165, 67, 52]  F0=[0.246, 0.118, 0.099]
  m=0.5   漫反射 albedo=[110, 45, 35]  F0=[0.451, 0.196, 0.157]
  m=0.75  漫反射 albedo=[55, 22, 17]  F0=[0.657, 0.275, 0.216]
  m=1     漫反射 albedo=[0, 0, 0]  F0=[0.863, 0.353, 0.275]
svg : out/cg_shade_06_2_sweep.svg (1013 bytes)

三列数字各说一件事。强度 $D(0)$ 从 809 掉到 0.32,跨了 2500 倍——这就是粗糙表面高光"糊掉"的量化形式。宽度从 0.76 度张到 90 度,只涨了 118 倍,远没有强度那么剧烈,所以调粗糙度时人眼先看到的是"亮斑变暗",而不是"亮斑变宽"。镜面 $f_s$ 从 10.79 掉到 0.0036,跌幅比 $D(0)$ 还大,因为 $f_s$ 额外乘了 $G$ 与 $F$:粗糙表面在掠射角被遮蔽项削得更狠。

还有一条横向对照:粗糙度 0.5 这一行落到 $\alpha^2=4096$,与 6.1 脚本里写死的 A2 = 4096 是同一个值——两节的数字是同一套参数下的产物,不是各算各的。

P 层:整数 arcsin

P 层多了一个 6.1 没有的东西:角度。定点库里没有三角函数,但"高光宽度是几度"必须说人话,所以得自己实现 arcsin。

// file: lantern/core/src/lantern/cg_shade_06_2_metal.ts
// tier: P
// verify: node --test 'core/test/cg_shade_06_*.test.ts'
// 6.2 金属度-粗糙度工作流的 TypeScript 侧:粗糙度 -> alpha 的映射、基础色的两重身份、
// NDF 半高角(需要整数 arcsin,而定点库里没有三角函数)。与 sim/cg_shade_06_2_metalness.py 对齐。
//
// arcsin 的实现路径:arcsin(x) = atan(x / sqrt(1 - x^2)),atan 用 Hastings 多项式
// atan(t) ≈ (pi/4) t + t(1 - t)(0.2447 + 0.0663 t),|t| <= 1 时误差 < 0.0015 rad。

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

export const ALPHA_MIN = 1311n; // alpha 下夹 0.02:alpha^2 = 26,再小 Q16.16 装不下
export const DIEL_F0 = 2621n; // 绝缘体 F0 = 0.04
export const SQRT2 = 92682n; // round(sqrt(2) * 2^16)
const PI2 = 102944n;
const Q4PI = 51472n;
const DEG = 3754936n; // round(180/pi * 2^16)
const H1 = 16037n;
const H2 = 4345n;

/** 粗糙度 -> alpha = roughness^2,并下夹到 ALPHA_MIN。 */
export function alphaOfRoughness(r: bigint): bigint {
  const a = fmul(r, r);
  return a < ALPHA_MIN ? ALPHA_MIN : a;
}

/** atan(x),Q16.16 弧度。 */
export function atanRad(x: bigint): bigint {
  if (x < 0n) return -atanRad(-x);
  if (x > ONE) return PI2 - atanRad(fdiv(ONE, x));
  return fmul(Q4PI, x) + fmul(fmul(x, ONE - x), H1 + fmul(H2, x));
}

/** arcsin(x) -> 度数(Q16.16)。 */
export function asinDeg(x: bigint): bigint {
  if (x >= ONE) return 90n * ONE;
  if (x <= -ONE) return -90n * ONE;
  return fmul(atanRad(fdiv(x, fsqrt(ONE - fmul(x, x)))), DEG);
}

/** NDF 半高角:解 D(n·h) = D(1)/2 得 (n·h)^2 = (sqrt2 a2 - 1)/(a2 - 1)。 */
export function dHalfAngleDeg(a2: bigint): bigint {
  if (a2 >= ONE) return 90n * ONE; // alpha = 1:D 是常数,整个半球都在半高以内
  const nh2 = fdiv(fmul(SQRT2, a2) - ONE, a2 - ONE);
  return nh2 <= 0n ? 90n * ONE : asinDeg(fsqrt(ONE - nh2));
}

/** 平面内波瓣:v 固定在 (rv, 0, sqrt(1-rv^2)),l 扫过整个平面。 */
export function lobe(a2: bigint, f0: bigint, rv: bigint, n: number = 128): bigint[][] {
  const zv = fsqrt(ONE - fmul(rv, rv));
  const step = (2n * ONE) / BigInt(n);
  const rows: bigint[][] = [];
  for (let k = 0; k <= n; k += 1) {
    let r = -ONE + BigInt(k) * step;
    if (r < 1n - ONE) r = 1n - ONE; // 避开 n·l = 0 的掠射端点
    if (r > ONE - 1n) r = ONE - 1n;
    const z = fsqrt(ONE - fmul(r, r));
    const hx = r + rv;
    const hz = z + zv;
    const hl = fsqrt(fmul(hx, hx) + fmul(hz, hz));
    rows.push([BigInt(k), r, z,
      specBrdf(a2, z, zv, fdiv(hz, hl), fdiv(fmul(rv, hx) + fmul(zv, hz), hl), f0)]);
  }
  return rows;
}

/** 镜面方向(r = -rv)处的 f_s:高光中心的实测强度。 */
export function mirrorPeak(a2: bigint, f0: bigint, rv: bigint = ONE / 2n): bigint {
  const rows = lobe(a2, f0, rv);
  const idx = (ONE - rv) / ((2n * ONE) / BigInt(rows.length - 1));
  return rows[Number(idx)][3];
}

/** 基础色的两重身份之一:绝缘体时它是漫反射 albedo(乘 1-m),8 位通道。 */
export function mixDiffuse(base8: number, m: bigint): number {
  return Number((BigInt(base8) * (ONE - m)) >> 16n);
}

/** 基础色的另一重身份:金属时它是镜面 F0(从 0.04 按 m 插值到基础色)。 */
export function mixF0(baseQ16: bigint, m: bigint): bigint {
  return DIEL_F0 + fmul(baseQ16 - DIEL_F0, m);
}

/** 8 位通道 -> Q16.16。 */
export function chanToQ16(c: number): bigint {
  return (BigInt(c) << 16n) / 255n;
}

export type { Dir };

atanRad 的 Hastings 多项式在第一版里被写成了 fmul(Q4PI, x) - fmul(fmul(x, ONE - x), ...),符号反了,$\arcsin(0.5)$ 算出 22.02 度而不是 30 度——整段代码看起来完全合理,只有和参考值对照才露馅。P 层测试因此专门钉了一条:在 $x\in{0.1661,0.25,0.3716,0.5,0.866}$ 五个点上,定点 arcsin 与浮点参考的差必须小于 0.1 度(实测最大 0.0896 度)。跨语言比对只能证明两边一致,证明不了两边都对,这条参考值断言是补上前者的。

阅读导航:上一节:6.1 微表面理论 · 下一节:6.3 能量守恒的验证 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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