1.3 递归与深度限制

底座的 trace 把 depth 一路传下去,却不把入射方向传给 shade。本节写一个与它逐字同构、只多传一个 dirv 的 trace_rec,给着色器加一层受深度门控的镜面反射:深度 1 的输出与卷三基线 sha256 逐字节相同(退化为直接光照),每加一层 MAE 与变化像素都单调下降。

1.3 递归与深度限制

站内 /graphics-raytracing-hybrid/ 那篇讲的是实时混合光追:光追只负责阴影与一次反射,主通道仍是光栅化,递归深度通常是一。本卷做离线光追的完整链路,递归是它的骨架——前两节把「一条光线」和「相机发出一条光线」讲完了,这一节让光线自己再发出光线。

递归的结构:trace 的 depth 参数

卷三的底座里,追踪函数是

# snippet: 底座 sim/cgtrace.py 的追踪函数签名
trace(nodes, order, tris, orig, dirv, depth, ctx) -> (r, g, b)

它的第 6 个参数 depth 就是递归深度。流程是:求交 → 命中就插值法线、算命中点 → 调 ctx["shade"](p, off, n, albedo, depth) 得到颜色。depth 被原样传进 shade,但 shade 拿不到入射方向 dirv——底座 shade 的签名是 shade(p, off, n, albedo, depth),没有 dirv。这是刻意的:基线的着色器是纯直接光照,用不到入射方向。

但反射需要它。镜面反射方向是 r = d - 2(d·n)n——没有入射方向 d 就算不出来。所以本节的脚本写了一个 trace_rec——与底座 trace 逐字同构(求交、插值、法线朝向、偏移四段一字不差),唯一差别是把 dirv 也传给 shade。递归的骨架不变,变的只是 shade 的内容:这正是本节的论点。

为什么深度必须有限

反射是自指的:一个点的颜色取决于它反射到的那个点的颜色,那个点又取决于它反射到的下一个点……只要场景里有一个闭合的反射路径(比如两面镜子对望),这个过程就永不终止。这不是理论顾虑——递归函数不加界,程序会栈溢出。

底座的界写在 shade 里,只有一行:

# snippet: 节选自 lantern/sim/cg_trace_01_3_depth.py 的 shade()
if depth + 1 < max_depth:
    rc = trace_rec(ctx, so, vNorm(rd), depth + 1)   # 只有还有预算才发反射光线
    out[i] += fmul(REFLECTIVITY, c8(rc[i]))

depth + 1 < max_depth 同时管住两件事:发不发这条反射光线,以及传下去的深度是多少。第 0 层主光线的 depth = 0,每反射一次 depth 加一;当 depth + 1 触到 max_depth,就不再发新的反射光线,改为只算直接光照。于是递归树被截断成有限深度。

为什么 MAX_DEPTH = 1 就是直接光照

把 max_depth = 1 代进去:主光线的 depth = 0,判据 0 + 1 < 1 为假,一条反射光线都不发。着色器退化成「环境光 + 方向光 + 点光源 + 各一条阴影光线」——正是卷三基线 sim/render_trace.py 做的事。MAX_DEPTH = 1 与「直接光照」是同一件事的两种说法,本节用 sha256 把它证死。

完整脚本

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_01_3_depth.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_01_3_depth.py
"""1.3 递归与深度限制:量出「深度 = 1」为什么恰好退化成直接光照。

底座的 `trace(nodes, order, tris, orig, dirv, depth, ctx)` 把 `depth` 一路传下去,
但**不把入射方向传给 `shade`**(签名 `shade(p, off, n, albedo, depth)`)。要让
`shade` 能反射,就得知道入射方向——故本脚本写一个 `trace_rec`,与底座 `trace`
逐字同构,只多传一个 `dirv`。求交、插值、法线朝向、偏移四段代码与底座一字不差。

着色器 = 基线直接光照(环境光 + 方向光 + 点光源,各带一条阴影光线)**加上**一层
镜面反射:`depth + 1 < max_depth` 时才发反射光线,反射方向 `r = d - 2(d·n)n`。于是:

  - `max_depth = 1`:`depth = 0`,`0 + 1 < 1` 为假 → 不发反射光线 →
    **退化成纯直接光照**,输出与卷三基线逐字节相同。
  - `max_depth >= 2`:每多一层就多一次弹射,量出光线数、图像变化与收敛。

产物:out/cg_trace_01_3_depth.csv、out/cg_trace_01_3_depth.json
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import cgtrace  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE_SHA = "213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71"
REFLECTIVITY = 16384  # 0.25,Q16.16:一层反射占多少权重
DEPTHS = [1, 2, 3, 4, 5, 6]


def c8(v):
    """8 位通道 -> Q16.16 的 [0, 1]。"""
    return (v << 16) // 255


def trace_rec(ctx, orig, dirv, depth):
    """与 `cgtrace.trace` 逐字同构,唯一差别:把 `dirv` 也传给 `shade`。"""
    nodes, order = ctx["bvh"]
    tris = ctx["tris"]
    hit = cgtrace.bvh_intersect(nodes, order, tris, orig, dirv, ctx["tmax"], ctx["bvh_stats"])
    ctx["calls"] += 1
    if hit is None:
        return ctx["background"]
    t, ti, u, v = hit
    rec = tris[ti]
    w0 = cglib.ONE - u - v
    n = cglib.v_norm((
        cglib.fmul(rec[9], w0) + cglib.fmul(rec[12], u) + cglib.fmul(rec[15], v),
        cglib.fmul(rec[10], w0) + cglib.fmul(rec[13], u) + cglib.fmul(rec[16], v),
        cglib.fmul(rec[11], w0) + cglib.fmul(rec[14], u) + cglib.fmul(rec[17], v),
    ))
    p = cglib.v_add(orig, (cglib.fmul(dirv[0], t), cglib.fmul(dirv[1], t), cglib.fmul(dirv[2], t)))
    if cglib.v_dot(n, dirv) > 0:
        n = (-n[0], -n[1], -n[2])
    off = (cglib.fmul(n[0], cgtrace.EPSILON), cglib.fmul(n[1], cgtrace.EPSILON),
           cglib.fmul(n[2], cgtrace.EPSILON))
    return ctx["shade"](p, off, n, (rec[18], rec[19], rec[20]), depth, dirv)


def make_shader(ctx):
    """直接光照(与卷三基线逐字同式)+ 一层镜面反射(受深度门控)。"""
    spec = ctx["spec"]
    tris, _ = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)
    ctx["tris"], ctx["bvh"] = tris, (nodes, order)

    dl, pl = spec["directionalLight"], spec["pointLight"]
    ldir, lcol, lint = tuple(dl["dirQ16"]), [c8(v) for v in dl["colorQ8"]], dl["intensityQ16"]
    ppos, pcol, pint = tuple(pl["posQ16"]), [c8(v) for v in pl["colorQ8"]], pl["intensityQ16"]
    pr2 = cglib.fmul(pl["rangeQ16"], pl["rangeQ16"])
    amb, tmax = spec["ambientQ16"], ctx["tmax"]
    stats, bs, md = ctx["stats"], ctx["bvh_stats"], ctx["max_depth"]

    def shade(p, off, n, albedo, depth, dirv):
        alb = [c8(v) for v in albedo]
        out = [cglib.fmul(amb, alb[i]) for i in range(3)]
        so = cglib.v_add(p, off)          # 阴影光线起点 = 命中点 + 法线偏移

        ndl = cglib.v_dot(n, ldir)
        if ndl > 0:
            stats["shadow_rays"] += 1
            if cgtrace.bvh_intersect(nodes, order, tris, so, ldir, tmax, bs) is None:
                k = cglib.fmul(lint, ndl)
                for i in range(3):
                    out[i] += cglib.fmul(k, cglib.fmul(lcol[i], alb[i]))
            else:
                stats["shadow_hits"] += 1

        to_l = cglib.v_sub(ppos, p)
        d2 = cglib.v_dot(to_l, to_l)
        if 0 < d2 < pr2:
            ldir_p = cglib.v_norm(to_l)
            ndl_p = cglib.v_dot(n, ldir_p)
            if ndl_p > 0:
                stats["shadow_rays"] += 1
                if cgtrace.bvh_intersect(nodes, order, tris, so, ldir_p, tmax, bs) is None:
                    att = cglib.fdiv(pr2, pr2 + d2)
                    k = cglib.fmul(cglib.fmul(pint, ndl_p), att)
                    for i in range(3):
                        out[i] += cglib.fmul(k, cglib.fmul(pcol[i], alb[i]))
                else:
                    stats["shadow_hits"] += 1

        # 反射:r = d - 2(d·n)n;只有还有预算(depth + 1 < max_depth)才发反射光线
        if depth + 1 < md:
            dn = cglib.v_dot(dirv, n)
            rd = (dirv[0] - 2 * cglib.fmul(dn, n[0]), dirv[1] - 2 * cglib.fmul(dn, n[1]),
                  dirv[2] - 2 * cglib.fmul(dn, n[2]))
            ctx["reflect_rays"] += 1
            rc = trace_rec(ctx, so, cglib.v_norm(rd), depth + 1)
            for i in range(3):
                out[i] += cglib.fmul(REFLECTIVITY, c8(rc[i]))

        return tuple(cgtrace._clamp8(x) for x in out)

    return shade


def render_at_depth(spec, max_depth):
    """按给定深度上限渲染整帧,返回 (color, ctx)。"""
    import numpy as np
    w, h = spec["width"], spec["height"]
    cam = spec["camera"]
    eye = tuple(cam["eyeQ16"])
    view = cglib.mat_look_at(eye, tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
    right = (view[0], view[1], view[2])
    up = (view[4], view[5], view[6])
    back = (view[8], view[9], view[10])
    tan_half = cam["tanHalfFovYQ16"]
    tan_x = cglib.fmul(tan_half, cglib.fdiv(cglib.f_from_int(w), cglib.f_from_int(h)))

    ctx = {"background": tuple(spec["backgroundQ8"]), "tmax": cam["farQ16"], "spec": spec,
           "max_depth": max_depth, "calls": 0, "reflect_rays": 0,
           "stats": {"shadow_rays": 0, "shadow_hits": 0},
           "bvh_stats": {"visited": 0, "tested": 0, "rays": 0, "hits": 0}}
    ctx["shade"] = make_shader(ctx)

    color = np.empty((h, w, 3), dtype=np.uint8)
    for py in range(h):
        sy = cglib.fdiv(cglib.f_from_int(2 * (h // 2 - py) - 1), cglib.f_from_int(h))
        ay = cglib.fmul(sy, tan_half)
        for px in range(w):
            sx = cglib.fdiv(cglib.f_from_int(2 * (px - w // 2) + 1), cglib.f_from_int(w))
            ax = cglib.fmul(sx, tan_x)
            d = cglib.v_norm((cglib.fmul(ax, right[0]) + cglib.fmul(ay, up[0]) - back[0],
                              cglib.fmul(ax, right[1]) + cglib.fmul(ay, up[1]) - back[1],
                              cglib.fmul(ax, right[2]) + cglib.fmul(ay, up[2]) - back[2]))
            ctx["bvh_stats"]["rays"] += 1
            color[py, px] = trace_rec(ctx, eye, d, 0)
    return color, ctx


def main():
    import numpy as np
    spec = cglib.load_spec()
    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)

    prev, rows = None, []
    for depth in DEPTHS:
        color, ctx = render_at_depth(spec, depth)
        sha = cgtrace.digest(color)
        mae_v = 0.0 if prev is None else round(cgtrace.mae(color, prev), 6)
        changed = 0 if prev is None else int((np.any(color != prev, axis=2)).sum())
        rows.append({
            "maxDepth": depth, "sha256": sha, "traceCalls": ctx["calls"],
            "reflectRays": ctx["reflect_rays"], "shadowRays": ctx["stats"]["shadow_rays"],
            "shadowHits": ctx["stats"]["shadow_hits"], "bvhVisited": ctx["bvh_stats"]["visited"],
            "bvhTested": ctx["bvh_stats"]["tested"], "maeVsPrev": mae_v,
            "changedPixels": changed, "equalsBaseline": sha == BASELINE_SHA})
        prev = color

    cglib.write_csv(
        os.path.join(out, "cg_trace_01_3_depth.csv"),
        ["max_depth", "sha256", "trace_calls", "reflect_rays", "shadow_rays", "shadow_hits",
         "bvh_visited", "bvh_tested", "mae_vs_prev", "changed_pixels", "equals_baseline"],
        [[r["maxDepth"], r["sha256"], r["traceCalls"], r["reflectRays"], r["shadowRays"],
          r["shadowHits"], r["bvhVisited"], r["bvhTested"], r["maeVsPrev"], r["changedPixels"],
          int(r["equalsBaseline"])] for r in rows])

    stats = {"baselineSha": BASELINE_SHA, "reflectivityQ16": REFLECTIVITY, "depths": rows,
             "depth1EqualsBaseline": rows[0]["equalsBaseline"]}
    with open(os.path.join(out, "cg_trace_01_3_depth.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print("== 1.3 递归与深度限制 ==")
    print(f"反射率             : {REFLECTIVITY} Q16.16({REFLECTIVITY / 65536:.4f})")
    print(f"{'深度':>4} {'trace 调用':>10} {'反射光线':>8} {'阴影光线':>8} "
          f"{'MAE(前一档)':>12} {'变化像素':>8} {'= 基线':>6}")
    for r in rows:
        print(f"{r['maxDepth']:>4} {r['traceCalls']:>10} {r['reflectRays']:>8} "
              f"{r['shadowRays']:>8} {r['maeVsPrev']:>12} {r['changedPixels']:>8} "
              f"{str(r['equalsBaseline']):>6}")
    print(f"深度 1 sha256      : {rows[0]['sha256']}")
    print(f"卷三基线 sha256    : {BASELINE_SHA}")
    print(f"深度 1 == 基线     : {rows[0]['equalsBaseline']}")
    print("csv/json           : out/cg_trace_01_3_depth.{csv,json}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

跑一次(本机约 60 秒):

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_01_3_depth.py
== 1.3 递归与深度限制 ==
反射率             : 16384 Q16.16(0.2500)
  深度   trace 调用     反射光线     阴影光线     MAE(前一档)     变化像素   = 基线
   1      76800        0    77211          0.0        0   True
   2     120531    43731    82517     3.207756    43731  False
   3     126790    49990    85896     0.183811     6257  False
   4     129651    52851    88034     0.024614     2328  False
   5     131585    54785    89153     0.003277      604  False
   6     132808    56008    89989     0.000373       84  False
深度 1 sha256      : 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71
卷三基线 sha256    : 213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71
深度 1 == 基线     : True
csv/json           : out/cg_trace_01_3_depth.{csv,json}

六个数字怎么读

深度 1 的 sha256 等于卷三基线。 213b413069ca66c9… 与 out/cg_trace_baseline.json 里的基线逐字节相同,深度 1 == 基线 : True。这是本节最硬的一条判据:MAX_DEPTH = 1 不是「近似直接光照」,而是逐字节等于直接光照。只要反射项在深度 1 处少发一条光线或多算一个偏移,sha256 立刻分叉。

trace 调用数 76800 → 120531 → 126790 → 129651 → 131585 → 132808。 增量是 43731、6259、2861、1934、1223,逐档衰减。原因很直观:反射光线打中东西的比例远低于主光线——大部分反射光线直接飞出了场景,返回背景色,不再发下一层。于是递归树的宽度逐层收缩,总光线数趋于一个有界值,而不是无限膨胀。这是「深度有限」在代价上的直接体现。

反射光线数 0 → 43731 → 49990 → 52851 → 54785 → 56008。 深度 2 的反射光线数 43731 恰好等于主光线命中数——每一条命中主光线都发一条反射光线。深度 3 比深度 2 多出 6259 条,正是上一层的反射光线里打中场景的那些。

变化像素 43731 → 6257 → 2328 → 604 → 84。 每加一层,发生变化的像素个数也在收缩。注意深度 3 的变化像素 6257 与上一层新增的反射光线 6259 只差 2:新增的那两条光线打到某个像素上,但加上 0.25 权重后四舍五入回原来的 8 位值,所以画面没变。「多一条光线」不必然「多一个变化像素」——这正是「收敛」的微观形态。

MAE 3.207756 → 0.183811 → 0.024614 → 0.003277 → 0.000373。 相邻档的比值是 17.45、7.47、7.51、8.79,每加一层 MAE 大致降一个数量级。这个下降是两件事的乘积:变化的像素越来越少(上一段),以及每个像素上变化的幅度越来越小(每弹射一次乘 REFLECTIVITY = 0.25)。两件事都朝同一方向走,所以 MAE 掉得比单纯的 0.25 更快。

阴影光线数 77211 → 82517 → 85896 → 88034 → 89153 → 89989。 深度 1 的 77211 与卷三基线 out/cg_trace_baseline.json 的 shadowRays 逐位相同。阴影光线随深度一起增长,因为反射光线打到的新命中点也要各算自己的直接光照——每条新命中点都带一条方向光阴影光线和(可能的)一条点光源阴影光线。遮挡数 1357 → 1880 → 2839 → 3705 → 4182 → 4726 同样单调增:反射光线看到的是场景的另一面,那里被遮挡的比例更高。

跨语言核对

P 层用 BigInt 重实现同一个「直接光照 + 一层受门控反射」的递归着色器,渲染深度 1 与深度 2,与 S 侧的 golden 逐字节比对:

// snippet: 节选自 lantern/core/test/cg_trace_01_3_depth.test.ts
// traceRec 与底座 trace 逐字同构,只多传一个 dirv —— 与 S 侧 cg_trace_01_3_depth.py 同构
if (depth + 1 < ctx.maxDepth) {
  const dn = vDot(dirv, n);
  const rd: Vec3 = [dirv[0] - 2n * fmul(dn, n[0]),      // r = d - 2(d·n)n
                    dirv[1] - 2n * fmul(dn, n[1]),
                    dirv[2] - 2n * fmul(dn, n[2])];
  ctx.reflectRays += 1;
  const rc = traceRec(ctx, so, vNorm(rd), depth + 1);
  for (let i = 0; i < 3; i++) out[i] += fmul(REFLECTIVITY, c8(rc[i]));
}

两条断言把结论钉死:深度 1 的像素缓冲 sha256 必须等于卷三基线(实测相等,且 reflectRays === 0,证明深度 1 一条反射光线都没发);深度 2 的 sha256 必须与 S 侧 out/cg_trace_01_3_depth.json 的 maxDepth = 2 那行逐字节相同(实测相等,traceCalls 与 reflectRays 也逐条相同)。同一个递归结构、同一个反射公式,两侧各写一遍 BigInt/int,输出仍逐字节一致——递归的引入没有破坏跨语言确定性。

阅读导航:上一节:1.2 相机与主光线 · 下一节:2.1 球、平面与三角形 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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