首页
/ three.js Frustum 视锥体剔除全面解析:源码原理与实战应用指南

three.js Frustum 视锥体剔除全面解析:源码原理与实战应用指南

2026-09-06 19:11:40作者:史锋燃Gardner

平截头体(Frustum,视锥体)是三维渲染管线中实现"视锥剔除"(frustum culling)的核心数学工具——它用 6 个平面描述相机可视区域的体积,凡是完全位于这 6 个平面之外的物体都可以安全跳过渲染,从而显著降低绘制开销。本指南以 three.js 官方 API 文档 docs/pages/Frustum.html.md 为骨架,逐项讲解 Frustum 类的构造、属性与方法,并结合本仓库 src/math/Frustum.js 的实现源码、WebGLRenderer 的调用链与单元测试 test/unit/src/math/Frustum.tests.js,带你理解"从投影矩阵提取 6 个平面"的底层数学、各类相交测试的算法取舍,以及如何在自己的场景中正确使用视锥体做自定义剔除。

一、Frustum 是什么:渲染性能的第一道闸门

在任意一帧的渲染过程中,相机前方可见的空间是一个四棱锥被近、远两个裁剪面截断后形成的六面体——即"平截头体"。three.js 文档对它的定位非常明确:

  • Frustum 用于判断"什么在相机的视野(field of view)之内",从而加速渲染:位于视锥之外的物体可以被安全地排除在渲染之外;
  • 该类主要面向渲染器内部使用This class is mainly intended for use internally by a renderer)。

从源码注释(src/math/Frustum.js)可以进一步确认这一设计意图。实际渲染时,WebGL/WebGPU 渲染器每帧都会对场景中的每个 Mesh、Line、Points、Sprite 做一次与当前视锥的相交测试,测试不通过的物体不会进入绘制列表——这就是被广泛使用的视锥剔除(Frustum Culling)。

对象侧的开关属性是 Object3D.frustumCulled(见 src/core/Object3D.js,默认值为 true)。在 src/renderers/WebGLRenderer.jssrc/renderers/WebGLRenderer.js 中可以看到统一的两段调用逻辑:

if ( ! object.frustumCulled || object.intersectsFrustum( _frustum ) ) {
    // ...将 object 加入当前渲染列表
}

即:frustumCulled = true 时执行剔除判断,只有与视锥相交的对象才入列;若把某个对象(例如始终面向相机的背景天空盒、始终需要渲染的包围指示器)的 frustumCulled 设为 false,则无条件入列。场景背景网格正是这样处理的(src/renderers/common/Background.jsbackgroundMesh.frustumCulled = false)。

判断"可见性"本质是近似但保守的:视锥只做粗粒度筛选,即使物体通过了剔除测试,仍可能因被其他物体遮挡而不产生任何可见像素。因此该机制只是性能优化的第一道闸门,后续还需经历深度测试、遮挡剔除等流程。

二、构造函数与 .planes 属性

2.1 new Frustum(p0, p1, p2, p3, p4, p5)

构造一个新的视锥体,六个参数分别对应包围视锥的六个平面:

参数 类型 说明
p0 Plane 包围视锥的第一个平面
p1 Plane 包围视锥的第二个平面
p2 Plane 包围视锥的第三个平面
p3 Plane 包围视锥的第四个平面
p4 Plane 包围视锥的第五个平面
p5 Plane 包围视锥的第六个平面

源码实现中这六个参数都是可选的:缺省时自动补 new Plane()(单位法向量 + 常量为 0 的"退化平面"),并直接按序存入数组(src/math/Frustum.js):

constructor( p0 = new Plane(), p1 = new Plane(), ..., p5 = new Plane() ) {
    this.planes = [ p0, p1, p2, p3, p4, p5 ];
}

因此 new Frustum() 也能得到一个包含 6 个默认平面的合法实例。单元测试 test/unit/src/math/Frustum.tests.js 验证了默认构造下 6 个平面均等于默认 Plane,也验证了传参构造时每个元素与传入平面一一相等。

2.2 .planes : Array<Plane>

planes 是保存包围视锥各平面的数组(下标 0~5)。结合 setFromProjectionMatrix 的源码注释,在"由投影矩阵提取"的默认流程中,六个平面的典型语义为:

  • planes[0]:左侧平面(left);
  • planes[1]:右侧平面(right);
  • planes[2]:顶面(top);
  • planes[3]:底面(bottom);
  • planes[4]:远平面(far);
  • planes[5]:近平面(near)。

每个元素都是标准的 Plane 对象,通过 normal(外法线单位向量)与 constant(常量项)表示平面方程 n·x + constant = 0,并可通过 distanceToPoint(point) 计算点到平面的带符号距离(正为法线同侧,负为另一侧)——这是下文所有相交测试的基础原语。

三、核心方法逐一拆解

3.1 .set(p0, p1, p2, p3, p4, p5) : Frustum

用给定的六个平面覆写当前视锥。与构造函数不同的是,它不创建新数组而是复用已存在的 6 个 Plane 对象,逐个调用 copysrc/math/Frustum.js),随后返回 this 以便链式调用。典型的应用场景是先构造一个复用实例,之后每帧通过 setsetFromProjectionMatrix 刷新数据,从而避免每帧分配新对象引发的 GC 压力。

3.2 .setFromProjectionMatrix(m, coordinateSystem, reversedDepth) : Frustum

这是将投影矩阵(或投影·视图复合矩阵)转化为 6 个平面的关键方法,也是每帧剔除流程的入口。参数如下:

参数 类型 默认值 说明
m Matrix4 投影矩阵。结合视图矩阵使用时通常传入投影矩阵与视图逆矩阵的乘积(投影·视图矩阵)
coordinateSystem WebGLCoordinateSystem | WebGPUCoordinateSystem WebGLCoordinateSystem 坐标系类型,来自 src/constants.js
reversedDepth boolean false 是否使用反转深度(reversed depth)

从源码看,方法先取出矩阵 16 个元素(列主序 me0~me15),再依据"Gribb-Hartmann 平面提取法"用矩阵行向量做加减组合并 normalize(),得到左右顶底四个平面(src/math/Frustum.js):

planes[ 0 ].setComponents( me3 - me0, me7 - me4, me11 - me8, me15 - me12 ).normalize(); // left
planes[ 1 ].setComponents( me3 + me0, me7 + me4, me11 + me8, me15 + me12 ).normalize(); // right
planes[ 2 ].setComponents( me3 + me1, me7 + me5, me11 + me9, me15 + me13 ).normalize(); // top
planes[ 3 ].setComponents( me3 - me1, me7 - me5, me11 - me9, me15 - me13 ).normalize(); // bottom

随后是近远平面,且近平面受坐标系与深度范围影响最大,源码中分支处理得很细(src/math/Frustum.js):

  1. reversedDepth = true(反转深度):现代渲染器常用反向 Z(near 映射到 1、far 映射到 0)以改善深度缓冲精度。此时远平面取 (me2, me6, me10, me14),近平面取 me3 - me2, ...——恰好与默认情形互换;
  2. reversedDepth = false + WebGL:far 用 me3 - me2, ...,near 用 me3 + me2, ...
  3. reversedDepth = false + WebGPU:远平面同上,但近平面采用 (me2, me6, me10, me14)。这是因为 WebGPU 的 NDC 深度范围是 [0,1] 而 WebGL 是 [-1,1],投影矩阵结构不同,必须区分对待;
  4. coordinateSystem 传入非法值,直接抛出 'THREE.Frustum.setFromProjectionMatrix(): Invalid coordinate system: ...'

方法结束时返回 this。文档特别提示 reversedDepth 默认为 false

在渲染器中的实际调用:WebGL 渲染路径先在 src/renderers/WebGLRenderer.js 计算 _projScreenMatrixprojectionMatrix × matrixWorldInverse),再以 WebGLCoordinateSystem 与相机当前 reversedDepth 提取视锥;WebGPU/通用渲染路径则位于 src/renderers/common/Renderer.js。点光源阴影的 PCF/PCSS 等算法也会用同样的方式为每张阴影深度图构建视锥并做剔除(例如 src/nodes/lighting/PointShadowNode.jssrc/renderers/webgl/WebGLShadowMap.js)。

3.3 .clone() : Frustum 与 .copy(frustum) : Frustum

  • .copy(frustum):把另一个视锥的 6 个平面逐个 copy 到自身并返回 thissrc/math/Frustum.js)。它复制的是 Plane 的而非引用;
  • .clone()return new this.constructor().copy( this ),得到完全独立的新实例(src/math/Frustum.js)。

单元测试对二者都做了"真拷贝"验证:修改副本中的平面或替换源对象数组元素,均不影响原视锥(test/unit/src/math/Frustum.tests.js)。

3.4 .containsPoint(point) : boolean

判断点是否位于视锥内部。实现是遍历 6 个平面,若任一点到平面的带符号距离小于 0(在平面外侧)即返回 false,全部通过才返回 truesrc/math/Frustum.js)。由于全部测试都基于"半空间"判断,这是所有相交测试里最基础、最精确的情形。

3.5 .intersectsSphere(sphere) : boolean 与保守剔除原理

判断给定包围球是否与视锥相交。算法为:对每个平面计算球心到平面的距离,只要存在某个平面使 distance < -radius,说明球完全在该平面的外侧,可直接判负;否则视为相交(src/math/Frustum.js)。

源码注释(src/math/Frustum.js)明确指出了该测试的性质,理解它有助于正确使用:

这是一个快速、保守的测试,偏向性能而非精度。对于"位于视锥外但未被单一平面分离"的球,它可能产生误报(false positive);但它从不产生漏报(false negative),因此对剔除而言是安全的。

换言之:intersectsSphere 可能把极少量的不可见物体"多画进去",但绝不会把可见物体剔除掉,作为渲染前的粗筛完全可靠。

3.6 .intersectsObject(object) : boolean

判断某 3D 对象的包围球是否与视锥相交,是逐对象剔除的直接入口。文档强调:对象必须拥有 geometry,包围球才能被计算。源码揭示了两种来源(src/math/Frustum.js):

  1. 若对象自身带 boundingSphere 属性(如 BatchedMesh 等会维护整体包围体),则优先使用并在为 null 时通过 object.computeBoundingSphere() 计算;
  2. 否则读取 object.geometry.boundingSphere,为 null 时调用 geometry.computeBoundingSphere() 计算,随后 copyapplyMatrix4(object.matrixWorld) 变换到世界空间;
  3. 最终统一交给 intersectsSphere 判定。

正因为要做一次 matrixWorld 变换,调用前通常需要确保对象的 matrixWorld 已更新(updateMatrixWorld()),测试用例正是这样做的(test/unit/src/math/Frustum.tests.js)。

MeshLinePointsSprite 各自实现的 intersectsFrustum(frustum) 方法均委托给该逻辑,例如 src/objects/Mesh.jsintersectsFrustum 直接 return frustum.intersectsObject( this );渲染器对 object.isMesh || object.isLine || object.isPointsobject.isSprite 分别调用 object.intersectsFrustum( _frustum )src/renderers/WebGLRenderer.jssrc/renderers/WebGLRenderer.js)。

3.7 .intersectsBox(box) : boolean

判断给定 Box3(轴对齐包围盒,AABB)是否与视锥相交。相比球测试更精确一些,它使用"正侧点"策略:对每个平面,取包围盒中沿该平面法线方向最靠前的那个角点(正侧点)做测试——法向分量大于 0 取 box.max,否则取 box.min——若正侧点都在平面外侧(distance < 0),则整个盒被该平面分离,判负(src/math/Frustum.js)。

源码注释同样将其标注为"保守测试"(src/math/Frustum.js):对于"位于视锥外但未被单一平面分离的大包围盒"可能误报,但绝不漏报。测试用例特意在 Box3(0,0,0)-(1,1,1) 基础上做了偏移,以避免包围盒恰好贴合视锥平面边界时浮点误差导致的不稳定(test/unit/src/math/Frustum.tests.js)。

3.8 .intersectsSprite(sprite) : boolean

Sprite(精灵)是始终面向相机的四边形,无传统几何体。为此实现专门合成一个保守的包围球(src/math/Frustum.js):把球心置于原点、半径设为对角线半长 0.7071067811865476(即 √2/2,保证覆盖单位四边形)并加上 sprite.center(中心点偏移)的影响,再经 matrixWorld 变换到世界空间后走 intersectsSphere。测试可参考 test/unit/src/math/Frustum.tests.js

四、一个完整的实战示例:手动实现视锥剔除

虽然渲染器默认已对开启了 frustumCulled 的对象做剔除,但当你做自定义渲染循环、拾取预筛选、LOD 选择或编辑器遮挡剔除时,可以完全复用这套 API。以下示例每帧从透视相机提取视锥,再分别测试点、包围盒、包围球与具体对象:

import * as THREE from './src/Three.js';

const camera = new THREE.PerspectiveCamera( 75, innerWidth / innerHeight, 0.1, 1000 );
const mesh = new THREE.Mesh( new THREE.BoxGeometry( 1, 1, 1 ) );

const frustum = new THREE.Frustum();            // 复用同一实例,避免逐帧分配
const projScreenMatrix = new THREE.Matrix4();   // 投影·视图复合矩阵

function updateCulling() {
    // 1) 合成 投影矩阵 × 视图逆矩阵
    projScreenMatrix.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse );

    // 2) 提取 6 个平面(WebGL 约定;若使用 WebGPURenderer 应传相机 coordinateSystem)
    frustum.setFromProjectionMatrix( projScreenMatrix, THREE.WebGLCoordinateSystem );

    // 3) 多种测试粒度
    const pointInside = frustum.containsPoint( new THREE.Vector3( 0, 0, - 5 ) );
    const boxInView   = frustum.intersectsBox( mesh.geometry.boundingBox !== null ? mesh.geometry.boundingBox : mesh.geometry.computeBoundingBox() );
    const sphereHit   = frustum.intersectsSphere( new THREE.Sphere( new THREE.Vector3( 0, 0, - 5 ), 1 ) );

    // 4) 对象级测试:等价于渲染器内部的 object.intersectsFrustum(frustum)
    mesh.updateMatrixWorld();                    // 确保 matrixWorld 最新
    const visible = frustum.intersectsObject( mesh );
    mesh.visible = visible && boxInView;         // 示例:结合自己的判定

    if ( visible ) renderer.render( scene, camera ); // 仅在视锥内时渲染(示意)
}

要点回顾:

  • 相机参数缺一不可setFromProjectionMatrix 提取出的视锥质量取决于传入矩阵。渲染器传入的 _projScreenMatrixprojectionMatrix × matrixWorldInverse,因此记得更新相机的 matrixWorld(相机通常挂在场景中自动更新,若相机无父节点且 matrixWorldAutoUpdate 为真,渲染器会代为调用 camera.updateMatrixWorld(),见 src/renderers/WebGLRenderer.js)。
  • 选择合适粒度的测试:逐对象用 intersectsObject(内部仍是包围球),追求更高精度可用 intersectsBox,两者都是"绝不漏报"的保守测试,误报仅意味着少量冗余绘制。
  • 几何没有包围球时:按文档要求先确保几何已存在,必要时调用 geometry.computeBoundingSphere()
  • 坐标系参数:本仓库同时提供 WebGL 与 WebGPU 渲染路径,WebGPU 下相机的 coordinateSystem 可能为 WebGPUCoordinateSystem,提取视锥时应传入一致的值(渲染器内部会自动处理,手动调用时需留意)。

五、进阶:ArrayCamera 多相机视锥与 FrustumArray

在 WebGL 与通用(WebGPU)渲染器中,当使用 ArrayCamera(多视图/多子相机,见 src/cameras/ArrayCamera.js,其 isArrayCamera 标志为 true)时,单个 Frustum 不够用,渲染器会改用 FrustumArray:它内部维护一个 Frustum 实例池,通过 setFromArrayCamera( cameraArray ) 为每个子相机分别计算视锥并缓存(src/math/FrustumArray.js),随后 intersectsObject( object ) 只要对象命中任一子视锥即视为可见。渲染器中 camera.isArrayCamera ? _frustumArray : _frustum 的写法清晰体现了这一分工(src/renderers/common/Renderer.jssrc/renderers/WebGLRenderer.js)。

从源码结构还可以推断:FrustumArray 的实例池会随最大子相机数量增长并保留复用(_count 记录当前使用数),从而避免相机阵列长度变化时反复分配对象。若你需要在自定义多视图方案中逐子相机剔除,可直接参考其"先乘 projectionMatrix × matrixWorldInverse,再按子相机的 coordinateSystem / reversedDepth 提取"的写法。

六、测试与验证

仓库针对 Frustum 提供了完整的 QUnit 单元测试 test/unit/src/math/Frustum.tests.js,覆盖:

  • 实例化:默认构造的 6 平面、传参构造的平面赋值;
  • set / clone / copy 的深拷贝语义;
  • setFromProjectionMatrix + containsPoint:分别使用正交投影(makeOrthographic)与透视投影(makePerspective)构造视锥,然后对近平面内外、远平面内外、视野四个角的内外、超出边界的点逐项断言;
  • intersectsSphere:通过半径补偿验证"保守测试"特性——球心在视锥外但半径足够大时应相交;
  • intersectsObjectintersectsSpriteintersectsBox:用透视投影视锥对移动后的 Mesh、Sprite、Box3 做可见性断言。

若要本地运行该测试套件,可在仓库根目录按 package.json 中定义的单元测试脚本执行(例如 npm run test-unit 一类命令,具体以 package.jsonscripts 为准),或直接运行整个 QUnit 测试入口。

结语

Frustum 虽然"主要面向渲染器内部",却是 three.js 高性能渲染的基石之一:setFromProjectionMatrix 完成数学提取,containsPoint / intersectsSphere / intersectsBox / intersectsObject / intersectsSprite 提供从点到对象的全粒度保守测试,WebGL 与 WebGPU 渲染路径、阴影贴图、点光源阴影乃至 ArrayCamera 多视口渲染都构建在这套机制之上。理解它的保守语义与坐标系分支,能让你在自定义剔除、LOD 与编辑器工具中写出既高效又不会"误杀可见物体"的正确代码。

登录后查看全文
热门项目推荐
相关项目推荐