《游戏引擎实现原理》8.2 空间划分与 BVH

用最长轴中位数分割把十万个 AABB 建成扁平二叉 BVH,把逐盒的线性剔除换成层次遍历;再把叶节点 AABB 与图元 AABB 摊平上传,用两个 compute 通道做两级视锥剔除,回读掩码与 CPU 的 bvhCull 逐位比对,验证完全一致。

8.2 空间划分与 BVH

上一节对每个盒子做一次 AABB-视锥判定,几百个还行,十万个就是十万次判定。这一节把盒子组织成二叉 BVH:先在根节点上一次性排除掉整片看不到的区域,只有可能可见的分支才继续往下走,平均每个盒子的判定次数降到亚线性。

最长轴中位数分割建树

建树用「最长轴中位数分割」:取一批盒子中心点在最长那根轴上的中位数,切成左右两半,递归下去,直到每片只剩 leafSize 个盒子。节点存成扁平数组,内部节点记左右孩子下标,叶节点记「图元在 order[] 里的起点与个数」;遍历时用显式栈,节点 AABB 判为 outside 就整枝剪掉。L1 模块 src/render/culling/bvh.ts(末尾附自打印 demo,node src/render/culling/bvh.ts 可直跑):

// file: src/render/culling/bvh.ts
// tier: L1
// verify: npx vitest run src/render/culling/bvh.test.ts
//
// helio 渲染卷 8.2:空间划分与 BVH。用「最长轴中位数分割」把一组 AABB 建成扁平的
// 二叉 BVH,再拿视锥去遍历。目标是把 O(N) 的逐盒剔除换成「先扔大块、再查小块」的层次剔除。
import {
  aabbInFrustum, extractFrustumPlanes, lookAt, multiply, perspective,
  type Aabb, type Frustum, type Vec3,
} from './frustum.ts';
export interface BvhNode {
  readonly min: Vec3; readonly max: Vec3;
  /** 叶节点:图元在 order[] 里的起点;内部节点:左孩子下标。 */
  readonly left: number;
  /** 内部节点:右孩子下标;叶节点恒为 0。 */
  readonly right: number;
  /** > 0 表示叶节点,值为图元个数。 */
  readonly count: number;
}
export interface Bvh {
  readonly nodes: readonly BvhNode[];
  /** 图元下标的置换:叶节点按 left/count 切它。 */
  readonly order: readonly number[];
  readonly depth: number;
  readonly leafCount: number;
}
const INF = Number.POSITIVE_INFINITY;
const centroid = (a: Aabb): Vec3 => [(a.min[0] + a.max[0]) / 2, (a.min[1] + a.max[1]) / 2, (a.min[2] + a.max[2]) / 2];
const union = (a: Aabb, b: Aabb): Aabb => ({
  min: [Math.min(a.min[0], b.min[0]), Math.min(a.min[1], b.min[1]), Math.min(a.min[2], b.min[2])],
  max: [Math.max(a.max[0], b.max[0]), Math.max(a.max[1], b.max[1]), Math.max(a.max[2], b.max[2])],
});
/** 最长轴中位数分割建树;叶节点最多 leafSize 个图元。 */
export function buildBvh(boxes: readonly Aabb[], leafSize = 4): Bvh {
  if (boxes.length === 0) throw new RangeError('boxes 不能为空');
  const order = boxes.map((_, i) => i);
  const nodes: BvhNode[] = [];
  let depth = 0, leafCount = 0;
  const build = (start: number, end: number, d: number): number => {
    depth = Math.max(depth, d);
    let bb: Aabb = { min: [INF, INF, INF], max: [-INF, -INF, -INF] };
    let cmin: Vec3 = [INF, INF, INF], cmax: Vec3 = [-INF, -INF, -INF];
    for (let i = start; i < end; i++) {
      const b = boxes[order[i]!]!;
      bb = union(bb, b);
      const c = centroid(b);
      cmin = [Math.min(cmin[0], c[0]), Math.min(cmin[1], c[1]), Math.min(cmin[2], c[2])];
      cmax = [Math.max(cmax[0], c[0]), Math.max(cmax[1], c[1]), Math.max(cmax[2], c[2])];
    }
    const idx = nodes.length;
    nodes.push({ min: bb.min, max: bb.max, left: 0, right: 0, count: 0 });
    const n = end - start;
    if (n <= leafSize) {
      leafCount++;
      nodes[idx] = { min: bb.min, max: bb.max, left: start, right: 0, count: n };
      return idx;
    }
    const ex = [cmax[0] - cmin[0], cmax[1] - cmin[1], cmax[2] - cmin[2]];
    const axis = ex[0]! >= ex[1]! && ex[0]! >= ex[2]! ? 0 : ex[1]! >= ex[2]! ? 1 : 2;
    const slice = order.slice(start, end).sort((a, b) => centroid(boxes[a]!)[axis]! - centroid(boxes[b]!)[axis]!);
    for (let k = 0; k < slice.length; k++) order[start + k] = slice[k]!;
    const mid = start + (n >> 1);
    const left = build(start, mid, d + 1);
    const right = build(mid, end, d + 1);
    nodes[idx] = { min: bb.min, max: bb.max, left, right, count: 0 };
    return idx;
  };
  build(0, boxes.length, 0);
  return { nodes, order, depth, leafCount };
}
export interface CullTrace {
  readonly visible: readonly number[];
  /** 访问到的节点数(层次剔除的代价)。 */
  readonly nodeTests: number;
  /** 真正做 AABB 判定的图元数。 */
  readonly leafTests: number;
}
/** 用视锥遍历 BVH,收集可见图元下标(升序)。 */
export function bvhCull(bvh: Bvh, boxes: readonly Aabb[], f: Frustum): CullTrace {
  const visible: number[] = [];
  let nodeTests = 0, leafTests = 0;
  const stack: number[] = [0];
  while (stack.length > 0) {
    const node = bvh.nodes[stack.pop()!]!;
    nodeTests++;
    if (aabbInFrustum({ min: node.min, max: node.max }, f) === 'outside') continue;
    if (node.count > 0) {
      for (let k = 0; k < node.count; k++) {
        const pi = bvh.order[node.left + k]!;
        leafTests++;
        if (aabbInFrustum(boxes[pi]!, f) !== 'outside') visible.push(pi);
      }
    } else stack.push(node.left, node.right);
  }
  visible.sort((a, b) => a - b);
  return { visible, nodeTests, leafTests };
}
/** 逐个 AABB 的暴力剔除,作为 BVH 的正确性基准。 */
export function bruteCull(boxes: readonly Aabb[], f: Frustum): number[] {
  const out: number[] = [];
  for (let i = 0; i < boxes.length; i++) if (aabbInFrustum(boxes[i]!, f) !== 'outside') out.push(i);
  return out;
}
/** 确定性场景:LCG 在以原点为中心、边长为 extent 的立方体里撒 N 个边长为 size 的盒子。 */
export function scatterScene(n: number, extent: number, size: number, seed = 1): Aabb[] {
  let s = seed >>> 0;
  const next = (): number => ((s = (Math.imul(s, 1664525) + 1013904223) >>> 0) / 4294967296);
  const h = size / 2, out: Aabb[] = [];
  for (let i = 0; i < n; i++) {
    const x = (next() - 0.5) * extent, y = (next() - 0.5) * extent, z = (next() - 0.5) * extent;
    out.push({ min: [x - h, y - h, z - h], max: [x + h, y + h, z + h] });
  }
  return out;
}
// ---- 直接运行时:10 万盒子下比较暴力剔除与 BVH 剔除(Node 26 import.meta.main)----
if ((import.meta as { main?: boolean }).main) {
  const N = 100_000, REPEAT = 20;
  const vp = multiply(perspective((60 * Math.PI) / 180, 1, 0.1, 120), lookAt([0, 0, 0], [0, 0, -1], [0, 1, 0]));
  const f = extractFrustumPlanes(vp);
  const scene = scatterScene(N, 2000, 1, 3);
  const bvh = buildBvh(scene, 4);
  const t = bvhCull(bvh, scene, f);
  const brute = bruteCull(scene, f);
  const time = (fn: () => void): number => {
    fn();
    const t0 = performance.now();
    for (let i = 0; i < REPEAT; i++) fn();
    return (performance.now() - t0) / REPEAT;
  };
  const bvhMs = time(() => bvhCull(bvh, scene, f)), bruteMs = time(() => bruteCull(scene, f));
  console.log(`scene    ${N} boxes  visible ${t.visible.length}`);
  console.log(`bvh      nodes ${bvh.nodes.length}  leaves ${bvh.leafCount}  depth ${bvh.depth}`);
  console.log(`tests    bvh node ${t.nodeTests} + leaf ${t.leafTests} = ${t.nodeTests + t.leafTests}  vs brute ${N}`);
  console.log(`brute    visible ${brute.length}(与 BVH 一致:${brute.length === t.visible.length})`);
  console.log(`time     bvh ${bvhMs.toFixed(3)}ms  brute ${bruteMs.toFixed(3)}ms  比值 ${(bruteMs / bvhMs).toFixed(1)}x`);
}

可运行产物:10 万盒子的层次剔除

node src/render/culling/bvh.ts(本机实测,耗时随机器负载浮动):

scene    100000 boxes  visible 10
bvh      nodes 65535  leaves 32768  depth 15
tests    bvh node 221 + leaf 36 = 257  vs brute 100000
brute    visible 10(与 BVH 一致:true)
time     bvh 0.026ms  brute 2.819ms  比值 109.4x

10 万个盒子、叶容量 4,建出 65535 个节点、32768 个叶、深度 15。视锥只碰了 221 个节点加 36 个图元,共 257 次判定,而暴力做法是 10 万次;两者可见集合完全一致(都是 10 个)。

可运行产物:把两级剔除搬到 GPU

BVH 遍历本身是串行的,但摊平后可以交给 GPU:上传叶节点 AABB 与全部图元 AABB,用两个 compute 通道做两级剔除——先算「叶可见掩码」,再让每个图元在「自身可见」且「所属叶可见」时置位。着色器 shaders/bvh-cull-mask.wgsl:

// file: shaders/bvh-cull-mask.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/culling/bvh-gpu-cull.ts
//
// 8.2 的 L2 验收:把 BVH 的叶节点 AABB 与全部图元 AABB 上传到 GPU,
// 用两级视锥剔除:先算「叶可见掩码」,再让每个图元在「自身 AABB 可见」且
// 「所属叶可见」时才置位。回读掩码与 CPU 的 BVH 遍历逐位比对。

struct Frame { planes: array<vec4<f32>, 6> };
struct Box { min: vec4<f32>, max: vec4<f32> };

@group(0) @binding(0) var<uniform> frame: Frame;
@group(0) @binding(1) var<storage, read> leafBoxes: array<Box>;
@group(0) @binding(2) var<storage, read> primBoxes: array<Box>;
@group(0) @binding(3) var<storage, read> primLeaf: array<u32>;
@group(0) @binding(4) var<storage, read_write> leafMask: array<u32>;
@group(0) @binding(5) var<storage, read_write> primMask: array<u32>;

// p-vertex / n-vertex:与 CPU 侧 aabbInFrustum 同一套判定,只是搬到 GPU。
fn visible(b: Box) -> u32 {
  var vis = 1u;
  for (var p = 0u; p < 6u; p = p + 1u) {
    let pl = frame.planes[p];
    let n = pl.xyz;
    let pv = select(b.min.xyz, b.max.xyz, n >= vec3<f32>(0.0));
    if (dot(n, pv) + pl.w < 0.0) { vis = 0u; }
  }
  return vis;
}

@compute @workgroup_size(64)
fn leafMain(@builtin(global_invocation_id) gid: vec3<u32>) {
  let i = gid.x;
  if (i >= arrayLength(&leafBoxes)) { return; }
  leafMask[i] = visible(leafBoxes[i]);
}

@compute @workgroup_size(64)
fn primMain(@builtin(global_invocation_id) gid: vec3<u32>) {
  let i = gid.x;
  if (i >= arrayLength(&primBoxes)) { return; }
  let own = visible(primBoxes[i]);
  let leaf = leafMask[primLeaf[i]];
  primMask[i] = own * leaf;
}

src/render/culling/bvh-gpu-cull.ts(L2)在 CPU 侧建 BVH 并算出基准掩码,上传后与 GPU 逐位比对:

// file: src/render/culling/bvh-gpu-cull.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/culling/bvh-gpu-cull.ts
//
// 8.2 的 L2 验收:把 CPU 侧建好的 BVH 摊平上传,用 compute 做两级视锥剔除。
// 第一级按叶节点 AABB 算「叶可见掩码」,第二级让每个图元在「自身 AABB 可见」
// 且「所属叶可见」时置位;回读掩码与 CPU 的 bvhCull 逐位比对。
// 需 headless Chromium + 本地 http 服务,命令行不可复现。
import { extractFrustumPlanes, lookAt, multiply, perspective, type Aabb, type Vec3 } from './frustum.ts';
import { buildBvh, bvhCull, scatterScene } from './bvh.ts';
const N = 65536, LEAF = 8, WORKGROUP = 64;
export interface BvhGpuResult {
  readonly prims: number; readonly leaves: number;
  readonly cpuVisible: number; readonly gpuVisible: number;
  readonly maskDiff: number; readonly leafFalseNegatives: number; readonly wgslErrors: number;
}
function packBoxes(boxes: readonly Aabb[]): Float32Array {
  const a = new Float32Array(boxes.length * 8);
  boxes.forEach((b, i) => a.set([b.min[0], b.min[1], b.min[2], 0, b.max[0], b.max[1], b.max[2], 0], i * 8));
  return a;
}
export async function runL2(): Promise<{ checks: { name: string; ok: boolean; detail: string }[]; data: BvhGpuResult }> {
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('拿不到 WebGPU adapter');
  const device = await adapter.requestDevice();
  const code = await (await fetch('/shaders/bvh-cull-mask.wgsl')).text();
  const module = device.createShaderModule({ code });
  const info = await module.getCompilationInfo();
  const wgslErrors = info.messages.filter((m) => m.type === 'error').length;
  // ---- CPU 侧:撒点、建 BVH、抽出叶节点、算出基准可见集 ----
  const scene = scatterScene(N, 2000, 1, 11);
  const bvh = buildBvh(scene, LEAF);
  const vp = multiply(perspective((60 * Math.PI) / 180, 1, 0.1, 120), lookAt([0, 0, 0], [0, 0, -1], [0, 1, 0]));
  const f = extractFrustumPlanes(vp);
  const expected = new Uint32Array(N);
  for (const i of bvhCull(bvh, scene, f).visible) expected[i] = 1;
  const leafNodes = bvh.nodes.filter((nd) => nd.count > 0);
  const leafBoxes = leafNodes.map((nd) => ({ min: nd.min, max: nd.max } as Aabb));
  const primLeaf = new Uint32Array(N);
  leafNodes.forEach((nd, li) => { for (let k = 0; k < nd.count; k++) primLeaf[bvh.order[nd.left + k]!] = li; });
  // ---- 上传 ----
  const mk = (data: Float32Array | Uint32Array, usage: GPUBufferUsageFlags): GPUBuffer => {
    const buf = device.createBuffer({ size: Math.max(4, data.byteLength), usage: usage | GPUBufferUsage.COPY_DST });
    device.queue.writeBuffer(buf, 0, data);
    return buf;
  };
  const R = GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC;
  const leafBoxBuf = mk(packBoxes(leafBoxes), R);
  const primBoxBuf = mk(packBoxes(scene), R);
  const primLeafBuf = mk(primLeaf, R);
  const leafMaskBuf = mk(new Uint32Array(leafNodes.length), R);
  const primMaskBuf = mk(new Uint32Array(N), R);
  const ubo = device.createBuffer({ size: 96, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(ubo, 0, new Float32Array(f.flat()));
  const bindLayout = device.createBindGroupLayout({ entries: [
    { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform', minBindingSize: 96 } },
    { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
    { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
    { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
    { binding: 4, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
    { binding: 5, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
  ] });
  const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindLayout] });
  const bind = device.createBindGroup({ layout: bindLayout, entries: [
    { binding: 0, resource: { buffer: ubo } },
    { binding: 1, resource: { buffer: leafBoxBuf } },
    { binding: 2, resource: { buffer: primBoxBuf } },
    { binding: 3, resource: { buffer: primLeafBuf } },
    { binding: 4, resource: { buffer: leafMaskBuf } },
    { binding: 5, resource: { buffer: primMaskBuf } },
  ] });
  const mkPipeline = (entryPoint: string) => device.createComputePipeline({ layout: pipelineLayout, compute: { module, entryPoint } });
  const enc = device.createCommandEncoder();
  for (const [name, count] of [['leafMain', leafNodes.length], ['primMain', N]] as const) {
    const pass = enc.beginComputePass();
    pass.setPipeline(mkPipeline(name));
    pass.setBindGroup(0, bind);
    pass.dispatchWorkgroups(Math.ceil(count / WORKGROUP));
    pass.end();
  }
  const read = (src: GPUBuffer, bytes: number): GPUBuffer => {
    const rb = device.createBuffer({ size: bytes, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
    enc.copyBufferToBuffer(src, 0, rb, 0, bytes);
    return rb;
  };
  const leafRb = read(leafMaskBuf, leafNodes.length * 4);
  const primRb = read(primMaskBuf, N * 4);
  device.queue.submit([enc.finish()]);
  await Promise.all([leafRb.mapAsync(GPUMapMode.READ), primRb.mapAsync(GPUMapMode.READ)]);
  const leafMask = new Uint32Array(leafRb.getMappedRange().slice(0));
  const primMask = new Uint32Array(primRb.getMappedRange().slice(0));
  leafRb.unmap(); primRb.unmap();
  let maskDiff = 0, gpuVisible = 0, leafFalseNegatives = 0;
  for (let i = 0; i < N; i++) {
    if (primMask[i] !== expected[i]) maskDiff++;
    if (primMask[i]! === 1) gpuVisible++;
    if (primMask[i]! === 1 && leafMask[primLeaf[i]!]! === 0) leafFalseNegatives++;
  }
  device.destroy();
  const data: BvhGpuResult = {
    prims: N, leaves: leafNodes.length,
    cpuVisible: expected.reduce((s, v) => s + v, 0), gpuVisible,
    maskDiff, leafFalseNegatives, wgslErrors,
  };
  const checks = [
    { name: 'wgsl.0error', ok: wgslErrors === 0, detail: `errors=${wgslErrors}` },
    { name: 'gpu.mask==cpu', ok: maskDiff === 0, detail: `diff=${maskDiff}` },
    { name: 'gpu.visible==cpu', ok: gpuVisible === data.cpuVisible, detail: `${gpuVisible} vs ${data.cpuVisible}` },
    { name: 'leaf.no.false.negative', ok: leafFalseNegatives === 0, detail: `bad=${leafFalseNegatives}` },
  ];
  return { checks, data };
}

node tools/run-l2.mjs src/render/culling/bvh-gpu-cull.ts(本机实测):

PASS  wgsl.0error  errors=0
PASS  gpu.mask==cpu  diff=0
PASS  gpu.visible==cpu  10 vs 10
PASS  leaf.no.false.negative  bad=0

65536 个图元、叶容量 8,建出 8192 个叶节点。GPU 的两级剔除结果与 CPU 的 bvhCull 逐位一致(diff=0),可见数都是 10,且没有任何图元出现「自身可见却所属叶不可见」的矛盾(bad=0)。

小结

BVH 把逐盒的线性扫描换成「先扔大块」的层次扫描:本机 10 万盒子里只做了 257 次判定。这一节建的树、算的叶掩码,下一节直接拿来当 GPU 驱动的输入——连 CPU 侧的剔除调用都省掉,让 GPU 自己决定画谁、画哪一档 LOD。

阅读导航:上一节:8.1 视锥剔除与包围体 · 下一节:8.3 GPU 驱动剔除与 LOD 协同 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径