2.3 材质的组合与混合
前两节量的是单个 BRDF 的能量。真实材质系统里更常见的是组合:两个材质按权重混合,或者在一个底色层上叠一层清漆。站内的 实时 PBR 与阴影贴图 讲的是这些材质在 GPU 上怎么打包上传;本节算的是它们在整数算术下的能量账——组合之后还守恒吗?
线性混合:不变量 w_A + w_B == ONE
BRDF 对材质系数是线性的:混合材质 $f = w_A f_A + w_B f_B$。于是它的半球反射率也是线性的:
$$ E = \int (w_A f_A + w_B f_B)\cos\theta,\mathrm{d}\omega = w_A E_A + w_B E_B \le w_A + w_B $$
只要权重和 == ONE(Q16.16 里就是 w_A + w_B == 65536),混合材质就一定 ≤ 1。这条不变量是材质系统里最容易写漏的一条:权重存成 Q16.16 后忘了归一化,或者滑条允许两个权重同时拉满,都会让能量越界。五组权重的实跑:
| w_A | w_B | 权重和 | 能量 | ≤ ONE? |
|---|---|---|---|---|
| 65536 | 0 | 65536 | 65533 | 是 |
| 49152 | 16384 | 65536 | 65479 | 是 |
| 32768 | 32768 | 65536 | 65426 | 是 |
| 16384 | 49152 | 65536 | 65373 | 是 |
| 0 | 65536 | 65536 | 65321 | 是 |
| 65536 | 65536 | 131072 | 130854 | 否 |
最后一行是负例:两个权重都拉满,和变成 $2\cdot\text{ONE}$,能量涨到 130854(1996.67‰)。不变量被破坏时,判据立刻报警。
分层:清漆层的 (1-F) 缩放
分层是另一种组合:底色层之上叠一层高光涂层。朴素相加把两层的能量直接加:$E = E_{\text{base}} + F\cdot E_{\text{coat}}$。这漏掉了一件事——涂层反射掉的那部分光,本来就不会到达底色层。正确做法是按 $(1-F)$ 缩放底色层:
$$ E = (1-F),E_{\text{base}} + F,E_{\text{coat}} $$
取底色层 $\rho = 0.9$、涂层菲涅耳 $F = 0.3$、涂层自身能量 1:朴素相加得 78640(1199.95‰,越界),按 $(1-F)$ 缩放得 60946(929.96‰,守恒)。差的正是那 $F\cdot E_{\text{base}} = 0.3\times58979$ 的重复计数。
用混合材质渲染一帧
组合不只是账面上的:把两套材质系数(漫反射 $k_d$、镜面 $k_s$)按 $w_A = w_B = 1/2$ 混合,用它渲染《烛影山谷》一帧,得到 43731 个覆盖像素与一个 sha256。半角向量取常量(视线固定为相机前向),故 $N\cdot H$ 的 16 次幂只需逐像素一次点积加 4 次平方。
S 层:混合能量 + 渲染
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_02_3_material_blend.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_02_3_material_blend.py
"""2.3 材质的组合与混合(S 层)。
两件事:
1. **线性混合**:材质参数按权重线性插值(漫反射 ρ、镜面 k_s 都是 Q16.16),
权重满足**不变量 w_A + w_B == ONE**。由于 BRDF 对系数线性,
混合材质的半球反射率 = w_A·E_A + w_B·E_B ≤ w_A + w_B = ONE——
即「能量守恒的材质线性混合后仍能量守恒」,前提正是那条不变量。
权重和 ≠ ONE 时(负例)能量立刻越界。
2. **分层(清漆层)**:底色层 + 高光涂层。**朴素相加** E_base + F·E_coat
会超 1(涂层反射的光没有从底色层里扣除);**正确做法**是
E = (1-F)·E_base + F·E_coat——涂层反射掉 F,只有 (1-F) 到达底色层。
3. 用混合材质渲染《烛影山谷》一帧,产出像素缓冲与 sha256(供 P 层逐字节比对)。
产物:out/cg_shade_02_3_material.json、out/shade_blend.rgb。
"""
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import cgshade # noqa: E402
import render_shade # noqa: E402
import cg_shade_02_2_brdf_fixed as b22 # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
# 单元能量(来自 2.1 / 2.2 的实跑):漫反射与归一化镜面各自接近 1
_ENERGY_JSON = json.load(open(os.path.join(ROOT, "out", "cg_shade_02_1_energy.json"), encoding="utf-8"))
_BRDF_JSON = json.load(open(os.path.join(ROOT, "out", "cg_shade_02_2_brdf.json"), encoding="utf-8"))
E_DIFF = _ENERGY_JSON["lambert"]["energy_q16"] # 65533
E_SPEC = _BRDF_JSON["energy_q16"]["phong_normalized"] # 65321
def blend_energy(wa, wb, ea, eb):
"""线性混合的能量:w_A·E_A + w_B·E_B(Q16.16)。"""
return cglib.fmul(wa, ea) + cglib.fmul(wb, eb)
def make_blend_shade(spec, wa, wb):
"""混合材质着色器:两套系数 (kd, ks) 按 w_A / w_B 线性混合。
半角向量 H = normalize(光方向 + 视线方向) 是常量(视线固定为相机前向),
故 (N·H)^16 只需逐像素一次点积 + 4 次平方。S/P 两侧逐位同序。
"""
dlight = spec["directionalLight"]
d = tuple(dlight["dirQ16"])
cam = spec["camera"]
view = cglib.v_norm(cglib.v_sub(tuple(cam["targetQ16"]), tuple(cam["eyeQ16"])))
h = cglib.v_norm(cglib.v_add(d, view))
intensity = dlight["intensityQ16"]
ambient = spec["ambientQ16"]
kd = cglib.fmul(wa, ONE) + cglib.fmul(wb, ONE >> 1) # A: kd=1 B: kd=1/2
ks = cglib.fmul(wa, 0) + cglib.fmul(wb, ONE >> 1) # A: ks=0 B: ks=1/2
def shade(normal, albedo):
ndl = cglib.v_dot(normal, d)
if ndl < 0:
ndl = 0
light = ambient + cglib.fmul(ndl, intensity)
nh = cglib.v_dot(normal, h)
if nh < 0:
nh = 0
sp = b22.pow_chain(nh, 4) # (N·H)^16
k = cglib.fmul(kd, light) + cglib.fmul(ks, sp)
if k > ONE:
k = ONE
return tuple((albedo[i] * k) >> 16 for i in range(3))
return shade
def main():
# ---- 1. 线性混合:权重和 == ONE 的不变量 ----
mat_a = {"rho": ONE, "ks": 0, "energy": E_DIFF} # 纯漫反射
mat_b = {"rho": 0, "ks": ONE, "energy": E_SPEC} # 纯镜面
weights = [(ONE, 0), (3 * ONE >> 2, ONE >> 2), (ONE >> 1, ONE >> 1),
(ONE >> 2, 3 * ONE >> 2), (0, ONE)]
blend_rows = []
for wa, wb in weights:
e = blend_energy(wa, wb, mat_a["energy"], mat_b["energy"])
blend_rows.append({
"wa_q16": wa, "wb_q16": wb, "sum_q16": wa + wb,
"invariant_sum_one": wa + wb == ONE,
"energy_q16": e, "le_one": e <= ONE,
"permille": round(e * 1000.0 / ONE, 4),
})
# 负例:权重和 = 2·ONE
wa, wb = ONE, ONE
e_bad = blend_energy(wa, wb, mat_a["energy"], mat_b["energy"])
negative = {"wa_q16": wa, "wb_q16": wb, "sum_q16": wa + wb,
"energy_q16": e_bad, "le_one": e_bad <= ONE,
"permille": round(e_bad * 1000.0 / ONE, 4)}
# ---- 2. 分层:朴素相加 vs 正确(按 (1-F) 缩放底色层)----
rho_base = 58982 # 0.9 的 Q16.16
f_coat = 19661 # 0.3 的 Q16.16
e_base = cglib.fmul(rho_base, E_DIFF)
e_coat = ONE # 完美镜面涂层,自身能量 1
naive = e_base + cglib.fmul(f_coat, e_coat)
corrected = cglib.fmul(ONE - f_coat, e_base) + cglib.fmul(f_coat, e_coat)
layering = {
"rho_base_q16": rho_base, "f_coat_q16": f_coat,
"e_base_q16": e_base, "e_coat_q16": e_coat,
"naive_q16": naive, "naive_le_one": naive <= ONE,
"naive_permille": round(naive * 1000.0 / ONE, 4),
"corrected_q16": corrected, "corrected_le_one": corrected <= ONE,
"corrected_permille": round(corrected * 1000.0 / ONE, 4),
}
# ---- 3. 用混合材质渲染一帧(w_A = w_B = 1/2)----
spec = cglib.load_spec()
width, height = spec["width"], spec["height"]
tris, total, dropped = render_shade.build_scene_shaded(spec)
shade = make_blend_shade(spec, ONE >> 1, ONE >> 1)
color, depth, covered, culled = cgshade.rasterize_shaded(
tris, width, height, spec["backgroundQ8"], shade
)
px = np.clip(color, 0, 255).astype(np.uint8)
sha = cgshade.digest(px)
with open(os.path.join(ROOT, "out", "shade_blend.rgb"), "wb") as fh:
fh.write(px.tobytes())
cglib.save_png(os.path.join(ROOT, "out", "shade_blend.png"), color)
record = {
"unit_energy": {"e_diff_q16": E_DIFF, "e_spec_q16": E_SPEC},
"linear_blend": blend_rows,
"linear_blend_negative": negative,
"layering": layering,
"render": {
"width": width, "height": height, "triangles_total": total,
"triangles_drawn": len(tris), "dropped": dropped, "culled": int(culled),
"covered_pixels": int(covered.sum()), "sha256": sha,
},
}
with open(os.path.join(ROOT, "out", "cg_shade_02_3_material.json"), "w", encoding="utf-8") as fh:
json.dump(record, fh, ensure_ascii=False, indent=2)
print(f"单元能量 漫反射/镜面 : {E_DIFF} / {E_SPEC}")
print("线性混合(w_A + w_B == ONE 的不变量):")
for r in blend_rows:
print(f" w_A={r['wa_q16']:6d} w_B={r['wb_q16']:6d} 和={r['sum_q16']:6d} "
f"能量={r['energy_q16']:6d} ({r['permille']:8.4f}‰) ≤ONE={r['le_one']}")
print(f"负例(和=2·ONE) : 能量={negative['energy_q16']} "
f"({negative['permille']}‰) ≤ONE={negative['le_one']}")
print(f"分层 朴素相加 : {naive} ({layering['naive_permille']}‰) ≤ONE={layering['naive_le_one']}")
print(f"分层 正确 (1-F) 缩放 : {corrected} ({layering['corrected_permille']}‰) "
f"≤ONE={layering['corrected_le_one']}")
print(f"混合材质渲染 : {int(covered.sum())} 覆盖 sha256 {sha[:16]}…")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出(/opt/homebrew/bin/python3 sim/cg_shade_02_3_material_blend.py):
单元能量 漫反射/镜面 : 65533 / 65321
线性混合(w_A + w_B == ONE 的不变量):
w_A= 65536 w_B= 0 和= 65536 能量= 65533 (999.9542‰) ≤ONE=True
w_A= 49152 w_B= 16384 和= 65536 能量= 65479 (999.1302‰) ≤ONE=True
w_A= 32768 w_B= 32768 和= 65536 能量= 65426 (998.3215‰) ≤ONE=True
w_A= 16384 w_B= 49152 和= 65536 能量= 65373 (997.5128‰) ≤ONE=True
w_A= 0 w_B= 65536 和= 65536 能量= 65321 (996.7194‰) ≤ONE=True
负例(和=2·ONE) : 能量=130854 (1996.6736‰) ≤ONE=False
分层 朴素相加 : 78640 (1199.9512‰) ≤ONE=False
分层 正确 (1-F) 缩放 : 60946 (929.9622‰) ≤ONE=True
混合材质渲染 : 43731 覆盖 sha256 2cae67b900a88046…
P 层:混合能量与跨宿主渲染
TypeScript 侧重算混合能量与分层能量,并重新渲染同一帧,断言像素缓冲的 sha256 与 S 侧逐字节相同;再换一组权重作为负例,断言 sha256 必须分叉:
// file: lantern/core/test/cg_shade_02_3_material_blend.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_02_3_material_blend.test.ts'
// 2.3 的 P 层:TypeScript 侧重算混合能量与分层能量,并**用混合材质渲染同一帧**,
// 断言像素缓冲的 sha256 与 S 侧 out/cg_shade_02_3_material.json 逐字节相同。
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
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 { blendEnergy, renderBlend, type Spec } 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_3_material.json"), "utf8"),
) as {
unit_energy: { e_diff_q16: number; e_spec_q16: number };
linear_blend: { wa_q16: number; wb_q16: number; sum_q16: number; energy_q16: number; le_one: boolean }[];
linear_blend_negative: { energy_q16: number; le_one: boolean };
layering: {
e_base_q16: number; f_coat_q16: number; naive_q16: number; corrected_q16: number;
naive_le_one: boolean; corrected_le_one: boolean;
};
render: { sha256: string; covered_pixels: number };
};
const spec = JSON.parse(readFileSync(join(root, "config", "scene_spec.json"), "utf8")) as Spec;
const eDiff = BigInt(g.unit_energy.e_diff_q16);
const eSpec = BigInt(g.unit_energy.e_spec_q16);
test("【核心】线性混合能量与 S 侧逐位相同,且 w_A + w_B == ONE 时 ≤ ONE", () => {
for (const r of g.linear_blend) {
const wa = BigInt(r.wa_q16);
const wb = BigInt(r.wb_q16);
const e = blendEnergy(wa, wb, eDiff, eSpec);
assert.equal(e, BigInt(r.energy_q16));
assert.equal(wa + wb, BigInt(r.sum_q16));
assert.equal(wa + wb, ONE, "不变量:权重和必须 == ONE");
assert.ok(e <= ONE, "混合后必须仍能量守恒");
}
});
test("负例:权重和 = 2·ONE 时混合能量越界", () => {
const e = blendEnergy(ONE, ONE, eDiff, eSpec);
assert.equal(e, BigInt(g.linear_blend_negative.energy_q16));
assert.ok(e > ONE, "权重和越界则能量越界");
assert.ok(!g.linear_blend_negative.le_one);
});
test("【核心】分层:朴素相加越界,按 (1-F) 缩放后守恒", () => {
const eBase = BigInt(g.layering.e_base_q16);
const f = BigInt(g.layering.f_coat_q16);
const naive = eBase + fmul(f, ONE);
const corrected = fmul(ONE - f, eBase) + fmul(f, ONE);
assert.equal(naive, BigInt(g.layering.naive_q16));
assert.equal(corrected, BigInt(g.layering.corrected_q16));
assert.ok(naive > ONE, "朴素相加必须越界");
assert.ok(corrected <= ONE, "正确分层必须守恒");
});
test("【核心】混合材质渲染:像素缓冲 sha256 与 S 侧逐字节相同", () => {
const r = renderBlend(spec, ONE >> 1n, ONE >> 1n);
const sha = createHash("sha256").update(Buffer.from(r.rgb)).digest("hex");
assert.equal(r.coveredCount, g.render.covered_pixels);
assert.equal(sha, g.render.sha256, "混合材质帧的 sha256 必须与 S 侧相同");
});
test("负例:换一组混合权重,sha256 必须分叉", () => {
const r = renderBlend(spec, 3n * ONE / 4n, ONE / 4n);
const sha = createHash("sha256").update(Buffer.from(r.rgb)).digest("hex");
assert.notEqual(sha, g.render.sha256, "不同混合权重必须给出不同帧");
});
node --test 'core/test/cg_shade_02_3_material_blend.test.ts' 的实测输出:
✔ 【核心】线性混合能量与 S 侧逐位相同,且 w_A + w_B == ONE 时 ≤ ONE
✔ 负例:权重和 = 2·ONE 时混合能量越界
✔ 【核心】分层:朴素相加越界,按 (1-F) 缩放后守恒
✔ 【核心】混合材质渲染:像素缓冲 sha256 与 S 侧逐字节相同
✔ 负例:换一组混合权重,sha256 必须分叉
ℹ tests 5 ℹ pass 5 ℹ fail 0
5 条全绿。渲染帧的 sha256 是 2cae67b9…da1a,S 与 P 两侧逐字节相同——这说明混合材质的着色求值本身也是可复现的整数序列,不只是能量账平。至此材质的能量账闭环:单种 BRDF 守恒、线性混合守恒、分层按 $(1-F)$ 缩放后守恒;下一章转进法线,看方向本身怎么被变换与重建。
阅读导航:上一节:2.2 常见 BRDF 的定点化 · 下一节:3.1 法线的变换 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。