2.3 材质的组合与混合

材质的线性混合只要满足不变量 w_A+w_B==ONE 就仍能量守恒(五组权重实测 65533/65479/65426/65373/65321 全 ≤ ONE,权重和 =2·ONE 时涨到 130854);清漆层朴素相加 78640 越界、按 (1-F) 缩放后 60946 守恒。并用混合材质渲染一帧,跨宿主 sha256 逐字节相同。

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_Aw_B权重和能量≤ ONE?
6553606553665533是
49152163846553665479是
32768327686553665426是
16384491526553665373是
0655366553665321是
6553665536131072130854否

最后一行是负例:两个权重都拉满,和变成 $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 法线的变换 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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