3.3 BVH 的遍历与代价

把 BVH 打开、关掉、再只去掉 best_t 收缩:三个模式跑同一条代码路径、同一批 76800 条主光线,用可逐条复现的整数计数(访问节点数、三角形测试数)与逐像素比对,回答加速结构到底省掉了什么、又省得够不够多;全部结论只建立在计数上,不做速度断言。

3.3 BVH 的遍历与代价

前两节把树建好了:511 个节点、树高 9、叶子容量 4。但「建了一棵加速结构」本身不是结论——结论应该是「这棵树在遍历时到底省掉了什么,省得够不够多」。本节用整数计数来回答,不用秒表。边界再划一次:实时剔除与可见性系统 里量的是实时管线的可见性代价(视锥剔除率、Hi-Z 通过率、每帧的绘制调用数);本节量的是离线光追的遍历代价——每条光线访问多少节点、测试多少三角形。前者是「一帧内省下多少东西不用画」,后者是「一条光线省下多少次求交」。

同一条代码路径,只换遍历

三个模式跑的是完全相同的 render_trace 与完全相同的着色器,唯一的差别是模块级 bvh_intersect 被换成三个实现之一:

  • bvh:底座实现,slab 判据用已收缩的 best_t 做上界;
  • noshrink:其余一字不改,slab 判据改用原始的 tmax(即不收缩 best_t);
  • linear:完全没有 BVH,逐条光线线性扫描全部 896 个三角形。

计数分两笔,对应遍历里两件成本不同的事:visited 是从栈里弹出并做过 slab 测试的节点数,tested 是真正跑过 Möller–Trumbore 的三角形数。两者再按光线种类拆开——主光线的起点恒等于相机位置 eye,阴影光线的起点是命中点加法线偏移,用 orig == eye 一个判据就能分开,不必改动底座签名。

收缩 best_t 到底省了什么

遍历时维护一个当前最近命中距离 best_t,初始为 far;每次命中就把它收缩到新的 t。收缩后的值会作为 slab 判据的上界传下去(tmax = min(tmax, t_hit))——盒子的进入时刻晚于当前已知的最近命中,这棵子树就不可能贡献更近的交点,整棵剪掉。把它关掉,节点访问数与三角形测试数都应当变大,而命中结果一个字都不该变——这正是下面负例要验的。

三个模式的整数计数

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_03_3_bvh_traversal_cost.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_03_3_bvh_traversal_cost.py
"""3.3 BVH 的遍历与代价:同一份场景、同一条代码路径,只换遍历方式。

三个模式跑的是**完全相同的** `cgtrace.render_trace` 与着色器(`render_trace.make_shader`),
唯一的差别是模块级 `cgtrace.bvh_intersect` 被换成三个实现之一:`bvh`(底座,slab 判据用
**已收缩的** `best_t` 做上界)、`noshrink`(其余不变,slab 判据改用**原始的** `tmax`)、
`linear`(完全没有 BVH,逐条光线线性扫描全部 896 个三角形)。

计数分两笔:`visited` 是从栈里弹出并做过 slab 测试的节点数,`tested` 是真正跑过
Möller–Trumbore 的三角形数;再按光线种类拆开——主光线起点恒等于相机位置 `eye`,阴影
光线起点是命中点加法线偏移,用 `orig == eye` 一个判据即可分开,无需改动底座签名。

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

import json
import os
import sys
import time

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

OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "out")
BASELINE_SHA = "213b413069ca66c9f0cc091bce5328eac0f540d075afa3bab37788aea335ba71"

MODE, EYE = "bvh", None
BUCKETS = {}


def tri_at(tris, ti):
    t = tris[ti]
    return (t[0], t[1], t[2]), (t[3], t[4], t[5]), (t[6], t[7], t[8])


def linear_intersect(nodes, order, tris, orig, dirv, tmax, stats=None):
    """无加速结构:线性扫描全部三角形,取最小正 `t`。

    它**没有**提前退出的余地——必须看完全部 896 个三角形才知道谁最近,所以
    「每次遍历 896 次测试」是结构决定的下界,不是实现的偷懒。
    """
    best_t, best = tmax, None
    for ti in range(len(tris)):
        if stats is not None:
            stats["tested"] += 1
        r = cgtrace.intersect_triangle(orig, dirv, *tri_at(tris, ti))
        if r is not None and r[0] < best_t:
            best_t, best = r[0], (r[0], ti, r[1], r[2])
    return best


def noshrink_intersect(nodes, order, tris, orig, dirv, tmax, stats=None):
    """与底座同结构,**唯一**差别:slab 判据用原始 `tmax` 而不是收缩后的 `best_t`。

    叶内的接受判据仍用 `best_t`,所以「选哪个三角形」的规则没变,变的是**有多少
    节点通过了 slab 测试**——这正是「收缩」在计数上留下的痕迹。
    """
    best_t, best = tmax, None
    stack = [0] if nodes else []
    while stack:
        node = nodes[stack.pop()]
        if stats is not None:
            stats["visited"] += 1
        if not cgtrace._slab_hit(node[0:3], node[3:6], orig, dirv, tmax):
            continue
        if node[6] < 0:
            for j in range(node[8], node[8] + node[7]):
                ti = order[j]
                if stats is not None:
                    stats["tested"] += 1
                r = cgtrace.intersect_triangle(orig, dirv, *tri_at(tris, ti))
                if r is not None and r[0] < best_t:
                    best_t, best = r[0], (r[0], ti, r[1], r[2])
        else:
            stack.append(node[6])
            stack.append(node[9])
    return best


IMPL = {"bvh": cgtrace.bvh_intersect, "noshrink": noshrink_intersect,
        "linear": linear_intersect}


def dispatch(nodes, order, tris, orig, dirv, tmax, stats=None):
    """三模式共用的入口:一次遍历的计数同时记进调用方的 stats 与按种类分的桶。

    分桶判据只有一个:主光线的起点恒等于相机位置 `eye`,阴影光线的起点是命中点
    加法线偏移,两者永不相等。这样就不必改动 `cgtrace.trace` 的签名。
    """
    local = {"visited": 0, "tested": 0}
    res = IMPL[MODE](nodes, order, tris, orig, dirv, tmax, local)
    if stats is not None:
        stats["visited"] += local["visited"]
        stats["tested"] += local["tested"]
    b = BUCKETS["primary" if orig == EYE else "shadow"]
    b["calls"] += 1
    b["visited"] += local["visited"]
    b["tested"] += local["tested"]
    return res


def run(mode, spec, eye):
    """跑一次完整渲染(主光线 + 阴影光线 + 着色),返回像素缓冲与计数。"""
    global MODE, EYE, BUCKETS
    MODE, EYE = mode, eye
    BUCKETS = {k: {"calls": 0, "visited": 0, "tested": 0} for k in ("primary", "shadow")}
    cgtrace.bvh_intersect = dispatch
    t0 = time.time()
    color, stats = cgtrace.render_trace(spec, spec["width"], spec["height"], rt.MAX_DEPTH,
                                        rt.make_shader,
                                        {"stats": {"shadow_rays": 0, "shadow_hits": 0}})
    ms = int((time.time() - t0) * 1000)
    cgtrace.bvh_intersect = IMPL["bvh"]
    return color, stats, dict(BUCKETS), ms


def pixel_diff(a, b, rays):
    """两张颜色缓冲的逐像素分歧数(缓冲是 (h, w, 3) 的 numpy 数组)。"""
    fa = a.reshape(-1, 3) if hasattr(a, "reshape") else a
    fb = b.reshape(-1, 3) if hasattr(b, "reshape") else b
    return sum(1 for i in range(rays) if tuple(fa[i]) != tuple(fb[i]))


def main():
    only = [a for a in sys.argv[1:] if not a.startswith("-")] or ["bvh", "noshrink", "linear"]
    spec = cglib.load_spec()
    eye, far = tuple(spec["camera"]["eyeQ16"]), spec["camera"]["farQ16"]
    rays = spec["width"] * spec["height"]
    tris, _dropped = cgtrace.build_scene_tris(spec)
    ntris = len(tris)

    results, colors = {}, {}
    for mode in only:
        print(f"... 跑 {mode}", flush=True)
        color, stats, buckets, ms = run(mode, spec, eye)
        colors[mode] = color
        calls = buckets["primary"]["calls"] + buckets["shadow"]["calls"]
        results[mode] = {
            "sha256": cgtrace.digest(color),
            "visited": stats["visited"], "tested": stats["tested"], "traversals": calls,
            "visitedPerPrimaryRay": round(stats["visited"] / rays, 4),
            "testedPerPrimaryRay": round(stats["tested"] / rays, 4),
            "visitedPerTraversal": round(stats["visited"] / calls, 4),
            "testedPerTraversal": round(stats["tested"] / calls, 4),
            "primary": buckets["primary"], "shadow": buckets["shadow"], "ms": ms,
        }
        r = results[mode]
        r["matchesBaseline"] = r["sha256"] == BASELINE_SHA
        print(f"    sha={r['sha256'][:16]}… visited={r['visited']} tested={r['tested']}"
              f" traversals={calls} ms={ms}", flush=True)

    pix = {}
    for a, b in (("bvh", "noshrink"), ("noshrink", "linear")):
        if a in colors and b in colors:
            pix[f"{a}Vs{b}"] = pixel_diff(colors[a], colors[b], rays)

    base = results.get("bvh")
    ratio = None
    if base and "linear" in results:
        ratio = {
            "perTraversal": round(results["linear"]["testedPerTraversal"]
                                  / base["testedPerTraversal"], 4),
            "perPrimaryRay": round(ntris / base["testedPerPrimaryRay"], 4),
        }

    out = {
        "triangles": ntris, "primaryRays": rays, "modes": results,
        "baselineSha256": BASELINE_SHA, "pixelDiff": pix,
        "linearTotalTested": ntris * base["traversals"] if base else None,
        "ratioLinearOverBvh": ratio,
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "cg_trace_03_3_cost.json"), "w", encoding="utf-8") as fh:
        json.dump(out, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    rows = [(m, r["visited"], r["tested"], r["traversals"], r["visitedPerPrimaryRay"],
             r["testedPerPrimaryRay"], r["primary"]["visited"], r["primary"]["tested"],
             r["shadow"]["visited"], r["shadow"]["tested"], r["ms"], r["sha256"][:16])
            for m, r in ((m, results[m]) for m in ("bvh", "noshrink", "linear") if m in results)]
    cglib.write_csv(os.path.join(OUT, "cg_trace_03_3_cost.csv"),
                    ["mode", "visited", "tested", "traversals",
                     "visited_per_primary", "tested_per_primary",
                     "primary_visited", "primary_tested", "shadow_visited", "shadow_tested",
                     "ms", "sha16"], rows)

    print("== 3.3 BVH 的遍历与代价 ==")
    print(f"三角形 / 主光线 : {ntris} / {rays}")
    for m in ("bvh", "noshrink", "linear"):
        if m not in results:
            continue
        r = results[m]
        print(f"[{m:8s}] sha256={r['sha256'][:16]}…  基线={r['matchesBaseline']}")
        print(f"           遍历次数={r['traversals']:7d}  访问节点={r['visited']:9d}"
              f"  测试三角形={r['tested']:10d}  耗时={r['ms']:7d} ms")
        print(f"           每主光线 访问={r['visitedPerPrimaryRay']:9.4f}"
              f"  测试={r['testedPerPrimaryRay']:9.4f}"
              f" | 每次遍历 访问={r['visitedPerTraversal']:7.4f}"
              f"  测试={r['testedPerTraversal']:7.4f}")
        print(f"           主光线 访问={r['primary']['visited']:9d}"
              f" 测试={r['primary']['tested']:9d}"
              f" | 阴影 访问={r['shadow']['visited']:9d}"
              f" 测试={r['shadow']['tested']:9d}")
    if base and "noshrink" in results:
        nb = results["noshrink"]
        dv, dt = nb["visited"] - base["visited"], nb["tested"] - base["tested"]
        print(f"收缩 vs 不收缩:访问 {dv:+d}({dv / nb['visited'] * 100:+.2f}% 相对不收缩)"
              f"  测试 {dt:+d}({dt / nb['tested'] * 100:+.2f}%)")
    if "noshrinkVslinear" in pix:
        print(f"noshrink 与 linear 图像逐像素分歧 : {pix['noshrinkVslinear']} / {rays}"
              f"(sha 相同 {results['noshrink']['sha256'] == results['linear']['sha256']})")
    if "bvhVsnoshrink" in pix:
        print(f"bvh 与 noshrink 图像逐像素分歧   : {pix['bvhVsnoshrink']} / {rays}")
    if base:
        print(f"无 BVH 全量测试数     : {ntris} × {base['traversals']}"
              f" = {ntris * base['traversals']}")
    if ratio:
        print(f"测试数之比 线性/BVH   : 每次遍历 {ratio['perTraversal']:.4f}"
              f"  每主光线 {ratio['perPrimaryRay']:.4f}")
    return 0


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

跑一遍(工作区 lantern/,linear 模式约需四分钟):

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_03_3_bvh_traversal_cost.py
== 3.3 BVH 的遍历与代价 ==
三角形 / 主光线 : 896 / 76800
[bvh     ] sha256=213b413069ca66c9…  基线=True
           遍历次数= 154011  访问节点=  3238333  测试三角形=   1119804  耗时=   6874 ms
           每主光线 访问=  42.1658  测试=  14.5808 | 每次遍历 访问=21.0266  测试= 7.2709
           主光线 访问=  1347708 测试=   463033 | 阴影 访问=  1890625 测试=   656771
[noshrink] sha256=213b413069ca66c9…  基线=True
           遍历次数= 154011  访问节点=  3484731  测试三角形=   1309882  耗时=   7837 ms
           每主光线 访问=  45.3741  测试=  17.0558 | 每次遍历 访问=22.6265  测试= 8.5051
           主光线 访问=  1584942 测试=   647774 | 阴影 访问=  1899789 测试=   662108
[linear  ] sha256=213b413069ca66c9…  基线=True
           遍历次数= 154011  访问节点=        0  测试三角形= 137993856  耗时= 247549 ms
           每主光线 访问=   0.0000  测试=1796.7950 | 每次遍历 访问= 0.0000  测试=896.0000
           主光线 访问=        0 测试= 68812800 | 阴影 访问=        0 测试= 69181056
收缩 vs 不收缩:访问 +246398(+7.07% 相对不收缩)  测试 +190078(+14.51%)
noshrink 与 linear 图像逐像素分歧 : 0 / 76800(sha 相同 True)
bvh 与 noshrink 图像逐像素分歧   : 0 / 76800
无 BVH 全量测试数     : 896 × 154011 = 137993856
测试数之比 线性/BVH   : 每次遍历 123.2310  每主光线 61.4507

怎么读这张表

遍历次数 154011 = 76800 条主光线 + 77211 条阴影光线,三个模式完全相同——换的是遍历方式,不是发多少条光线。

两个口径要分清楚。 「每次遍历」= 计数 ÷ 154011(含主光线与阴影光线);「每主光线」= 计数 ÷ 76800。bvh 的三角形测试是 1119804 次,于是每次遍历 7.2709 个、每主光线 14.5808 个。无 BVH 时每次遍历恒为 896 个(全部三角形)、每主光线 1796.7950 个——因为每条主光线平均还要发约 2 条阴影光线,分母却是主光线条数。「测试数之比 线性/BVH」两个数(123.2310 与 61.4507)的差别只来自分母不同,不是两次测量。

这两个比值是计数之比,不是速度之比。 耗时那一列(6874 / 7837 / 247549 ms)只说明本机这一次的顺序,不构成任何「快 N 倍」的结论:一次 linear 遍历要做 896 次 Möller–Trumbore,而 bvh 平均只做 7.27 次,但两者的单次成本并不相同,Python 的循环开销也在里面。本节全部结论都只建立在整数计数上,它们逐条可复现。

主光线与阴影光线的成本不一样。 bvh 下主光线访问 1347708 个节点、测试 463033 个三角形;阴影光线访问 1890625 个节点、测试 656771 个三角形。阴影光线的节点访问更多(它们从命中点出发,穿过更多子树),而阴影光线根本不需要找「最近的」交点——只要找到任何一个遮挡就可以停。这里如实报告:底座没有为阴影光线做任何提前退出优化,它和主光线走的是同一条遍历。

负例:不收缩会怎样

noshrink 只改了一处——slab 判据的上界从收缩后的 best_t 换回原始 tmax。结果是访问 +246398(相对不收缩 +7.07%)、测试 +190078(+14.51%)。多出来的访问与测试集中在主光线侧(主光线测试从 463033 涨到 647774),阴影侧几乎不变(656771 → 662108)——因为阴影光线本来就在第一个遮挡处停止收缩。

关键在最后两行:bvh 与 noshrink 的图像逐像素分歧 0 / 76800,noshrink 与 linear 也是 0 / 76800,三个模式的 sha256 与卷三基线 213b4130… 逐字节相同。多访问了 24 万个节点、多测了 19 万个三角形,没有任何一条光线的命中结果被改变——这才是「剪枝是保守的」这句话的可证伪形式:省掉的工作确实是不必要的工作,而不是把本该命中的交点一起省掉了。

P 层把计数钉死

P 层在 TypeScript 侧重跑同一批主光线,断言的是精确整数而不是四舍五入后的均值,负例则要求「计数严格变大、命中逐条相同」两件事同时成立:

// snippet: core/test/cg_trace_03_3_bvh_traversal_cost.test.ts(节选:负例)
// verify: node --test 'core/test/cg_trace_03_3_bvh_traversal_cost.test.ts'
  assert.ok(unshrunk.visited > shrunk.visited, "不收缩必然多访问节点");
  assert.equal(unshrunk.visited - shrunk.visited, 237234);
  // 关键:多出来的访问与测试**没有改变任何一条光线的命中结果**——剪枝是保守的。
  for (let i = 0; i < rays.length; i += 1)
    assert.deepEqual(unshrunk.hits[i], shrunk.hits[i], `第 ${i} 条主光线的命中必须相同`);

小结

  • 遍历次数 154011 = 76800 主光线 + 77211 阴影光线,三个模式一致;bvh 三角形测试 1119804(每主光线 14.5808、每次遍历 7.2709),无 BVH 时每次遍历恒为 896、全量 137993856 次。
  • 计数之比 线性/BVH:每次遍历 123.2310、每主光线 61.4507;两个口径只是分母不同,且都是计数之比,不是速度之比。
  • 关掉 best_t 收缩:访问 +246398、测试 +190078,但逐像素分歧 0 / 76800,三个模式 sha256 与基线逐字节相同;阴影光线占了节点访问的 1890625 / 3238333(约 58%),而底座没为它做提前退出。

下一章从「光线被挡住之后」接着走:阴影光线本身怎么发、偏移向量为什么不是起点。

阅读导航:上一节:3.2 BVH 的构建 · 下一节:4.1 硬阴影与阴影光线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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