4.2 镜面反射与折射

反射方向 r = d - 2(d·n)n 与 Snell 折射在 Q16.16 下逐位算清:4096 组随机单位向量的单位性偏差最大 55;eta = 1.5 时 3061 组全内反射;临界角公式与整数扫描在水 / 玻璃 / 金刚石三档互证到 1–2 LSB。菲涅尔 x^5 的平方链与逐次相乘实测不逐位相同(429/4096 差 1 LSB)。

4.2 镜面反射与折射

站内 /graphics-water-ocean-rendering/ 那篇做的是水面渲染工程:解析波形叠加、菲涅尔近似、屏幕空间反射,目标是把一片海画得像海,关心帧时间与观感。本节换一个尺度,把「水面为什么在某些角度变成镜子、在某些角度又透明见底」拆到方向公式一级,并在 Q16.16 定点下逐位算清楚。上一节的可见性是离散的(挡 / 不挡);这一节的三个量——反射方向、折射方向、菲涅尔权重——都是连续的,误差会累积,所以每一个都要给出可断言的整数判据。

镜面反射:r = d - 2(d·n)n

镜子把入射方向 d 关于法线 n 做一次镜像,公式只有一行:r = d - 2(d·n)n。d 指向表面(与 n 反向),n 是单位法线。因为 d·n < 0,减掉两倍的法向分量正好把光线「翻」到另一侧。在定点里,2(d·n) 是整数加倍,没有精度损失;两个减法各只有一次 fmul。

判据是单位性:d 与 n 都是单位向量时,r 也必须是单位向量。S 层用 SplitMix64 造4096 组随机单位向量,两两配对算 r,量 | |r|² - 1 |:

  • 最大偏差 55(Q16.16 的一个 LSB 是 1/65536)
  • 平均偏差 4.6565

偏差不为零,全部来自 fmul 的向下取整——d[i] - fmul(k, n[i]) 里那个乘法被截断了一次。4096 组里最大 55 说明误差不会失控,但它确实存在,而且逐位可复现:把同样的随机序列喂给 TypeScript 的 BigInt 版本,得到的是同一个 32 位打包摘要。

折射与 Snell 定律

折射是光线穿过界面时的方向偏折,由 Snell 定律给出:sinθi / sinθt = eta = n1 / n2。写成向量形式(eta = n1/n2,cosθi = -d·n)就是 k = 1 - eta²(1 - cos²θi) 与 t = eta·d + (eta·cosθi - sqrt(k))·n。

k 是判别式:k < 0 时无实数解,光线根本折射不出去——这就是全内反射(TIR)。所以 refract() 的返回类型是「方向或 None」,调用方必须处理 None,否则就是漏了一条分支。

定点实现里有两个必须走对的函数:

  • fsqrt(k) 求 sqrt(k),实现是 isqrt(k << 16)——把 k 左移 16 位再开方,等价于对 Q16.16 的量开方。
  • eta = fdiv(n1·ONE, n2·ONE):fdiv 向零截断,而 Python 的 // 向下取整。凡是与 TypeScript 对齐的整数除法都必须走 fdiv,否则负数会分叉。

同样的 4096 组随机方向,这次先把 n 翻到与 d 反向(保证正面入射),再算 t:

  • 成功折射 1035 条,全内反射 3061 条(eta = 1.5)
  • 成功那部分的单位性偏差:最大 46、平均 5.4444

全内反射占了 75%——因为随机方向各向均匀,绝大多数入射角都超过了玻璃的临界角。这恰好说明 k < 0 这条分支不是罕见情况,而是主路径。

全内反射与临界角

令 k = 0 解出临界角:cosθc = sqrt(eta² - 1) / eta。超过这个角度,光线全部被反射回介质内部。S 层用两种互相独立的方法算它:一是公式 fdiv(fsqrt(eta² - ONE), eta);二是整数扫描,从 cosθ = 0 往上逐格增加,找 k 首次变成非负的那一格。

材料eta (Q16.16)cosθc 公式cosθc 扫描差θc
水873814334743348148.5915°
玻璃983044884748848141.8112°
金刚石1585975967859680224.4097°

两种方法在 1–2 个 LSB 内一致(扫描落在整数格点上,天然带一个格点的量化误差),θc 与教科书值吻合:水约 48.6°、玻璃约 41.8°、金刚石约 24.4°。金刚石差 2,是因为它的 eta 更大,fsqrt 的截断误差在 eta² 上被放大了一档。

顺带一个工程结论:折射率越大,临界角越小——金刚石只要偏离法线 24.4° 以上就全反射,所以它「火彩」强。这不是光学奇观,是 cosθc = sqrt(eta²-1)/eta 的单调性。

菲涅尔项:那个「不逐位相同」的结果

真实界面既反射又折射,能量怎么分由菲涅尔项决定。本节用最简的 Schlick 近似 (1 - V·H)^5,其中五次幂有两种写法:

  • 逐次相乘:x*x -> *x -> *x -> *x,4 次 fmul。
  • 平方链:x*x -> (x²)² -> *x,3 次 fmul。

教科书上它们是同一个多项式,实数下当然相等。在 Q16.16 下不相等:4096 个采样里有 429 个差 1 个 LSB(最大差就是 1)。

归因可以坐实:fmul 的交换律零反例(4096 组三元组里 0 组 fmul(a,b) != fmul(b,a)),而结合律有 979 组反例。根因是 fmul(a, b) = (a*b) >> 16 的向下取整:同一个乘积在不同分组点被截断,少截断一次就落回另一侧。这不是 bug,是定点运算的固有性质——但它提醒我们:在定点里,「代数上等价」不等于「逐位相同」。

本节因此把两件事分开声明:

  • 平方链与逐次相乘不是逐位相同(实测 429/4096)。
  • 平方链本身在 Python 与 TypeScript 之间是逐位相同的(同一个 32 位打包摘要)。

前一句是数学事实,后一句才是跨语言判据。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_04_2_reflection_refraction.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_04_2_reflection_refraction.py
"""4.2《镜面反射与折射》:反射向量、Snell 定律、全内反射与菲涅尔项。

三个方向公式在 Q16.16 下逐位算清楚,并给出可断言的量:

  1. **镜面反射** `r = d - 2(d·n)n`:对 4096 组随机单位向量验证 `|r|² = 1`。
  2. **折射**(Snell):`eta = n1/n2`,`k = 1 - eta²(1-cos²θi)`;`k < 0` 即全内反射。
     用 `fsqrt` 求 `sqrt(k)`、用向零截断的 `fdiv` 求 `eta`,统计全内反射条数。
  3. **临界角** `cosθc = sqrt(eta²-1)/eta`:公式与整数扫描(从 cosθ=0 往上找 `k` 首次
     变号)互证,给出水 / 玻璃 / 金刚石三档。
  4. **菲涅尔** `(1-V·H)^5`:平方链(3 次乘法)与逐次相乘(4 次乘法)各算一遍。实测
     **不逐位相同**——4096 个样本里 429 个差 1 个 LSB。归因用两个反例计数器坐实:`fmul`
     的**交换律**零反例(整数乘法可交换、位移相同),**结合律** 4096 组里有 979 组反例
     (`(a*b)>>16` 在不同分组点向下取整,少截断一次就落回另一侧)。
  5. **一次镜面弹射**:反射率 0.5,主光线命中后加一条反射光线,与基线对照。

产出:
  out/cg_trace_04_2_optics.json  计数、摘要与探针(供正文引用与 P 层对拍)
  out/cg_trace_04_2_snell.csv    三档折射率的临界角表
  out/cg_trace_04_2_mirror.png   一次弹射的镜面成片
"""

import json
import math
import os
import sys

import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cglib  # noqa: E402
import cgtrace  # noqa: E402
import cg_trace_04_2_mirror as mirror  # noqa: E402
import cg_trace_04_optics as optics  # noqa: E402

OUT = os.path.join(ROOT, "out")
ONE = cglib.ONE
fdiv, fsqrt, fmul = cglib.fdiv, cglib.fsqrt, cglib.fmul
v_dot = cglib.v_dot
SAMPLES = 4096
SEED = 20261015


def probe_snell():
    """三档折射率的临界角:公式 vs 整数扫描。"""
    rows = []
    for name, num, den in (("water", 4, 3), ("glass", 3, 2), ("diamond", 242, 100)):
        eta = fdiv(num * ONE, den * ONE)
        eta2 = fmul(eta, eta)
        cosc_formula = fdiv(fsqrt(eta2 - ONE), eta)
        cosc_sweep = ONE
        for c in range(0, ONE + 1):  # 从 cosθ=0 往上,找 k 首次 >= 0
            if ONE - fmul(eta2, ONE - fmul(c, c)) >= 0:
                cosc_sweep = c
                break
        rows.append({
            "material": name, "eta_q16": eta,
            "cos_theta_c_formula": cosc_formula, "cos_theta_c_sweep": cosc_sweep,
            "agree_within_1lsb": abs(cosc_formula - cosc_sweep) <= 1,
            "theta_c_deg": round(math.degrees(math.acos(cosc_formula / ONE)), 4),
        })
    return rows


def main():
    os.makedirs(OUT, exist_ok=True)

    # 1) 反射:单位性 + 逐位摘要
    state, errs, vals, probe_r = SEED, [], [], []
    for i in range(SAMPLES):
        state, d = optics.rand_unit(state)
        state, n = optics.rand_unit(state)
        r = optics.reflect(d, n)
        errs.append(optics.unit_err(r))
        vals.extend(r)
        if i < 4:
            probe_r.append(list(d) + list(n) + list(r))

    # 2) 折射:单位性 + 全内反射统计
    eta_glass = fdiv(3 * ONE, 2 * ONE)
    state, rerrs, rvals, probe_t = SEED, [], [], []
    ok = tir = 0
    for i in range(SAMPLES):
        state, d = optics.rand_unit(state)
        state, n = optics.rand_unit(state)
        if v_dot(d, n) > 0:  # 保证正面入射
            n = (-n[0], -n[1], -n[2])
        t = optics.refract(d, n, eta_glass)
        if t is None:
            tir += 1
            rvals.extend([0, 0, 0])
        else:
            ok += 1
            rerrs.append(optics.unit_err(t))
            rvals.extend(t)
        if i < 4:
            probe_t.append(list(d) + list(n) + (list(t) if t else [None] * 3))

    # 3) 临界角
    snell = probe_snell()

    # 4) 菲涅尔 (1-V·H)^5:平方链 vs 逐次相乘
    state, diff, max_diff, pow_vals, probe_p = SEED, 0, 0, [], []
    for i in range(SAMPLES):
        state, x = cgtrace.rng_q16(state)
        a, b = optics.pow5_sequential(x), optics.pow5_chain(x)
        if abs(a - b):
            diff += 1
            max_diff = max(max_diff, abs(a - b))
        pow_vals.append(b)
        if i < 4:
            probe_p.append([x, a, b])

    # 4b) 归因:fmul 破坏的是结合律,不是交换律
    state, comm_bad, assoc_bad = SEED, 0, 0
    for _ in range(SAMPLES):
        state, a = cgtrace.rng_q16(state)
        state, b = cgtrace.rng_q16(state)
        state, c = cgtrace.rng_q16(state)
        comm_bad += fmul(a, b) != fmul(b, a)
        assoc_bad += fmul(fmul(a, b), c) != fmul(a, fmul(b, c))

    # 5) 一次镜面弹射
    refl_q = ONE >> 1
    spec, base, _, _, _ = mirror.render_mirror(0, 0)
    _, mir, _, mir_s, mir_ms = mirror.render_mirror(refl_q, 1)
    cglib.save_png(os.path.join(OUT, "cg_trace_04_2_mirror.png"), mir)
    with open(os.path.join(OUT, "cg_trace_04_2_mirror.rgb"), "wb") as f:
        f.write(np.ascontiguousarray(mir, dtype=np.uint8).tobytes())
    cglib.write_csv(
        os.path.join(OUT, "cg_trace_04_2_snell.csv"),
        ["material", "eta_q16", "cos_theta_c_formula", "cos_theta_c_sweep", "theta_c_deg"],
        [[r["material"], r["eta_q16"], r["cos_theta_c_formula"], r["cos_theta_c_sweep"],
          r["theta_c_deg"]] for r in snell])

    report = {
        "samples": SAMPLES,
        "reflect_max_unit_err": max(errs),
        "reflect_mean_unit_err": round(sum(errs) / len(errs), 4),
        "reflect_digest": optics.pack32(vals),
        "refract_eta_glass_q16": eta_glass,
        "refract_ok": ok, "refract_tir": tir,
        "refract_max_unit_err": max(rerrs),
        "refract_mean_unit_err": round(sum(rerrs) / len(rerrs), 4),
        "refract_digest": optics.pack32(rvals),
        "snell": snell,
        "pow5_chain_mults": 3, "pow5_sequential_mults": 4,
        "pow5_diff_count": diff, "pow5_max_abs_diff": max_diff,
        "pow5_chain_digest": optics.pack32(pow_vals),
        "fmul_commutativity_diff": comm_bad,
        "fmul_associativity_diff": assoc_bad,
        "probes": {"reflect": probe_r, "refract": probe_t, "pow5": probe_p},
        "reflectivity_q16": refl_q,
        "baseline_sha256": cgtrace.digest(base),
        "mirror_sha256": cgtrace.digest(mir),
        "mae_baseline_vs_mirror": round(cgtrace.mae(base, mir), 4),
        "psnr_baseline_vs_mirror": round(cgtrace.psnr(base, mir), 4),
        "baseline_mean_channel": round(float(np.asarray(base, dtype=np.int64).mean()), 4),
        "mirror_mean_channel": round(float(np.asarray(mir, dtype=np.int64).mean()), 4),
        "mirror_reflect_rays": mir_s["reflect_rays"],
        "mirror_shadow_rays": mir_s["dir_rays"] + mir_s["pt_rays"],
        "ms_mirror": mir_ms,
    }
    with open(os.path.join(OUT, "cg_trace_04_2_optics.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print("== 4.2 镜面反射与折射 ==")
    print(f"反射单位性偏差    : max {max(errs)} / mean {report['reflect_mean_unit_err']}"
          f"({SAMPLES} 组随机单位向量)")
    print(f"折射单位性偏差    : max {max(rerrs)} / mean {report['refract_mean_unit_err']}"
          f"(成功 {ok} / 全内反射 {tir})")
    for r in snell:
        print(f"  {r['material']:8s} eta={r['eta_q16']} cosθc={r['cos_theta_c_formula']}"
              f"(扫描 {r['cos_theta_c_sweep']},差"
              f" {abs(r['cos_theta_c_formula'] - r['cos_theta_c_sweep'])})θc={r['theta_c_deg']}°")
    print(f"菲涅尔 x^5        : 平方链 3 次乘法 / 逐次 4 次,差 {diff} / {SAMPLES}"
          f"(最大 {max_diff} LSB)")
    print(f"fmul 归因         : 交换律反例 {comm_bad} / 结合律反例 {assoc_bad}"
          f"({SAMPLES} 组三元组)")
    print(f"镜面弹射          : 反射光线 {mir_s['reflect_rays']},"
          f"阴影光线 {report['mirror_shadow_rays']}")
    print(f"基线 vs 镜面 MAE  : {report['mae_baseline_vs_mirror']}"
          f"(PSNR {report['psnr_baseline_vs_mirror']} dB)")
    print(f"平均通道          : 基线 {report['baseline_mean_channel']}"
          f" -> 镜面 {report['mirror_mean_channel']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_04_2_reflection_refraction.py
== 4.2 镜面反射与折射 ==
反射单位性偏差    : max 55 / mean 4.6565(4096 组随机单位向量)
折射单位性偏差    : max 46 / mean 5.4444(成功 1035 / 全内反射 3061)
  water    eta=87381 cosθc=43347(扫描 43348,差 1)θc=48.5915°
  glass    eta=98304 cosθc=48847(扫描 48848,差 1)θc=41.8112°
  diamond  eta=158597 cosθc=59678(扫描 59680,差 2)θc=24.4097°
菲涅尔 x^5        : 平方链 3 次乘法 / 逐次 4 次,差 429 / 4096(最大 1 LSB)
fmul 归因         : 交换律反例 0 / 结合律反例 979(4096 组三元组)
镜面弹射          : 反射光线 43731,阴影光线 82515
基线 vs 镜面 MAE  : 20.8846(PSNR 21.7343 dB)
平均通道          : 基线 59.1939 -> 镜面 39.3528

一次镜面弹射的成片

公式正确之后,把它接回渲染链路:主光线命中后,沿反射方向再追一条光线,按反射率 0.5 与直接光照混合。

  • 反射光线 43731——恰好等于命中像素数,因为每个命中点发且只发一条。
  • 阴影光线 82515,比 4.1 的 77211 条多 5304 条——多出来的那部分来自反射光线自己的命中点:它们在 depth + 1 处也各求了一次直接光照。
  • 与基线的 MAE 20.8846、PSNR 21.7343 dB。
  • 平均通道从 59.1939 降到 39.3528——反射把一半能量换成了「看到的那个东西」,而不是「本来的直接光照」。

逐字节判据:refl = 0 时得到的 sha256 与卷三基线完全相同(213b4130…),refl = 0.5 时是 202f9b18…。前者证明这条链路没有意外改动,后者证明弹射真的发生了。

P 层

# snippet: core/test/cg_trace_04_2_reflection_refraction.test.ts(节选:菲涅尔的诚实声明)
# verify: node --test 'core/test/cg_trace_04_2_reflection_refraction.test.ts'
  assert.equal(packDigest(chainVals), golden.pow5_chain_digest);
  assert.equal(diff, golden.pow5_diff_count);
  assert.ok(diff > 0, "平方链与逐次相乘确实不是逐位相同");

最后一行断言是反向的:它要求两者不相等。如果哪天有人「优化」成让它们相等了(比如改成向上取整),这条断言会失败——因为那意味着定点语义变了。P 层在这里守的不是「正确」,而是「口径不变」。

小结

  • 反射 r = d - 2(d·n)n:4096 组随机方向,单位性最大偏差 55、平均 4.6565。
  • 折射 k = 1 - eta²(1 - cos²θi),k < 0 即全内反射;eta = 1.5 时 4096 组里 3061 组全反射。
  • 临界角公式与整数扫描在 1–2 LSB 内互证:水 48.5915°、玻璃 41.8112°、金刚石 24.4097°。
  • 菲涅尔 x^5 的平方链与逐次相乘不逐位相同(429/4096 差 1 LSB);归因是 fmul 的结合律失效(4096 组三元组 979 组反例),交换律则零反例。
  • 一次镜面弹射:反射光线 43731、阴影光线 82515,与基线 MAE 20.8846。

一次弹射只用到了主光线的方向。要让反射光线自己再反射,就得把每一层的入射方向带下去——那是下一节的事。

阅读导航:上一节:4.1 硬阴影与阴影光线 · 下一节:4.3 递归的光线树 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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