3.1 包围盒与 BVH

把 896 个三角形收进一棵扁平整数数组的 BVH:叶子 9 个整数、内部节点 10 个,left 等于负一是整棵树唯一的类型标签。本节立起两条不变量——父盒包含两个子盒、叶盒包含它引用的每个三角形,再把 slab 判据逐轴展开到方向分量为 0 的退化分支,全部落到实跑输出。

3.1 包围盒与 BVH

上一章把「一条光线与一个三角形怎么求交」钉死了:896 个三角形,每次求交都是一次 Möller–Trumbore。加速结构要解决的就是「每条光线都去问一遍全部 896 个三角形」这件事——那会让一张 320×240 的图做 76800 × 896 = 68812800 次求交,这个数字在本章第三节会被完整地量出来。边界先划清楚:实时剔除与可见性系统 讲的是实时管线里的 BVH——视锥剔除、Hi-Z、遮挡查询,目标是在一帧内决定「哪些东西根本不用画」;本章讲的是离线光追里的 BVH——一棵为「一条光线尽快找到最近交点」而建的树,关心的是构建方式与遍历代价。两者都叫 BVH,优化目标却不同:前者按视锥与屏幕空间剪,后者按光线与包围盒剪。

本节只立三件事:把节点布局钉成一段整数数组、写出 BVH 必须满足的两条不变量、把 slab 判据逐轴展开。后两节全部建在这三件东西上。

节点是一个整数数组,不是对象树

BVH 最常见的实现形态是一堆带指针的对象,但在本卷的底座里它是一段扁平的整数数组。叶子节点 9 个整数:

[lo0, lo1, lo2,  hi0, hi1, hi2,  left, count, start]

下界 3 个、上界 3 个、左子索引 1 个、三角形数 1 个、起始下标 1 个。内部节点多一个整数——右子索引放在下标 9:

[lo0, lo1, lo2,  hi0, hi1, hi2,  left, count, start,  right]

叶子用 left == -1 标记。这个哨兵值是整棵树唯一的「类型标签」:遍历时只看 left 的符号就知道该继续压栈还是该扫三角形,不需要额外的标志位,也不需要虚函数表。这 511 个节点装在一段连续整数里,于是「整棵树」可以一次性序列化成字符串、算一个 sha256——本节的判据就建立在这个哈希上。

两条不变量

BVH 的正确性只有两条内容:父盒包含两个子盒(否则 slab 判据会在父节点就剪掉本该命中的子树),叶盒包含它引用的每一个三角形(否则叶内的三角形会被误判为不可能命中)。两条都不是「大概成立」的期望,而是必须逐节点验证的不变量。

树形本身也完全规整:中位数切分保证左右子树大小最多差 1,叶子容量又是常数 4,于是树必然长成一棵满二叉树。下面脚本会把节点数、深度直方图、两条不变量的违反次数、order 是否排列、根盒是否等于全局盒全部算出来。

slab 判据:逐轴展开

一个轴对齐包围盒与一条光线的求交,可以逐轴做成一维的「板」(slab)问题。对第 k 轴,光线进入与离开这块板的参数是 t1 = (lo_k − o_k) / d_k、t2 = (hi_k − o_k) / d_k。两个值可能反序(取决于方向分量的符号),所以先交换成 t1 ≤ t2,再取所有轴上 t1 的最大值作为进入时刻、所有轴上 t2 的最小值作为离开时刻;进入晚于离开就没穿过这个盒子。

方向分量恰好为 0 时除法没有意义,判据退化成「原点是否落在这一轴的板内」的半区间测试——这不是特例分支,而是同一个判据在 1/0 = ±inf 下的极限:t1 = −inf、t2 = +inf,该轴对区间不产生约束,唯一的条件是原点必须在板内。

脚本还会放一个负例:把逐轴区间里的 if a > b: swap 那一行去掉,其余不变。正面断言全部通过不能证明判据是对的——一个恒返回 True 的实现也能全过;只有一个明显错误的变体在同一批光线上大面积分叉,才说明判据在起作用。

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_03_1_aabb_bvh.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_03_1_aabb_bvh.py
"""3.1 包围盒与 BVH:节点布局、两条不变量、slab 判据的逐轴展开。

本节立起三件东西,后两节都建在它们上面:

  1. **节点布局**:BVH 是一段扁平的整数数组,不是对象树。每个节点 9 个整数
     (下界 3 + 上界 3 + 左子 1 + 三角形数 1 + 起始下标 1),内部节点再多一个右子索引
     变成 10 个。叶子用 `left == -1` 标记——这个哨兵值是整棵树唯一的「类型标签」。
  2. **包围盒不变量**:父盒必须**包含**两个子盒,叶盒必须包含它引用的每一个三角形。
     这两条是 BVH 正确性的全部内容;不成立则 slab 判据会误杀。
  3. **slab 判据**:逐轴算 `t1 = (lo-o)/d`、`t2 = (hi-o)/d`,取 `max(t_enter)` /
     `min(t_exit)`。方向分量为 0 时除法无意义,退化成「原点是否落在这一轴的板内」
     的半区间测试——这不是特例分支,是同一个判据在 `1/0 = ±inf` 下的极限。

产物:out/cg_trace_03_1_bvh.json、out/cg_trace_03_1_depth.csv
"""

import hashlib
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
from cg_trace_03_common import ONE, bounds_of, camera_ray, contains, structure_text  # noqa: E402

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


def depth_histogram(nodes):
    """每个深度的 `[节点数, 叶子数, 内部节点数]`(深度以根为 0 计)。"""
    hist = {}

    def walk(i, d):
        slot = hist.setdefault(d, [0, 0, 0])
        slot[0] += 1
        if nodes[i][6] >= 0:
            slot[2] += 1
            walk(nodes[i][6], d + 1)
            walk(nodes[i][9], d + 1)
        else:
            slot[1] += 1

    if nodes:
        walk(0, 0)
    return hist


def check_invariants(nodes, order, tris):
    """两条不变量:父包含子、叶包含其引用的三角形。返回各自的违反次数。"""
    bad_parent = bad_leaf = 0
    for n in nodes:
        if n[6] >= 0:
            for c in (n[6], n[9]):
                if not contains((n[0:3], n[3:6]), (nodes[c][0:3], nodes[c][3:6])):
                    bad_parent += 1
        else:
            for j in range(n[8], n[8] + n[7]):
                t = tris[order[j]]
                tri = ([min(t[k], t[3 + k], t[6 + k]) for k in range(3)],
                       [max(t[k], t[3 + k], t[6 + k]) for k in range(3)])
                if not contains((n[0:3], n[3:6]), tri):
                    bad_leaf += 1
    return bad_parent, bad_leaf


def slab_intervals(lo, hi, orig, dirv, tmax):
    """把 slab 判据逐轴展开:返回 `(rows, t_enter, t_exit, 命中)`。

    方向分量为 0 时不做除法,直接判「原点是否落在该轴的板内」——数学上就是
    `t1 = -inf`、`t2 = +inf` 的极限,该轴对区间不产生约束。
    """
    t0, t1 = 0, tmax
    rows = []
    for k in range(3):
        d = dirv[k]
        if d == 0:
            inside = lo[k] <= orig[k] <= hi[k]
            rows.append((k, d, None, None, t0, t1, inside))
            if not inside:
                return rows, t0, t1, False
            continue
        inv = cglib.fdiv(ONE, d)
        a = cglib.fmul(lo[k] - orig[k], inv)
        b = cglib.fmul(hi[k] - orig[k], inv)
        if a > b:
            a, b = b, a
        if a > t0:
            t0 = a
        if b < t1:
            t1 = b
        rows.append((k, d, a, b, t0, t1, t0 <= t1))
        if t0 > t1:
            return rows, t0, t1, False
    return rows, t0, t1, True


def slab_hit_unsorted(lo, hi, orig, dirv, tmax):
    """负例:**不排序**逐轴区间的 slab 判据(少了 `if a > b` 交换)。

    原点落在板内时 `t1` 可能大于 `t2`,不交换就等价于用一个反向区间求交,结果必然
    与正确判据分叉。这是「判据通过不说明问题、必须配负例」的现场演示。
    """
    t0, t1 = 0, tmax
    for k in range(3):
        d = dirv[k]
        if d == 0:
            if orig[k] < lo[k] or orig[k] > hi[k]:
                return False
            continue
        inv = cglib.fdiv(ONE, d)
        a = cglib.fmul(lo[k] - orig[k], inv)
        b = cglib.fmul(hi[k] - orig[k], inv)
        t0 = max(t0, a)
        t1 = min(t1, b)
        if t0 > t1:
            return False
    return True


def main():
    spec = cglib.load_spec()
    tris, _dropped = cgtrace.build_scene_tris(spec)
    nodes, order = cgtrace.build_bvh(tris)
    far = spec["camera"]["farQ16"]

    leaves = [n for n in nodes if n[6] < 0]
    leaf_sizes = {}
    for n in leaves:
        leaf_sizes[n[7]] = leaf_sizes.get(n[7], 0) + 1
    hist = depth_histogram(nodes)
    height_edges, levels = max(hist), max(hist) + 1

    bad_parent, bad_leaf = check_invariants(nodes, order, tris)
    perm_ok = sorted(order) == list(range(len(tris)))
    glo, ghi = bounds_of(range(len(tris)), tris)
    root_is_global = nodes[0][0:3] == glo and nodes[0][3:6] == ghi

    # 一条真的打到地形的主光线,用来看 slab 区间怎么逐轴收缩
    eye, dirv = camera_ray(spec, 160, 200)
    hit = cgtrace.bvh_intersect(nodes, order, tris, eye, dirv, far)
    rows, te, tx, ok = slab_intervals(nodes[0][0:3], nodes[0][3:6], eye, dirv, far)

    # 轴平行光线 d = (0, 1, 0):两根分别落在 x 板内 / 板外
    axis_dir = (0, ONE, 0)
    axis_in = slab_intervals(nodes[0][0:3], nodes[0][3:6], (0, 0, 0), axis_dir, far)
    axis_out = slab_intervals(nodes[0][0:3], nodes[0][3:6], (ghi[0] + ONE, 0, 0), axis_dir, far)

    # 负例:正确判据 vs 不排序判据,在 320x240 的全部主光线上逐条比对根盒结果
    rlo, rhi = nodes[0][0:3], nodes[0][3:6]
    disagree = 0
    for py in range(spec["height"]):
        for px in range(spec["width"]):
            e, dv = camera_ray(spec, px, py)
            if cgtrace._slab_hit(rlo, rhi, e, dv, far) != slab_hit_unsorted(rlo, rhi, e, dv, far):
                disagree += 1

    os.makedirs(OUT, exist_ok=True)
    cglib.write_csv(os.path.join(OUT, "cg_trace_03_1_depth.csv"),
                    ["depth", "nodes", "leaves", "internal"],
                    [(d, hist[d][0], hist[d][1], hist[d][2]) for d in sorted(hist)])
    out = {
        "triangles": len(tris), "nodes": len(nodes), "leaves": len(leaves),
        "internal": len(nodes) - len(leaves), "nodeWidths": sorted({len(n) for n in nodes}),
        "leafSizeHist": {str(k): v for k, v in sorted(leaf_sizes.items())},
        "heightEdges": height_edges, "levels": levels,
        "depthHist": {str(k): v[0] for k, v in sorted(hist.items())},
        "invariantBadParent": bad_parent, "invariantBadLeaf": bad_leaf,
        "orderIsPermutation": perm_ok, "rootIsGlobalBox": root_is_global,
        "rootLo": nodes[0][0:3], "rootHi": nodes[0][3:6],
        "rootChildren": [nodes[0][6], nodes[0][9]],
        "structureSha256": hashlib.sha256(structure_text(nodes, order).encode()).hexdigest(),
        "demoRayPixel": [160, 200], "demoRayDir": list(dirv),
        "demoRayHitT": None if hit is None else hit[0],
        "demoRayHitTri": None if hit is None else hit[1],
        "demoRootEnter": te, "demoRootExit": tx, "demoRootSlabHit": ok,
        "axisDirZeroInside": axis_in[3], "axisDirZeroOutside": axis_out[3],
        "unsortedDisagree": disagree, "unsortedChecked": spec["width"] * spec["height"],
    }
    with open(os.path.join(OUT, "cg_trace_03_1_bvh.json"), "w", encoding="utf-8") as fh:
        json.dump(out, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    q = lambda x: f"{x / ONE:+.4f}"  # noqa: E731  Q16.16 -> 打印用小数,不参与判据
    print("== 3.1 包围盒与 BVH ==")
    print(f"三角形 / 节点   : {len(tris)} / {len(nodes)}")
    print(f"叶子 / 内部     : {len(leaves)} / {len(nodes) - len(leaves)}")
    print(f"节点整数个数    : {out['nodeWidths']}(叶子 {len(leaves[0])},内部 {len(nodes[0])})")
    print(f"叶子容量分布    : {dict(sorted(leaf_sizes.items()))}")
    print(f"树高            : {levels} 层(边数 {height_edges})")
    print(f"深度直方图      : {out['depthHist']}")
    print(f"不变量 父包含子 : 违反 {bad_parent} ;  不变量 叶含三角 : 违反 {bad_leaf}")
    print(f"order 是排列    : {perm_ok} ;  根盒 == 全局盒  : {root_is_global}")
    print(f"根节点整数      : {nodes[0]}")
    print(f"根的两子        : {nodes[0][6]}, {nodes[0][9]}")
    print(f"首叶节点整数    : {leaves[0]}")
    print(f"演示光线 像素   : (160, 200)  方向={tuple(round(v / ONE, 6) for v in dirv)}")
    print(f"  命中 t={out['demoRayHitT']} tri={out['demoRayHitTri']}")
    for k, d, a, b, t0, t1, okk in rows:
        if d == 0:
            print(f"  轴{k} d=0        : 半区间测试 板内={okk}  t=[{q(t0)}, {q(t1)}]")
        else:
            print(f"  轴{k} t1={q(a)} t2={q(b)} -> t=[{q(t0)}, {q(t1)}] ok={okk}")
    print(f"根盒 slab 命中  : {ok}  t=[{q(te)}, {q(tx)}]")
    print(f"轴平行(0,1,0)板内: {axis_in[3]}    板外(x 超上界): {axis_out[3]}")
    print(f"结构 sha256     : {out['structureSha256']}")
    print(f"负例 不排序判据 : {disagree} / {out['unsortedChecked']} 条光线与正确判据分歧")
    return 0


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

跑一遍(工作区 lantern/):

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_03_1_aabb_bvh.py
== 3.1 包围盒与 BVH ==
三角形 / 节点   : 896 / 511
叶子 / 内部     : 256 / 255
节点整数个数    : [9, 10](叶子 9,内部 10)
叶子容量分布    : {3: 128, 4: 128}
树高            : 9 层(边数 8)
深度直方图      : {'0': 1, '1': 2, '2': 4, '3': 8, '4': 16, '5': 32, '6': 64, '7': 128, '8': 256}
不变量 父包含子 : 违反 0 ;  不变量 叶含三角 : 违反 0
order 是排列    : True ;  根盒 == 全局盒  : True
根节点整数      : [-1441792, 8856, -1441792, 1441792, 425984, 1441792, 1, 0, 0, 256]
根的两子        : 1, 256
首叶节点整数    : [-1441792, 66510, -1441792, -1081344, 112944, -1261568, -1, 3, 0]
演示光线 像素   : (160, 200)  方向=(0.002228, -0.509552, -0.860443)
  命中 t=563803 tri=464
  轴0 t1=-9875.2876 t2=+9875.2876 -> t=[+0.0000, +64.0000] ok=True
  轴1 t1=-0.9812 t2=+11.5098 -> t=[+0.0000, +11.5098] ok=True
  轴2 t1=+2.3244 t2=+53.4605 -> t=[+2.3244, +11.5098] ok=True
根盒 slab 命中  : True  t=[+2.3244, +11.5098]
轴平行(0,1,0)板内: True    板外(x 超上界): False
结构 sha256     : 30b0164703b26b6ba65d8b28df929ed191f5e7634a00e8067c56a3506e313431
负例 不排序判据 : 67200 / 76800 条光线与正确判据分歧

这组数字各是什么

  • 896 / 511:896 个三角形建成 511 个节点;256 个叶子 + 255 个内部,正是一棵满二叉树该有的数目。
  • 节点整数个数 [9, 10]:叶子 9、内部 10。根节点第 6 项是 1、第 9 项是 256,右子索引确实在下标 9;首叶第 6 项是 −1、count = 3、start = 0。
  • 叶子容量分布 {3: 128, 4: 128}:128 个叶装满 4 个三角形,另外 128 个剩 3 个——奇数分裂留下的痕迹。
  • 深度直方图逐层翻倍(1, 2, 4, …, 256),树高 9 层 / 8 条边。
  • 两条不变量违反 0 次;order 是 0..895 的排列;根盒等于全局盒。
  • 演示光线的逐轴收缩:轴 0 的区间宽到罩住整个 [0, far],t_enter 仍是 0;真正把进入时刻推到 2.3244 的是轴 2;最终区间 [2.3244, 11.5098] 与命中 t = 563803(约 8.60)一致。
  • 轴平行光线:d = (0, 1, 0) 时板内为 True、x 超上界为 False,退化分支两侧都对。
  • 结构 sha256 30b01647…:把 511 个节点连同 order 排列序列化后的哈希,下一节的「排序键换舍入语义」就靠它判分叉。
  • 负例 67200 / 76800:去掉区间排序后,87.5% 的主光线上判据结论改变——判据不是恒真的。

小结

  • BVH 在本卷底座里是一段扁平整数数组:叶子 9 个整数、内部节点 10 个,left == −1 是唯一的类型标签。
  • 896 个三角形建成 511 个节点(叶子 256、内部 255),树高 9 层,深度直方图逐层翻倍。
  • 两条不变量——父包含子、叶含三角——实跑违反 0 次。
  • slab 判据逐轴算 t1 = (lo − o) / d、t2 = (hi − o) / d,取 max(t_enter) / min(t_exit);d = 0 退化成半区间测试。

下一节把「这棵树是怎么长出来的」拆开:最长轴怎么选、中位数划分离 SAH 最优有多远、叶子容量为什么是 4。

阅读导航:上一节:2.3 容差与自交 · 下一节:3.2 BVH 的构建 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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