WebGL 视锥体裁剪与包围盒计算优化
WebGL 视锥体裁剪与包围盒计算优化在三维数字孪生、智慧园区或超大规模拓扑可视化场景中当场景中的网格Mesh数量达到上万甚至数十万级别时渲染帧率往往会断崖式下跌。很多开发者的第一反应是降低模型精度或做 LOD多层次细节但如果 80% 的模型根本不在当前摄像机的视野范围内把它们全部送入 GPU 渲染管线本身就是一种巨大的算力浪费。视锥体裁剪Frustum Culling就是为了解决这一问题在 CPU 端提前计算出哪些几何体处于视锥体内并在发起 Draw Call 之前将不可见的物体精准剔除。视锥体与六平面的数学提取透视投影相机的可视区域是一个被近裁剪面Near Plane和远裁剪面Far Plane截断的四棱锥台被称为视锥体Frustum。它由 6 个平面构成近平面Near、远平面Far左平面Left、右平面Right上平面Top、下平面Bottom在三维空间中任何一个平面都可以用一般方程表示为$$Ax By Cz D 0$$其中 $(A, B, C)$ 为平面的法向量 $\mathbf{n}$$D$ 为坐标原点到平面的有符号距离。若法向量归一化即 $A^2 B^2 C^2 1$则任意点 $P(x, y, z)$ 到平面的有符号距离为$$\text{dist} A x B y C z D$$若 $\text{dist} 0$点在平面的正面若 $\text{dist} 0$点在平面的背面。我们可以直接从视图投影矩阵View-Projection Matrix, $M P \times V$的行向量中快速提取出这 6 个平面的方程系数export interface Plane { normal: [number, number, number]; constant: number; } export class Frustum { public planes: Plane[] new Array(6).fill(null).map(() ({ normal: [0, 0, 0], constant: 0, })); // 从 4x4 视图投影矩阵提取 6 个平面 public setFromProjectionMatrix(m: Float32Array): this { const me m; const me0 me[0], me1 me[1], me2 me[2], me3 me[3]; const me4 me[4], me5 me[5], me6 me[6], me7 me[7]; const me8 me[8], me9 me[9], me10 me[10], me11 me[11]; const me12 me[12], me13 me[13], me14 me[14], me15 me[15]; // 提取并归一化平面函数 const setPlane (index: number, a: number, b: number, c: number, d: number) { const invLen 1.0 / Math.sqrt(a * a b * b c * c); this.planes[index].normal[0] a * invLen; this.planes[index].normal[1] b * invLen; this.planes[index].normal[2] c * invLen; this.planes[index].constant d * invLen; }; // 左平面 setPlane(0, me3 me0, me7 me4, me11 me8, me15 me12); // 右平面 setPlane(1, me3 - me0, me7 - me4, me11 - me8, me15 - me12); // 下平面 setPlane(2, me3 me1, me7 me5, me11 me9, me15 me13); // 上平面 setPlane(3, me3 - me1, me7 - me5, me11 - me9, me15 - me13); // 近平面 setPlane(4, me3 me2, me7 me6, me11 me10, me15 me14); // 远平面 setPlane(5, me3 - me2, me7 - me6, me11 - me10, me15 - me14); return this; } }包围盒AABB相交测试优化对每个复杂的 3D 几何体逐个顶点去测试是否在视锥体内是不现实的。通常我们会为每个物体计算一个轴对齐包围盒AABB, Axis-Aligned Bounding Box。AABB 由世界坐标系下的最小值点 $\mathbf{min}(x_1, y_1, z_1)$ 和最大值点 $\mathbf{max}(x_2, y_2, z_2)$ 决定。判断 AABB 是否在视锥体外部的高效算法是对于视锥体的每一个平面找到 AABB 在该平面法向量方向上“最靠正方向的顶点P-Vertex”和“最靠负方向的顶点N-Vertex”。如果 AABB 的 P-Vertex 都在平面的背面有符号距离 $ 0$那么整个 AABB 必定完全位于视锥体外部可以直接剔除export interface AABB { min: [number, number, number]; max: [number, number, number]; } export function intersectsAABB(frustum: Frustum, aabb: AABB): boolean { const planes frustum.planes; for (let i 0; i 6; i) { const plane planes[i]; const nx plane.normal[0]; const ny plane.normal[1]; const nz plane.normal[2]; // 计算 P-Vertex沿法线最正方向的盒体顶点 const px nx 0 ? aabb.max[0] : aabb.min[0]; const py ny 0 ? aabb.max[1] : aabb.min[1]; const pz nz 0 ? aabb.max[2] : aabb.min[2]; // 计算点到平面的有符号距离 const distance nx * px ny * py nz * pz plane.constant; // 若最大正方向点都落在平面背面则整个包围盒都在视野外 if (distance 0) { return false; } } return true; }八叉树Octree空间加速将复杂度从 $O(N)$ 降至 $O(\log N)$如果场景中有 50,000 个物体即便单次 AABB 相交测试极快每帧遍历 50,000 次仍然会消耗几毫秒 CPU 时间。通过构建**八叉树Octree**将空间进行层次化划分若一个父节点的包围盒完全位于视锥体外则其下的所有子节点与几何体全部直接剔除跳过递归。若父节点完全包含在视锥体内则其下的所有子节点无需再做相交测试全部标记为可见。仅当父节点与视锥体相交时才递归细分测试子节点。export class OctreeNode { public bounds: AABB; public children: OctreeNode[] | null null; public objects: Array{ id: string; aabb: AABB } []; constructor(bounds: AABB) { this.bounds bounds; } public cull(frustum: Frustum, visibleObjectIds: string[]) { // 1. 测试当前节点的包围盒 if (!intersectsAABB(frustum, this.bounds)) { return; // 完全不可见剪枝整个子树 } // 2. 收集当前节点挂载的对象 for (let i 0; i this.objects.length; i) { const obj this.objects[i]; if (intersectsAABB(frustum, obj.aabb)) { visibleObjectIds.push(obj.id); } } // 3. 递归遍历子节点 if (this.children) { for (let i 0; i 8; i) { this.children[i].cull(frustum, visibleObjectIds); } } } }空间换时间与工程实战考量避免每帧在 JavaScript 中分配对象平面向量计算中绝不可频繁new Vector3()或返回临时数组。所有矩阵与向量运算必须使用类型化数组Float32Array并在闭包中预分配复用否则 V8 引擎的高频垃圾回收GC会导致严重的微掉帧。包围球Bounding Sphere快速预检在做 AABB 矩形测试前可以先用单个点和标量半径做包围球快速测试只需一次点乘如果包围球在视野外直接略过进一步压缩数学运算周期。结合 GPU 遮挡查询Occlusion Query对于视锥体内被前景巨型建筑完全遮挡的内部管线CPU 视锥体裁剪无法识别可结合WEBGL_occlusion_query_ext拓展实现遮挡剔除实现全链路的视觉极简渲染。