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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。