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 |
| lvl1 | 44877 | — | 反射 43731 + 折射 1146 |
| lvl2 | 9232 | 20.6% | 只有 lvl1 里真正命中几何的那部分才继续 |
| lvl3 | 6881 | 74.5% | 越深,存活率反而越高 |
| lvl4 | 6269 | 91.1% | 接近饱和 |
lvl1 = 44877,其中反射 43731 条、折射 1146 条。反射光线永远存在(一条),折射光线只在 k >= 0 时存在;tir 统计显示 depth = 1 这一档就有 42585 次全内反射,所以折射分支比反射分支稀疏得多。
越深存活率越高(20.6% → 74.5% → 91.1%),是因为深处剩下的都是「没有全内反射」的那批光线——它们的入射角更「正」,命中几何的概率也更高。
代价随深度增长
| max_depth | 总光线 | 每主光线 | 相对 depth=1 | 耗时 |
|---|---|---|---|---|
| 1 | 206307 | 2.686 | 1.000× | 10054 ms |
| 2 | 222917 | 2.903 | 1.081× | 11354 ms |
| 3 | 236520 | 3.080 | 1.147× | 12629 ms |
| 4 | 248513 | 3.236 | 1.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_depth | MAE vs depth4 | PSNR | 最大像素差 | 有差异的像素 |
|---|---|---|---|---|
| 1 | 1.0039 | 48.0971 dB | 61 | 6546(85.2344‰) |
| 2 | 0.2269 | 61.0146 dB | 28 | 3538(46.0677‰) |
| 3 | 0.0651 | 71.8546 dB | 14 | 2451(31.9141‰) |
| 4 | 0(参考解) | — | 0 | 0 |
关键在于误差的符号是单边的:平均通道随深度单调下降(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 积分与估计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。