2.2 常见 BRDF 的定点化
上一节的整数积分器是一把尺子。本节用它去量三种最常见的 BRDF——Lambert、Phong、Blinn-Phong——在全整数下各丢多少能量。站内的 实时 PBR 与阴影贴图 关心这些模型跑在 GPU 上快不快;本节关心它们在定点下守恒不守恒,以及归一化系数怎么写成一个整数。
三种 BRDF 的整数形式
入射方向取法线时,三种模型的反射向量都等于法线,于是可以共用 2.1 的同一组 512 方向:
- Lambert:$f_r = \rho/\pi$。$1/\pi$ 取整成
INV_PI = round(ONE/π) = 20861。 - Phong:$f_r = k_s\cdot\frac{n+2}{2\pi}(R\cdot V)^n$。$n=16$ 时归一化系数 $\frac{n+2}{2\pi} = 9/\pi = 9\cdot\text{INV_PI} = 187749$。
- Blinn-Phong:$f_r = k_s\cdot\frac{n+8}{8\pi}(N\cdot H)^n$。$n=16$ 时 $\frac{n+8}{8\pi} = 3/\pi = 3\cdot\text{INV_PI} = 62583$。
三个归一化系数都不是新常数,而是同一个 $1/\pi$ 的整数倍——这正是把「物理量」和「定点表示」分开的好处:只在 $1/\pi$ 处取一次整,其余全部是整数乘法。
指数用整数平方链
$n=16 = 2^4$,所以 $(R\cdot V)^{16}$ 可以用 4 次平方实现:$c \to c^2 \to c^4 \to c^8 \to c^{16}$,每一步就是一次 fmul(乘完算术右移 16 位)。没有 pow、没有浮点、没有查表。同样地,Blinn 的半角向量 $N\cdot H = \cos(\theta/2) = \dfrac{1+\cos\theta}{\sqrt{2+2\cos\theta}}$ 里唯一的除法用 fsqrt 求分母,全整数。
三种 BRDF 的能量残差
把每种 BRDF 塞进 2.1 的积分器,再把结果与闭式积分并排。参照值全是整数有理数:
- Lambert:$\int (\rho/\pi)\cos\theta,\mathrm{d}\omega = \rho = 1$。
- Phong 归一化:$\frac{n+2}{2\pi}\int\cos^n\theta\cos\theta,\mathrm{d}\omega = \frac{n+2}{2\pi}\cdot\frac{2\pi}{n+2} = 1$。
- Blinn 归一化:换元 $u=\cos\theta$ 得 $I_b = \frac{1}{256}\int_0^1 u(1+u)^8,\mathrm{d}u = \frac{4097}{23040}$,故 $E = \frac{n+8}{4}I_b = \frac{4097}{3840} = 1.0669$。
- Phong 未归一化:$\int\cos^n\theta\cos\theta,\mathrm{d}\omega = \frac{2\pi}{n+2} = \pi/9$。
| BRDF | 512 方向估计 | 闭式参照 | 求积差 | ≤ ONE? |
|---|---|---|---|---|
| Lambert | 65533(999.9542‰) | 65536 | 3 | 是 |
| Phong 归一化 | 65321(996.7194‰) | 65536 | 215 | 是 |
| Blinn 归一化 | 69886(1066.3757‰) | 69922 | 36 | 否 |
| Phong 未归一化 | 22800(347.9004‰) | 22876 | 76 | 是 |
两个结论:
- 归一化 Phong 守恒——估计 65321 与参照 65536 的 215 差全部来自求积($\cos^{16}\theta$ 在 $\theta=0$ 附近很尖,64 个极角带的中点法则会略微低估峰区),不是模型误差。
- 归一化 Blinn-Phong 其实超了 1——估计 69886、参照 69922,两者都 > 65536。这不是求积假象:$\frac{n+8}{8\pi}$ 是在 $R\cdot V \approx N\cdot H$ 的近似下推出的,$N\cdot H$ 的瓣比 $R\cdot V$ 的瓣更宽,于是反射出来的能量比 1 多约 6.69%。而未归一化的 Phong 只反射 34.8%,丢掉三分之二的能量。
S 层:三种 BRDF 一次跑完
脚本在 2.1 的采样集上依次求三种 BRDF 的能量,并用闭式有理数给出参照:
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_02_2_brdf_fixed.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_02_2_brdf_fixed.py
"""2.2 常见 BRDF 的定点化(S 层)。
在 2.1 的同一个 512 方向半球采样集上,把三种 BRDF 写成**全整数**形式,
并逐个实跑它们的**能量守恒残差**:
- **Lambert**:f_r = ρ/π,1/π 的整数近似 INV_PI = round(ONE/π) = 20861。
- **Phong**:归一化系数 (n+2)/(2π),n=16 时 = 9/π = 9·INV_PI = 187749;
指数 (R·V)^n 用**整数平方链**实现——n=16 = 2⁴,故 4 次平方
c→c²→c⁴→c⁸→c¹⁶,每次都是 fmul(右移 16),无浮点、无 pow。
- **Blinn-Phong**:归一化系数 (n+8)/(8π),n=16 时 = 3/π = 3·INV_PI = 62583;
半角向量 N·H = (1+cosθ)/√(2+2cosθ) = cos(θ/2),整数里用 fsqrt 求分母。
入射方向取**法线**,故 Phong 的反射向量 = 法线、R·V = cosθ;
Blinn-Phong 的半角向量 N·H = cos(θ/2)。两种情形都被积函数各向同性,
8 个方位精确求和。
**参照值**用**闭式积分的有理数**给出(无求积),故「半球 512 方向估计」与
「闭式参照」之差就是**求积误差**(采样密度造成的),而**归一化误差**(BRDF
本身是否守恒)则看闭式参照与 ONE 的关系——两者能分开,结论才不含糊。
另给出**未归一化 Phong**(f = k_s·(R·V)^n)作为负例:它的能量远小于 1,
说明「不归一化就丢能量」。
产物:out/cg_shade_02_2_brdf.json。
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import cg_shade_02_1_brdf_energy as base # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
INV_PI = base.INV_PI # 20861
N_EXP = 16 # Phong / Blinn-Phong 指数
PHONG_NORM = 9 * INV_PI # (n+2)/(2π)·ONE,n=16 -> 9/π
BLINN_NORM = 3 * INV_PI # (n+8)/(8π)·ONE,n=16 -> 3/π
def pow_chain(c, sq):
"""整数平方链:c^(2^sq),每次一步 fmul。sq=4 -> c^16。"""
for _ in range(sq):
c = cglib.fmul(c, c)
return c
def half_cos(cz):
"""Blinn-Phong 的 N·H = cos(θ/2) = (1+cosθ)/√(2+2cosθ),整数。"""
num = ONE + cz
den = cglib.fsqrt(2 * num)
return 0 if den == 0 else cglib.fdiv(num, den)
def closed_form_reference():
"""闭式参照(全整数有理数,无求积):
- Lambert:∫ (ρ/π)·cosθ dω = ρ = ONE。
- Phong 归一化:(n+2)/(2π)·∫cosⁿθ cosθ dω = (n+2)/(2π)·2π/(n+2) = ONE。
- Blinn 归一化:(n+8)/(8π)·2π·I_b,其中
I_b = ∫₀^{π/2} cosⁿ(θ/2)·cosθ·sinθ dθ,换元 u = cosθ 得
I_b = (1/256)∫₀¹ u(1+u)^8 du = (1/256)(1023/10 − 511/9) = 4097/23040。
故 E = (n+8)/4·I_b = 6·4097/23040 = 4097/3840。
- Phong 未归一化:∫cosⁿθ cosθ dω = 2π/(n+2) = π/9。
返回四个 Q16.16 参照值。
"""
e_blinn = (4097 * 6 << 16) // 23040 # 24582/23040 的 Q16.16
e_raw = base.TWO_PI // (N_EXP + 2) # π/9 的 Q16.16
return {"lambert": ONE, "phong_normalized": ONE,
"blinn_normalized": e_blinn, "phong_raw": e_raw}
def main():
samples = base.build_hemisphere()
lambert = lambda dx, dy, cz: base.lambert(ONE) # ρ/π
phong = lambda dx, dy, cz: cglib.fmul(PHONG_NORM, pow_chain(cz, 4))
blinn = lambda dx, dy, cz: cglib.fmul(BLINN_NORM, pow_chain(half_cos(cz), 4))
phong_raw = lambda dx, dy, cz: pow_chain(cz, 4) # 负例:未归一化
energy = {
"lambert": base.hemisphere_integral(samples, lambert),
"phong_normalized": base.hemisphere_integral(samples, phong),
"blinn_normalized": base.hemisphere_integral(samples, blinn),
"phong_raw": base.hemisphere_integral(samples, phong_raw),
}
analytic = closed_form_reference()
c = samples[32 * base.N_PHI][2] # 取中间一带(k=32,cosθ≈0.51)展示平方链
chain = [c]
for _ in range(4):
chain.append(cglib.fmul(chain[-1], chain[-1]))
record = {
"constants": {
"inv_pi": INV_PI,
"phong_norm_q16": PHONG_NORM,
"blinn_norm_q16": BLINN_NORM,
"phong_norm_expr": "(n+2)/(2π) = 9/π,n=16",
"blinn_norm_expr": "(n+8)/(8π) = 3/π,n=16",
},
"pow_chain": {
"n": N_EXP, "squarings": 4,
"c0_q16": chain[0], "c2_q16": chain[1], "c4_q16": chain[2],
"c8_q16": chain[3], "c16_q16": chain[4],
},
"closed_form": {
"blinn_fraction": "4097/3840",
"blinn_q16": analytic["blinn_normalized"],
"phong_raw_q16": analytic["phong_raw"],
},
"energy_q16": energy,
"analytic_q16": analytic,
"residual_q16": {k: ONE - v for k, v in energy.items()},
"quadrature_gap_q16": {k: analytic[k] - energy[k] for k in energy},
"permille_of_one": {k: round(v * 1000.0 / ONE, 4) for k, v in energy.items()},
"analytic_permille": {k: round(v * 1000.0 / ONE, 4) for k, v in analytic.items()},
"assert": {
"lambert_le_one": energy["lambert"] <= ONE,
"phong_normalized_le_one": energy["phong_normalized"] <= ONE,
"blinn_normalized_over_one": energy["blinn_normalized"] > ONE,
"phong_raw_loses_energy": energy["phong_raw"] < energy["phong_normalized"] // 2,
},
}
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, "cg_shade_02_2_brdf.json"), "w", encoding="utf-8") as fh:
json.dump(record, fh, ensure_ascii=False, indent=2)
print(f"1/π / Phong 归一化 / Blinn 归一化 : {INV_PI} / {PHONG_NORM} / {BLINN_NORM}")
print(f"平方链 c^16 (4 次平方) : {chain[0]} -> {chain[4]}")
for name in energy:
print(f"{name:18s} 估计 {energy[name]:6d} ({record['permille_of_one'][name]:8.4f}‰) "
f"参照 {analytic[name]:6d} ({record['analytic_permille'][name]:8.4f}‰) "
f"求积差 {record['quadrature_gap_q16'][name]:5d}")
print(f"断言全过 : {all(record['assert'].values())}")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出(/opt/homebrew/bin/python3 sim/cg_shade_02_2_brdf_fixed.py):
1/π / Phong 归一化 / Blinn 归一化 : 20861 / 187749 / 62583
平方链 c^16 (4 次平方) : 33280 -> 1
lambert 估计 65533 (999.9542‰) 参照 65536 (1000.0000‰) 求积差 3
phong_normalized 估计 65321 (996.7194‰) 参照 65536 (1000.0000‰) 求积差 215
blinn_normalized 估计 69886 (1066.3757‰) 参照 69922 (1066.9250‰) 求积差 36
phong_raw 估计 22800 (347.9004‰) 参照 22876 (349.0601‰) 求积差 76
断言全过 : True
P 层:跨宿主的能量断言
TypeScript 侧重算平方链、半角与三种能量,断言与 S 侧逐位相同;两条负例分别锁住「Blinn 必须超 1」和「未归一化 Phong 必须丢一半以上能量」:
// file: lantern/core/test/cg_shade_02_2_brdf_fixed.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_02_2_brdf_fixed.test.ts'
// 2.2 的 P 层:TypeScript 侧重算三种 BRDF 的定点能量,断言与 S 侧
// out/cg_shade_02_2_brdf.json 逐位相同;Blinn 归一化超 1 是**真实结论**,不是误差。
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { ONE, fmul } from "../src/lantern/fixed.ts";
import {
BLINN_NORM, INV_PI, PHONG_NORM, buildHemisphere, halfCos, hemisphereIntegral, powChain,
} from "../src/lantern/cg_shade_02_material.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const g = JSON.parse(
readFileSync(join(root, "out", "cg_shade_02_2_brdf.json"), "utf8"),
) as {
constants: { inv_pi: number; phong_norm_q16: number; blinn_norm_q16: number };
pow_chain: { c0_q16: number; c16_q16: number };
energy_q16: Record<string, number>;
analytic_q16: Record<string, number>;
assert: Record<string, boolean>;
};
const samples = buildHemisphere();
test("归一化常量与 S 侧逐位相同(1/π、9/π、3/π)", () => {
assert.equal(INV_PI, BigInt(g.constants.inv_pi));
assert.equal(PHONG_NORM, BigInt(g.constants.phong_norm_q16));
assert.equal(BLINN_NORM, BigInt(g.constants.blinn_norm_q16));
assert.equal(INV_PI, 20861n);
assert.equal(PHONG_NORM, 187749n);
assert.equal(BLINN_NORM, 62583n);
});
test("平方链 c^16:4 次平方与 S 侧逐位相同", () => {
const c0 = samples[32 * 8][2];
assert.equal(c0, BigInt(g.pow_chain.c0_q16));
assert.equal(powChain(c0, 4), BigInt(g.pow_chain.c16_q16));
});
test("【核心】三种 BRDF 的能量估计与 S 侧逐位相同", () => {
const energy = {
lambert: hemisphereIntegral(samples, () => fmul(ONE, INV_PI)),
phong_normalized: hemisphereIntegral(samples, (_x, _y, cz) => fmul(PHONG_NORM, powChain(cz, 4))),
blinn_normalized: hemisphereIntegral(samples, (_x, _y, cz) => fmul(BLINN_NORM, powChain(halfCos(cz), 4))),
phong_raw: hemisphereIntegral(samples, (_x, _y, cz) => powChain(cz, 4)),
};
for (const k of Object.keys(energy)) {
assert.equal(energy[k as keyof typeof energy], BigInt(g.energy_q16[k]), `${k} 与 S 侧不符`);
}
});
test("【核心】Lambert 与归一化 Phong ≤ ONE;归一化 Blinn **超过** ONE", () => {
assert.ok(BigInt(g.energy_q16.lambert) <= ONE, "Lambert 必须 ≤ ONE");
assert.ok(BigInt(g.energy_q16.phong_normalized) <= ONE, "归一化 Phong 必须 ≤ ONE");
assert.ok(BigInt(g.energy_q16.blinn_normalized) > ONE, "归一化 Blinn 的能量必须 > ONE(真实结论)");
assert.ok(g.assert.blinn_normalized_over_one);
});
test("负例:未归一化 Phong 严重丢能量(不到归一化的一半)", () => {
assert.ok(
BigInt(g.energy_q16.phong_raw) < BigInt(g.energy_q16.phong_normalized) / 2n,
"未归一化 Phong 的能量必须远小于归一化版本",
);
});
node --test 'core/test/cg_shade_02_2_brdf_fixed.test.ts' 的实测输出:
✔ 归一化常量与 S 侧逐位相同(1/π、9/π、3/π)
✔ 平方链 c^16:4 次平方与 S 侧逐位相同
✔ 【核心】三种 BRDF 的能量估计与 S 侧逐位相同
✔ 【核心】Lambert 与归一化 Phong ≤ ONE;归一化 Blinn **超过** ONE
✔ 负例:未归一化 Phong 严重丢能量(不到归一化的一半)
ℹ tests 5 ℹ pass 5 ℹ fail 0
5 条全绿。到这里,单个 BRDF 的能量账已经算清;下一节把多个材质组合起来,看混合与分层之后这条账还平不平。
阅读导航:上一节:2.1 BRDF 与能量守恒 · 下一节:2.3 材质的组合与混合 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。