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 球、平面与三角形 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。