《游戏引擎实现原理》8.1 视锥剔除与包围体

从相机的视图投影矩阵抽出六个裁剪面,用 p-vertex 判定 AABB 与包围球的可见性;先跑通 CPU 侧剔除统计,再在 headless Chromium 里只把可见盒子按实例绘制,回读帧缓冲逐色核对被剔除的盒子一个像素都没画出来,全程附可复现代码。

8.1 视锥剔除与包围体

场景图告诉渲染器「有什么」,相机告诉它「从哪看」。但把上百个物体原封不动丢给 GPU,绝大部分根本不在画面里。这一节做第一道过滤:从相机的视图投影矩阵抽出六个平面,用一个包围体快速判断「这个物体到底该不该进绘制队列」——本节产出 src/render/culling/frustum.ts(L1)与 src/render/culling/frustum-cull-render.ts(L2,配 shaders/cull-boxes.wgsl),所有数值均来自本机实跑。

本章与站内既有内容的分工

视锥剔除的原理、Hi-Z、GPU-driven 全景,站内 /graphics-culling-visibility-systems/ 已系统讲过。本节不重推算法,只做「在 helio 上从零实现 + 实测」。

既有文章它负责什么本节只做什么
/graphics-culling-visibility-systems/视锥 / BVH / Hi-Z / GPU-driven 的原理与多引擎对照在 helio 上写出可跑的剔除模块并核对真实像素

从 viewProj 抽出六个平面

六个裁剪面不用手推。Gribb–Hartmann 方法直接把 viewProj 的四行两两相加/相减得到它们;WebGPU 的裁剪空间 z ∈ [0,1](不是 OpenGL 的 [-1,1]),所以近平面就是 z 行本身。判定用 p-vertex / n-vertex:沿平面法线取包围盒上最远的那个角点,它若落在平面外侧,整个盒子必然在外;所有最近角点都在内侧,盒子就完整可见;介于两者之间则是相交。

L1 模块 src/render/culling/frustum.ts(末尾附一个自打印 demo,node src/render/culling/frustum.ts 可直接运行):

// file: src/render/culling/frustum.ts
// tier: L1
// verify: npx vitest run src/render/culling/frustum.test.ts
//
// helio 渲染卷 8.1:视锥剔除与包围体。从视图投影矩阵抽出 6 个归一化平面,
// 对 AABB / 包围球做保守可见性判定。列主序矩阵,裁剪空间 z ∈ [0,1](WebGPU 约定)。

export type Vec3 = readonly [number, number, number];
/** 平面 ax+by+cz+d=0:(a,b,c) 为单位法线指向视锥内侧。 */
export type Plane = readonly [number, number, number, number];
export type Aabb = { readonly min: Vec3; readonly max: Vec3 };
export type Sphere = { readonly center: Vec3; readonly radius: number };
export type Frustum = readonly [Plane, Plane, Plane, Plane, Plane, Plane];

const rowOf = (m: readonly number[], r: number): number[] => [m[r]!, m[4 + r]!, m[8 + r]!, m[12 + r]!];

/** 右手系透视投影,裁剪空间 z ∈ [0,1](WebGPU / D3D 约定)。 */
export function perspective(fovy: number, aspect: number, near: number, far: number): number[] {
  const f = 1 / Math.tan(fovy / 2);
  const m = new Array<number>(16).fill(0);
  m[0] = f / aspect; m[5] = f;
  m[10] = far / (near - far); m[11] = -1; m[14] = (far * near) / (near - far);
  return m;
}

/** 右手系 lookAt:eye 看向 target,视线朝 -z,up 决定「上」。 */
export function lookAt(eye: Vec3, target: Vec3, up: Vec3): number[] {
  let zx = eye[0] - target[0], zy = eye[1] - target[1], zz = eye[2] - target[2];
  let l = Math.hypot(zx, zy, zz) || 1; zx /= l; zy /= l; zz /= l;
  let xx = up[1] * zz - up[2] * zy, xy = up[2] * zx - up[0] * zz, xz = up[0] * zy - up[1] * zx;
  l = Math.hypot(xx, xy, xz) || 1; xx /= l; xy /= l; xz /= l;
  const yx = zy * xz - zz * xy, yy = zz * xx - zx * xz, yz = zx * xy - zy * xx;
  return [
    xx, yx, zx, 0, xy, yy, zy, 0, xz, yz, zz, 0,
    -(xx * eye[0] + xy * eye[1] + xz * eye[2]),
    -(yx * eye[0] + yy * eye[1] + yz * eye[2]),
    -(zx * eye[0] + zy * eye[1] + zz * eye[2]), 1,
  ];
}

/** out = a * b(列主序)。 */
export function multiply(a: readonly number[], b: readonly number[]): number[] {
  const o = new Array<number>(16);
  for (let c = 0; c < 4; c++) {
    const b0 = b[c * 4]!, b1 = b[c * 4 + 1]!, b2 = b[c * 4 + 2]!, b3 = b[c * 4 + 3]!;
    for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r]! * b0 + a[4 + r]! * b1 + a[8 + r]! * b2 + a[12 + r]! * b3;
  }
  return o;
}
const normPlane = (p: readonly number[]): Plane => {
  const len = Math.hypot(p[0]!, p[1]!, p[2]!) || 1;
  return [p[0]! / len, p[1]! / len, p[2]! / len, p[3]! / len];
};

/**
 * Gribb–Hartmann:从列主序 viewProj 抽出 6 个平面。WebGPU 裁剪空间 z ∈ [0,1],
 * 故 left = w+x、right = w-x、bottom = w+y、top = w-y、near = z、far = w-z。
 */
export function extractFrustumPlanes(vp: readonly number[]): Frustum {
  const x = rowOf(vp, 0), y = rowOf(vp, 1), z = rowOf(vp, 2), w = rowOf(vp, 3);
  const add = (a: number[], b: number[]) => [a[0]! + b[0]!, a[1]! + b[1]!, a[2]! + b[2]!, a[3]! + b[3]!];
  const sub = (a: number[], b: number[]) => [a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!, a[3]! - b[3]!];
  return [normPlane(add(w, x)), normPlane(sub(w, x)), normPlane(add(w, y)),
    normPlane(sub(w, y)), normPlane(z), normPlane(sub(w, z))];
}

/** 点到平面的有符号距离(视锥内侧为正)。 */
export function planeDistance(p: Plane, v: Vec3): number {
  return p[0] * v[0] + p[1] * v[1] + p[2] * v[2] + p[3];
}

export type Visibility = 'inside' | 'intersect' | 'outside';

/**
 * AABB 与视锥:p-vertex / n-vertex 判定。沿平面法线最远的点(p-vertex)在外 → 完全在外;
 * 所有最近点(n-vertex)都在内 → 完全在内;否则相交。
 */
export function aabbInFrustum(aabb: Aabb, f: Frustum): Visibility {
  let allInside = true;
  for (const p of f) {
    const px = p[0] >= 0 ? aabb.max[0] : aabb.min[0];
    const py = p[1] >= 0 ? aabb.max[1] : aabb.min[1];
    const pz = p[2] >= 0 ? aabb.max[2] : aabb.min[2];
    if (p[0] * px + p[1] * py + p[2] * pz + p[3] < 0) return 'outside';
    const nx = p[0] >= 0 ? aabb.min[0] : aabb.max[0];
    const ny = p[1] >= 0 ? aabb.min[1] : aabb.max[1];
    const nz = p[2] >= 0 ? aabb.min[2] : aabb.max[2];
    if (p[0] * nx + p[1] * ny + p[2] * nz + p[3] < 0) allInside = false;
  }
  return allInside ? 'inside' : 'intersect';
}

/** 包围球与视锥:球心到平面距离 < -r 才算完全在外(保守)。 */
export function sphereInFrustum(s: Sphere, f: Frustum): boolean {
  for (const p of f) if (planeDistance(p, s.center) < -s.radius) return false;
  return true;
}

/** AABB 的最小外接球(球心取盒中心,半径取到角点)。 */
export function boundingSphere(aabb: Aabb): Sphere {
  const c: Vec3 = [(aabb.min[0] + aabb.max[0]) / 2, (aabb.min[1] + aabb.max[1]) / 2, (aabb.min[2] + aabb.max[2]) / 2];
  return { center: c, radius: Math.hypot(aabb.max[0] - c[0], aabb.max[1] - c[1], aabb.max[2] - c[2]) };
}

export type CullStats = { readonly total: number; readonly visible: number; readonly inside: number; readonly intersect: number };

/** 对一组 AABB 做视锥剔除,返回统计。 */
export function cullAabbs(boxes: readonly Aabb[], f: Frustum): CullStats {
  let inside = 0, intersect = 0;
  for (const b of boxes) {
    const v = aabbInFrustum(b, f);
    if (v === 'inside') inside++; else if (v === 'intersect') intersect++;
  }
  return { total: boxes.length, visible: inside + intersect, inside, intersect };
}

/** 确定性场景:把单位盒子铺成 rows × cols 的网格。 */
export function gridScene(rows: number, cols: number, spacing: number, size: number): Aabb[] {
  const boxes: Aabb[] = [], off = (rows - 1) / 2, offC = (cols - 1) / 2, h = size / 2;
  for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
    const x = (c - offC) * spacing, z = (r - off) * spacing;
    boxes.push({ min: [x - h, -h, z - h], max: [x + h, h, z + h] });
  }
  return boxes;
}
// ---- 直接运行时打印剔除统计(Node 26 import.meta.main)----
if ((import.meta as { main?: boolean }).main) {
  const vp = multiply(perspective((60 * Math.PI) / 180, 1, 0.1, 10), lookAt([0, 0, 5], [0, 0, 0], [0, 1, 0]));
  const f = extractFrustumPlanes(vp);
  const boxes = gridScene(7, 7, 2, 1.2);
  const s = cullAabbs(boxes, f);
  console.log(`planes  ${f.length}  normalized`);
  console.log(`scene   ${s.total} boxes  visible ${s.visible} (inside ${s.inside} / intersect ${s.intersect})`);
  const vis = boxes.filter((b) => sphereInFrustum(boundingSphere(b), f)).length;
  console.log(`sphere  visible ${vis}(保守:>= AABB 判定 ${s.visible})`);
}

可运行产物:命令行剔除统计

node src/render/culling/frustum.ts(本机实测,直接运行上面的模块):

planes  6  normalized
scene   49 boxes  visible 19 (inside 10 / intersect 9)
sphere  visible 28(保守:>= AABB 判定 19)

7x7 网格共 49 个盒子,相机在 z=5 朝 -z 看:19 个可见,其中 10 个完整落在视锥内、9 个与视锥边界相交,其余 30 个被剔除。同一批盒子改用包围球判定得到 28 个——球比盒子大,判定更保守,可见集合只会更大,这正是「包围体越紧剔除越多、越松越安全」的直观体现。

可运行产物:只画可见盒子

L1 只证明 CPU 算得对。真正的验收是:把可见盒子按实例画出来,回读帧缓冲逐色核对——可见盒子的颜色必须出现,被剔除盒子的颜色一个像素都不能有。着色器 shaders/cull-boxes.wgsl 给每个实例一个唯一颜色:

// file: shaders/cull-boxes.wgsl
// tier: L2
// verify: node tools/run-l2.mjs src/render/culling/frustum-cull-render.ts
//
// 8.1 的 L2 验收:只把 CPU 视锥剔除后「存活」的盒子按实例画出来。
// 每个实例一个唯一颜色,便于在回读的帧缓冲里按颜色核对「谁被画了、谁没被画」。
// 盒子位于 z=0 平面、正面朝相机,故无需深度测试,实例之间在屏幕上也不重叠。

struct Frame { vp: mat4x4<f32> };
@group(0) @binding(0) var<uniform> frame: Frame;

struct VSOut {
  @builtin(position) pos: vec4<f32>,
  @location(0) color: vec3<f32>,
};

@vertex
fn vs(
  @location(0) corner: vec2<f32>,   // 单位四边形顶点 [-0.5, 0.5]^2
  @location(1) center: vec3<f32>,   // 每个实例的世界位置
  @location(2) half: f32,           // 半边长
  @location(3) color: vec3<f32>,    // 每实例唯一颜色
) -> VSOut {
  var o: VSOut;
  let p = center + vec3<f32>(corner * (half * 2.0), 0.0);
  o.pos = frame.vp * vec4<f32>(p, 1.0);
  o.color = color;
  return o;
}

@fragment
fn fs(in: VSOut) -> @location(0) vec4<f32> {
  return vec4<f32>(in.color, 1.0);
}

src/render/culling/frustum-cull-render.ts(L2)用与 L1 同一条 viewProj 做剔除再绘制:

// file: src/render/culling/frustum-cull-render.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/culling/frustum-cull-render.ts
//
// 8.1 的 L2 验收:CPU 视锥剔除 + 真实渲染。场景是 z=0 平面上 7x7 的盒子网格;
// 用 frustum.ts 逐盒做 AABB-视锥判定,只把可见盒子按实例绘制;回读帧缓冲,
// 按「每实例唯一颜色」核对:可见盒子的颜色必须出现,被剔除盒子一个像素都不出现。
// 需 headless Chromium + 本地 http 服务(navigator.gpu 只在安全上下文可用),命令行不可复现。

import { aabbInFrustum, extractFrustumPlanes, lookAt, multiply, perspective, type Aabb, type Vec3 } from './frustum.ts';

const W = 256, H = 256, CLEAR: readonly [number, number, number] = [0.05, 0.05, 0.1];

export interface CullRenderResult {
  readonly total: number; readonly visible: number; readonly culled: number;
  readonly drawnInstances: number; readonly presentVisible: number;
  readonly leakedCulled: number; readonly nonBackground: number; readonly wgslErrors: number;
}
type Check = { readonly name: string; readonly ok: boolean; readonly detail: string };

/** 盒子 i 的唯一颜色(r 通道每实例差 5,容差 1 下不会撞色)。 */
function colorOf(i: number): [number, number, number] {
  return [(i * 5 + 5) / 255, 200 / 255, 100 / 255];
}

export async function runL2(): Promise<{ checks: Check[]; data: CullRenderResult }> {
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('拿不到 WebGPU adapter');
  const device = await adapter.requestDevice();

  const code = await (await fetch('/shaders/cull-boxes.wgsl')).text();
  const module = device.createShaderModule({ code });
  const info = await module.getCompilationInfo();
  const wgslErrors = info.messages.filter((m) => m.type === 'error').length;

  // 场景:z=0 平面上 7x7 盒子网格(确定性摆放)。
  const grid = [-6, -4, -2, 0, 2, 4, 6];
  const boxes: { aabb: Aabb; center: Vec3 }[] = [];
  for (const y of grid) for (const x of grid)
    boxes.push({ aabb: { min: [x - 0.5, y - 0.5, -0.5], max: [x + 0.5, y + 0.5, 0.5] }, center: [x, y, 0] });
  const vp = multiply(perspective((60 * Math.PI) / 180, 1, 0.1, 10), lookAt([0, 0, 5], [0, 0, 0], [0, 1, 0]));
  const frustum = extractFrustumPlanes(vp);
  const visible: number[] = [], culled: number[] = [];
  boxes.forEach((b, i) => (aabbInFrustum(b.aabb, frustum) === 'outside' ? culled : visible).push(i));

  // 只为可见盒子构造实例:center(3) + half(1) + color(3) = 7 floats。
  const inst = new Float32Array(visible.length * 7);
  visible.forEach((bi, k) => {
    const c = colorOf(bi);
    inst.set([...boxes[bi]!.center, 0.5, c[0], c[1], c[2]], k * 7);
  });
  const quad = device.createBuffer({ size: 8 * 4, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(quad, 0, new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5]));
  const vbuf = device.createBuffer({ size: Math.max(28, inst.byteLength), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(vbuf, 0, inst);
  const ubuf = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(ubuf, 0, new Float32Array(vp));

  const pipeline = device.createRenderPipeline({
    layout: 'auto',
    vertex: { module, entryPoint: 'vs', buffers: [
      { arrayStride: 8, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x2' }] },
      { arrayStride: 28, stepMode: 'instance', attributes: [
        { shaderLocation: 1, offset: 0, format: 'float32x3' },
        { shaderLocation: 2, offset: 12, format: 'float32' },
        { shaderLocation: 3, offset: 16, format: 'float32x3' },
      ] },
    ] },
    fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
    primitive: { topology: 'triangle-list', cullMode: 'none' },
  });
  const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: ubuf } }] });
  const tex = device.createTexture({ size: [W, H], format: 'rgba8unorm', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
  const enc = device.createCommandEncoder();
  const pass = enc.beginRenderPass({ colorAttachments: [{ view: tex.createView(), clearValue: { r: CLEAR[0], g: CLEAR[1], b: CLEAR[2], a: 1 }, loadOp: 'clear', storeOp: 'store' }] });
  if (visible.length > 0) {
    pass.setPipeline(pipeline); pass.setBindGroup(0, bind);
    pass.setVertexBuffer(0, quad); pass.setVertexBuffer(1, vbuf);
    pass.draw(4, visible.length);
  }
  pass.end();
  const bpr = W * 4;
  const rb = device.createBuffer({ size: bpr * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  enc.copyTextureToBuffer({ texture: tex }, { buffer: rb, bytesPerRow: bpr, rowsPerImage: H }, [W, H]);
  device.queue.submit([enc.finish()]);
  await rb.mapAsync(GPUMapMode.READ);
  const px = new Uint8Array(rb.getMappedRange().slice(0)); rb.unmap();
  const clearB = CLEAR.map((v) => Math.round(v * 255));
  let nonBackground = 0;
  for (let i = 0; i < px.length; i += 4)
    if (Math.abs(px[i]! - clearB[0]!) > 1 || Math.abs(px[i + 1]! - clearB[1]!) > 1 || Math.abs(px[i + 2]! - clearB[2]!) > 1) nonBackground++;
  const present = (bi: number): number => {
    const [r, g, b] = colorOf(bi).map((v) => Math.round(v * 255));
    let n = 0;
    for (let i = 0; i < px.length; i += 4)
      if (Math.abs(px[i]! - r!) <= 1 && Math.abs(px[i + 1]! - g!) <= 1 && Math.abs(px[i + 2]! - b!) <= 1) n++;
    return n;
  };
  const presentVisible = visible.filter((bi) => present(bi) > 0).length;
  const leakedCulled = culled.filter((bi) => present(bi) > 0).length;
  device.destroy();

  const data: CullRenderResult = {
    total: boxes.length, visible: visible.length, culled: culled.length,
    drawnInstances: visible.length, presentVisible, leakedCulled, nonBackground, wgslErrors,
  };
  const checks: Check[] = [
    { name: 'wgsl.0error', ok: wgslErrors === 0, detail: `errors=${wgslErrors}` },
    { name: 'cull.visible==9', ok: visible.length === 9, detail: `visible=${visible.length} culled=${culled.length}` },
    { name: 'all.visible.drawn', ok: presentVisible === visible.length, detail: `${presentVisible}/${visible.length}` },
    { name: 'no.culled.leaked', ok: leakedCulled === 0, detail: `leaked=${leakedCulled}` },
    { name: 'drew.pixels', ok: nonBackground > 0, detail: `nonBackground=${nonBackground}` },
  ];
  return { checks, data };
}

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

PASS  wgsl.0error  errors=0
PASS  cull.visible==9  visible=9 culled=40
PASS  all.visible.drawn  9/9
PASS  no.culled.leaked  leaked=0
PASS  drew.pixels  nonBackground=8778

z=0 平面上的 7x7 网格共 49 个盒子,相机在 (0,0,5) 朝 -z、fov 60°:9 个可见、40 个被剔除,与 CPU 判定一致;9 个可见盒子的颜色全部出现在帧里(9/9),40 个被剔除盒子的颜色一个都没出现(leaked=0),非背景像素 8778 个证明确实画出了东西。只要剔除结果与绘制集合有一处不一致,这两条断言就会当场失败。

小结

视锥剔除把「有什么」先砍成「看得见什么」:六个平面从 viewProj 免费抽出,p-vertex 判定用一个包围体换来 O(1) 的可见性检查,包围球比 AABB 更保守也更便宜。CPU 侧算对了,下一节就解决「几百个盒子还好,十万个怎么办」——用 BVH 把逐盒判定变成层次判定。

阅读导航:上一节:回到目录 · 下一节:8.2 空间划分与 BVH 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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