4.3 递归的光线树

光线树把「反射的光线还能再反射」显式组织成一棵按深度分层的树:depth 只是透传给着色器的参数,形状由着色器决定。实测每层光线 76800 / 44877 / 9232 / 6881 / 6269,代价随深度亚线性增长(每主光线 2.686 → 3.236 条),而在某一层截断会引入单边偏差而非无偏估计。

4.3 递归的光线树

站内 /graphics-water-ocean-rendering/ 那篇在水面渲染里用一次反射加一次折射近似水体,反射的反射不再展开——因为实时渲染付不起这个代价。本节正好相反:把「反射的光线还能再反射」这件事显式组织成一棵树,数清楚它有多少条光线、每深一层多花多少、以及在某一层截断会引入多大的偏差。这也是从「一次弹射」走向「全局光照」的第一步。

depth 只是一个参数

底座的光线追踪函数签名是

# snippet: sim/cgtrace.py(节选:底座签名)
def trace(nodes, order, tris, orig, dirv, depth, ctx): ...

它做的事只有三件:求交、插值法线与反照率、调用 ctx["shade"](p, off, n, albedo, depth)。depth 既不参与求交也不参与着色——它只是被原样传进着色器。着色器拿它做一件事:决定「还要不要再发一条光线」。

于是光线树从着色器里长出来:

  • depth == 0 是主光线,由 render_trace 从相机发出。
  • 着色器在 depth < max_depth 时,沿反射方向与折射方向各发一条子光线,交给 trace(..., depth + 1, ctx)。
  • depth == max_depth 时不再发子光线,只算直接光照。

底座里 render_trace(spec, w, h, max_depth, shade_factory, ctx) 的 max_depth 参数只用于透传给工厂函数,真正的截断判据在着色器里。这不是设计缺陷,而是刻意的:光线树的形状由着色器决定,底座只负责把它跑起来。

方向栈:着色器拿不到入射方向

这里有一个接口上的硬约束:shade(p, off, n, albedo, depth) 没有入射方向 d。可反射公式 r = d - 2(d·n)n 偏偏要用 d。本节的做法是维护一个按深度索引的方向栈 dirs:

  • 主光线(depth == 0)的方向由 normalize(p - eye) 重建——主光线都从眼睛出发,这是唯一可以「反推」的一层。
  • 其余各层:父层在调用 trace(..., depth + 1, ctx) 之前,把子光线的方向写进 dirs[depth + 1];子层在自己的 shade(depth + 1) 里读回来。
# snippet: sim/cg_trace_04_3_recursive_ray_tree.py(节选:方向栈的读写)
        d = v_norm(v_sub(p, eye)) if depth == 0 else dirs[depth]
        ...
        r = optics.reflect(d, n)
        dirs[depth + 1] = r

栈的长度是 max_depth + 2。为什么 +2?因为 depth 从 0 数到 max_depth,再加一格给「下一层已经写好、但还没被读」的那个槽位。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_04_3_recursive_ray_tree.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_04_3_recursive_ray_tree.py
"""4.3《递归的光线树》:depth 参数、分支、截断与代价。

底座 `cgtrace.trace(nodes, order, tris, orig, dirv, depth, ctx)` 的 `depth` 是这一节
的主角:它既不参与求交也不参与着色,只是**传给 `ctx["shade"]`**,让着色器自己决定
「还要不要再发一条光线」。光线树由此长出来——每个命中点是一棵子树,反射与折射是两条
分支,`depth == max_depth` 时截断。

着色器签名 `shade(p, off, n, albedo, depth)` **拿不到入射方向**,所以本节维护一个按
深度索引的方向栈 `dirs`:每层在发子光线之前把子光线方向写进 `dirs[depth+1]`,被调用方
在 `shade(depth+1)` 里读回来。主光线(depth 0)的方向由 `normalize(p - eye)` 重建。

三个可度量的量:

  1. **每层的光线条数**:随 max_depth 增长的表格(lvl0/lvl1/... + 阴影光线)。
  2. **分支因子**:每个命中点发几条子光线(反射一条 + 折射一条,全内反射时只发一条)。
  3. **截断引入的偏差**:把 max_depth = 1..4 的成片与最深一档相减,量出 MAE 与平均
     亮度随深度的变化。截断**不是无偏的**——它系统性丢掉间接光。

产出:
  out/cg_trace_04_3_raytree.json     深度—光线计数表、偏差表与 sha256
  out/cg_trace_04_3_depth_curve.csv  每档 max_depth 的计数与误差
  out/cg_trace_04_3_tree_d4.png      max_depth = 4 的成片
"""

import json
import os
import sys
import time

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_light as light  # noqa: E402
import cg_trace_04_optics as optics  # noqa: E402

OUT = os.path.join(ROOT, "out")
ONE = cglib.ONE
fmul = cglib.fmul
v_add, v_sub, v_norm = cglib.v_add, cglib.v_sub, cglib.v_norm
ETA_GLASS = cglib.fdiv(3 * ONE, 2 * ONE)  # 1.5
REFLECTIVITY = ONE >> 1  # 0.5
STAT_KEYS = ["dir_rays", "dir_hits", "pt_rays", "pt_hits",
             "reflect_rays", "refract_rays", "tir"]


def make_tree_shader(ctx, max_depth):
    """直接光照 + 递归的反射/折射。`depth < max_depth` 时展开两条分支。

    每层维护方向栈 `dirs[depth]`:父层在调用 `trace(..., depth+1, ctx)` 之前把子光线
    方向写进去。这是「着色器拿不到入射方向」这一约束的直接后果,也是把光线树显式组织
    起来的动机。
    """
    spec = ctx["spec"]
    tris, _ = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)
    ctx["tris"] = tris
    ctx["bvh"] = (nodes, order)
    eye = tuple(spec["camera"]["eyeQ16"])
    stats = ctx["stats"]
    direct = light.build_direct(spec, nodes, order, tris, ctx["tmax"], stats,
                                ctx["bvh_stats"], raw=True)
    dirs = [None] * (max_depth + 2)

    def shade(p, off, n, albedo, depth):
        out = direct(p, off, n, albedo)  # 求值器内部把 8 位反照率转成 Q16.16
        if depth >= max_depth:
            return tuple(cgtrace._clamp8(x) for x in out)

        # 入射方向:主光线由 normalize(p - eye) 重建,其余从方向栈读回
        d = v_norm(v_sub(p, eye)) if depth == 0 else dirs[depth]
        so = v_add(p, off)

        # 分支一:镜面反射
        r = optics.reflect(d, n)
        dirs[depth + 1] = r
        stats["cast_at"][depth + 1] += 1
        stats["reflect_rays"] += 1
        c_refl = cgtrace.trace(nodes, order, tris, so, r, depth + 1, ctx)

        # 分支二:折射(全内反射时不存在)
        t = optics.refract(d, n, ETA_GLASS)
        if t is None:
            stats["tir"] += 1
            mix = [optics.c8(v) for v in c_refl]
        else:
            dirs[depth + 1] = t
            stats["cast_at"][depth + 1] += 1
            stats["refract_rays"] += 1
            c_refr = cgtrace.trace(nodes, order, tris, so, t, depth + 1, ctx)
            mix = [(optics.c8(c_refl[i]) + optics.c8(c_refr[i])) >> 1 for i in range(3)]

        for i in range(3):
            out[i] = fmul(ONE - REFLECTIVITY, out[i]) + fmul(REFLECTIVITY, mix[i])
        return tuple(cgtrace._clamp8(x) for x in out)

    return shade


def render(max_depth):
    spec = cglib.load_spec()
    ctx = {"stats": {k: 0 for k in STAT_KEYS}, "cast_at": [0] * (max_depth + 2)}
    ctx["stats"]["cast_at"] = ctx["cast_at"]
    t0 = time.time()
    color, stats = cgtrace.render_trace(
        spec, spec["width"], spec["height"], max_depth,
        lambda c: make_tree_shader(c, max_depth), ctx)
    return spec, color, stats, ctx["stats"], int((time.time() - t0) * 1000)


def main():
    os.makedirs(OUT, exist_ok=True)
    depths = [1, 2, 3, 4]
    results = {d: render(d) for d in depths}
    ref = results[depths[-1]][1]

    rows = []
    for d in depths:
        _, color, stats, s, ms = results[d]
        shadow_rays = s["dir_rays"] + s["pt_rays"]
        shadow_hits = s["dir_hits"] + s["pt_hits"]
        diff = np.abs(np.asarray(color, dtype=np.int64) - np.asarray(ref, dtype=np.int64))
        pixels_differ = int((diff.max(axis=2) > 0).sum())
        pv = cgtrace.psnr(color, ref)
        rows.append({
            "max_depth": d, "lvl0": stats["rays"], "cast_at": s["cast_at"][1:],
            "secondary_rays": sum(s["cast_at"][1:]),
            "shadow_rays": shadow_rays, "shadow_hits": shadow_hits,
            "reflect_rays": s["reflect_rays"], "refract_rays": s["refract_rays"],
            "tir": s["tir"], "total_rays": stats["rays"] + sum(s["cast_at"][1:]) + shadow_rays,
            "ms": ms, "mean_channel": round(float(np.asarray(color, dtype=np.int64).mean()), 4),
            "mae_vs_ref": round(cgtrace.mae(color, ref), 4),
            "psnr_vs_ref": round(pv, 4) if pv != float("inf") else None,
            "max_diff": int(diff.max()), "pixels_differ": pixels_differ,
            "pixels_differ_permille": round(1000.0 * pixels_differ / (color.shape[0] * color.shape[1]), 4),
            "sha256": cgtrace.digest(color),
        })

    cglib.write_csv(
        os.path.join(OUT, "cg_trace_04_3_depth_curve.csv"),
        ["max_depth", "lvl0", "lvl1", "lvl2", "lvl3", "lvl4", "secondary", "shadow",
         "total_rays", "ms", "mean_channel", "mae_vs_ref"],
        [[r["max_depth"], r["lvl0"]] + (r["cast_at"] + [0, 0, 0, 0])[:4]
         + [r["secondary_rays"], r["shadow_rays"], r["total_rays"], r["ms"],
            r["mean_channel"], r["mae_vs_ref"]] for r in rows])
    cglib.save_png(os.path.join(OUT, "cg_trace_04_3_tree_d4.png"), ref)
    with open(os.path.join(OUT, "cg_trace_04_3_tree_d4.rgb"), "wb") as f:
        f.write(np.ascontiguousarray(ref, dtype=np.uint8).tobytes())

    report = {
        "depths": depths, "eta_glass_q16": ETA_GLASS, "reflectivity_q16": REFLECTIVITY,
        "reference_depth": depths[-1],
        "reference_mean_channel": round(float(np.asarray(ref, dtype=np.int64).mean()), 4),
        "table": rows,
    }
    with open(os.path.join(OUT, "cg_trace_04_3_raytree.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print("== 4.3 递归的光线树 ==")
    print(f"{'depth':>5} {'lvl0':>7} {'lvl1':>7} {'lvl2':>7} {'lvl3':>7} {'lvl4':>7}"
          f" {'shadow':>7} {'total':>8} {'ms':>6} {'mean':>8} {'mae':>7} {'maxd':>5} {'diff‰':>8}")
    for r in rows:
        ca = (r["cast_at"] + [0, 0, 0, 0])[:4]
        print(f"{r['max_depth']:>5} {r['lvl0']:>7} {ca[0]:>7} {ca[1]:>7} {ca[2]:>7} {ca[3]:>7}"
              f" {r['shadow_rays']:>7} {r['total_rays']:>8} {r['ms']:>6}"
              f" {r['mean_channel']:>8} {r['mae_vs_ref']:>7} {r['max_diff']:>5}"
              f" {r['pixels_differ_permille']:>8}")
    last = rows[-1]
    print(f"分支            : 反射 {last['reflect_rays']} / 折射 {last['refract_rays']}"
          f" / 全内反射 {last['tir']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_04_3_recursive_ray_tree.py
== 4.3 递归的光线树 ==
depth    lvl0    lvl1    lvl2    lvl3    lvl4  shadow    total     ms     mean     mae  maxd    diff‰
    1   76800   44877       0       0       0   84630   206307  10054  39.6494  1.0039    61  85.2344
    2   76800   44877    9232       0       0   92008   222917  11354  39.0775  0.2269    28  46.0677
    3   76800   44877    9232    6881       0   98730   236520  12629  38.9574  0.0651    14  31.9141
    4   76800   44877    9232    6881    6269  104454   248513  13696  38.9138     0.0     0      0.0
分支            : 反射 61263 / 折射 5996 / 全内反射 55267

光线树的形状

把上表按层读一遍,能看出这棵树的三个特征:

层光线数相对上一层说明
lvl0(主光线)76800—320 × 240
lvl144877—反射 43731 + 折射 1146
lvl2923220.6%只有 lvl1 里真正命中几何的那部分才继续
lvl3688174.5%越深,存活率反而越高
lvl4626991.1%接近饱和

lvl1 = 44877,其中反射 43731 条、折射 1146 条。反射光线永远存在(一条),折射光线只在 k >= 0 时存在;tir 统计显示 depth = 1 这一档就有 42585 次全内反射,所以折射分支比反射分支稀疏得多。

越深存活率越高(20.6% → 74.5% → 91.1%),是因为深处剩下的都是「没有全内反射」的那批光线——它们的入射角更「正」,命中几何的概率也更高。

代价随深度增长

max_depth总光线每主光线相对 depth=1耗时
12063072.6861.000×10054 ms
22229172.9031.081×11354 ms
32365203.0801.147×12629 ms
42485133.2361.205×13696 ms

每深一层,总光线数增加约 1.2–1.7 万条,相对代价每次涨 5.1–8.1%(8.1% → 6.1% → 5.1%,在减速)。增长是亚线性的——lvl1 → lvl2 一步就掉到 20.6%,后面几层的基数已经很小,几何级数收敛。这也解释了为什么在离线渲染里,把 max_depth 从 4 提到 8 是「多花一点」,而再往上提几乎「白送」:越深的层贡献越小。

这些是本机(Apple M1 Pro,单线程 Python)的实测值,不是普适结论——深度的实际代价取决于场景里有多少「反射得到的几何」。

截断引入偏差

把每一档的成片与最深一档(depth = 4)相减:

max_depthMAE vs depth4PSNR最大像素差有差异的像素
11.003948.0971 dB616546(85.2344‰)
20.226961.0146 dB283538(46.0677‰)
30.065171.8546 dB142451(31.9141‰)
40(参考解)—00

关键在于误差的符号是单边的:平均通道随深度单调下降(39.6494 → 39.0775 → 38.9574 → 38.9138)。本节把反射率设成 0.5,每弹一次就把一半能量换成「反射看到的东西」,而场景里那些东西比直接光照更暗(背景只有 (12, 14, 20))。所以 depth = 1 的图像系统性地偏亮,误差不会正负抵消。这正是下一卷「无偏性与偏差」要展开的话题——在这里只需要记住:少算的间接光不会自己补回来。

MAE 随深度快速下降(1.0039 → 0.2269 → 0.0651),但下降速度在放缓:每加一层大约缩小到 1/4.4、1/3.5——接近一个常数比,说明几何级数在收敛,而不是某一层突然变好。

P 层

# snippet: core/test/cg_trace_04_3_recursive_ray_tree.test.ts(节选:每层计数)
# verify: node --test 'core/test/cg_trace_04_3_recursive_ray_tree.test.ts'
    for (let k = 0; k < row.cast_at.length; k += 1) {
      assert.equal(counts.castAt[k + 1], row.cast_at[k], `lvl${k + 1} 光线数`);
    }
    assert.equal(counts.shadowRays, row.shadow_rays);
    assert.equal(sha(rgb), row.sha256);

P 层对每一档 max_depth 各跑一遍,断言每层的光线数与像素缓冲 sha256。因为方向栈是逐层写读的,任何一层的索引错位都会让某一档的计数分叉——这是「递归结构正确性」最直接的判据。

小结

  • depth 只是透传给着色器的参数,光线树的形状完全由着色器决定。
  • 因为着色器拿不到入射方向,本节用按深度索引的方向栈把每层的方向带下去。
  • 光线树每层的条数:76800 / 44877 / 9232 / 6881 / 6269,比值逐层收敛。
  • 代价随深度亚线性增长:每主光线 2.686 → 3.236 条(depth 1 → 4)。
  • 截断引入单边偏差(depth = 1 的 MAE 1.0039、平均通道偏高),不是无偏估计。

到这里,「一条光线打到一个点、再发光线出去」的最小闭环就走完了。下一章开始给这个循环装上概率:如果反射方向不是唯一确定的,而是从某个分布里抽出来的,那么每条路径的答案都不一样——那就要谈积分与估计了。

阅读导航:上一节:4.2 镜面反射与折射 · 下一节:5.1 积分与估计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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