第 8 节:Camera 与投影系统——从透视矩阵到 Frustum Culling
Camera 在 Three.js 中既是"观察者"(决定看到什么),也是"投影机"(决定 3D 如何压成 2D)。它涉及三大核心:View 矩阵、Projection 矩阵、视锥体剔除。
🏗️ Camera 的三把剑
Camera
├── matrixWorld: 相机在世界中的位姿
├── matrixWorldInverse: View 矩阵 = matrixWorld 的逆
├── projectionMatrix: 投影矩阵(透视/正交)
├── projectionMatrixInverse: 投影矩阵的逆
├── fov / aspect / near / far
├── layers: Layers (32 位掩码)
└── view: 启用 frustumCulling 的子对象列表
1. View 矩阵
View 矩阵本质上是"世界坐标 → 相机坐标"的变换,等价于"把世界平移旋转到相机视角下"。
// 简化源码
updateMatrixWorld(force) {
super.updateMatrixWorld(force);
this.matrixWorldInverse.copy(this.matrixWorld).invert();
}
为什么取逆? 因为相机的"位姿"矩阵描述的是"相机在世界中怎么摆放",而我们需要的是"反过来把世界变换到相机视角"——所以取逆。
2. 投影矩阵
透视投影
// 简化源码:PerspectiveCamera.updateProjectionMatrix()
updateProjectionMatrix() {
const near = this.near;
const top = near * Math.tan(DEG2RAD * 0.5 * this.fov) / this.zoom;
const height = 2 * top;
const width = this.aspect * height;
const left = -0.5 * width;
this.projectionMatrix.makePerspective(left, left + width, top, top - height, near, this.far);
}
fov 是垂直视场角,aspect 是宽高比。tan(fov/2) * near 给出 near 平面半高。
正交投影
// 简化源码:OrthographicCamera
updateProjectionMatrix() {
this.projectionMatrix.makeOrthographic(
this.left, this.right, this.top, this.bottom, this.near, this.far
);
}
正交投影没有"近大远小",所有物体大小一致——适合 2D UI、工程制图。
🔍 Frustum Culling:6 个平面的魔法
如果一个物体在相机视锥体之外,根本不需要绘制——直接跳过,省下 GPU 时间。
1. 视锥体的 6 个平面
// 简化源码:src/math/Frustum.js
class Frustum {
constructor(p0, p1, p2, p3, p4, p5) {
this.planes = [p0, p1, p2, p3, p4, p5]; // 6 个 Plane
}
setFromProjectionMatrix(m) {
const planes = this.planes;
const me = m.elements;
// 6 个平面的法向量和常数项,从 projectionMatrix * viewMatrix 推导
// ...
}
}
这 6 个平面分别对应:near、far、left、right、top、bottom。
2. 视锥体与包围盒/包围球求交
// 简化源码
intersectsObject(object) {
if (object.boundingSphere === undefined) {
// 自动计算 boundingSphere
if (object.geometry.boundingSphere === null) {
object.geometry.computeBoundingSphere();
}
object.boundingSphere = object.geometry.boundingSphere.clone();
object.boundingSphere.applyMatrix4(object.matrixWorld);
}
return this.intersectsSphere(object.boundingSphere);
}
intersectsSphere(sphere) {
const planes = this.planes;
const center = sphere.center;
const negRadius = -sphere.radius;
for (let i = 0; i < 6; i++) {
const distance = planes[i].distanceToPoint(center);
if (distance < negRadius) return false; // 完全在某平面外侧
}
return true; // 至少与所有平面相交或在内部
}
「点到平面的距离 < -radius」 就说明球完全在平面外侧。如果任何一个平面判定为完全外侧,则整个球(和物体)在视锥体之外,可以剔除。
3. 包围球 vs 包围盒
| 类型 | 精度 | 计算开销 | 适用 |
|---|---|---|---|
| 包围球 (BoundingSphere) | 较低 | 极低 | 简单几何体、远距离物体 |
| 包围盒 (BoundingBox) | 较高 | 较低 | 复杂不规则几何体 |
Three.js 默认用包围球——因为它只需要 1 次距离计算就能判定相交,比 AABB 8 个顶点全算一遍快 8 倍。
🖼️ Viewport 与 Scissor:多视口渲染
Viewport 决定"渲染到画布的哪个矩形区域":
// 简化源码
setViewport(x, y, width, height) {
if (currentViewport[0] !== x || ...) {
gl.viewport(x, y, width, height);
}
}
// 切到屏幕左半边
renderer.setViewport(0, 0, width / 2, height);
camera.aspect = (width / 2) / height; // 注意:也要更新相机的宽高比!
camera.updateProjectionMatrix();
renderer.render(scene, camera);
// 切到屏幕右半边
renderer.setViewport(width / 2, 0, width / 2, height);
camera.aspect = (width / 2) / height;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
Scissor 类似,但只影响光栅化输出区域,不影响投影——适合做小地图、HUD。
🎯 Mini 实现
class MiniCamera {
constructor(fov = 75, aspect = 1, near = 0.1, far = 1000) {
this.fov = fov;
this.aspect = aspect;
this.near = near;
this.far = far;
this.position = [0, 0, 5];
this.viewMatrix = identity();
this.projMatrix = identity();
}
update() {
// View 矩阵:相机位置的逆
this.viewMatrix = invert(this.position);
// Projection 矩阵
const f = 1 / Math.tan((this.fov / 2) * Math.PI / 180);
this.projMatrix = [
f / this.aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (this.far + this.near) / (this.near - this.far), (2 * this.far * this.near) / (this.near - this.far),
0, 0, -1, 0
];
}
// 简化 Frustum 剔除
isInFrustum(boundingSphere) {
// 简单测试:检查 z 距离
const dz = boundingSphere.center[2] - this.position[2];
if (dz > this.far + boundingSphere.radius) return false;
if (dz < this.near - boundingSphere.radius) return false;
return true;
}
}
🆚 OGL 对比
OGL 的 Camera 直接继承 Transform,矩阵更新与场景图融为一体:
// OGL: src/camera/Camera.js
class Camera extends Transform {
constructor(gl, { fov = 45, aspect = 1, near = 0.1, far = 100 } = {}) {
super();
this.fov = fov;
// ... 自动维护 viewMatrix / projectionMatrix
}
get projectionMatrix() {
// 自动按 fov / aspect / near / far 计算
}
get viewMatrix() {
return this.inverseMatrix; // 直接用 Transform 维护的逆矩阵
}
}
设计取舍:OGL 让 Camera 拥有自己的位姿(继承 Transform),并自动计算 viewMatrix。Three.js 让 Camera 继承 Object3D 但默认不放在场景图里——更传统但更明确。
🎓 深度思考
为什么 Frustum Culling 不完全消除 overdraw?
它只剔除"完全在视锥外的物体"。如果物体部分在视锥内,仍会被整物体渲染。真正消除 overdraw 要靠遮挡剔除(Occlusion Culling)——但 WebGL 没有原生支持,需要用 Hi-Z、Portal 等算法。透视矩阵的
w分量为什么是-z?
这样透视除法之后,近处 z 值更大,远处 z 值更小,符合 NDC 约定(z 越小越远)。为什么投影矩阵的 near 不能太小?
透视除法后深度精度近似于 $1/z$。如果 near=0.001,在 1 米内就吃掉了大部分深度精度,远处就会闪烁(Z-Fighting)。