Three.js 进阶:打造丝滑的物理角色控制器 (基于 three-mesh-bvh)
在 Three.js 开发中,实现一个手感良好、且能与复杂场景精确交互的角色控制器(Character Controller)一直是个难点。虽然我们可以使用 Cannon.js 或 Ammo.js 等物理引擎,但对于很多漫游类项目来说,引入完整的物理引擎往往显得“过重”,且难以精细控制角色的移动手感(如瞬间起停、爬楼梯、空气阻力等)。
本文将讲解如何使用 Three.js 配合 three-mesh-bvh 库,实现一个轻量级、高性能且支持第一/第三人称切换的物理角色控制器。
目前实现了四个操作模式:
- Orbit(默认拖拽)
- Free Fly(WASD + QE)
- FPP Collision
- TPP Collision
为什么选择 three-mesh-bvh?
three-mesh-bvh 是一个为 Three.js 几何体构建 Bounding Volume Hierarchy (BVH) 的库。它的主要优势在于:
- 极高的射线检测性能:比 Three.js 原生的 Raycaster 快成百上千倍。
- 几何体碰撞检测:支持检测胶囊体(Capsule)、球体、盒子与复杂场景 Mesh 的碰撞及穿透深度。
- 轻量:不需要物理引擎的模拟循环,完全由我们自己控制位置更新,更容易实现“游戏化”的手感。
核心实现原理
我们的核心思路是:使用胶囊体(Capsule)代表角色,使用 BVH 索引的静态 Mesh 代表环境。
每一帧的逻辑如下:
- 应用重力:让角色向下加速。
- 应用输入移动:根据 WASD 计算水平位移。
- 子步进(Sub-stepping):将一帧的时间切分为多个微小的时间片(如 1/120 秒),在每个时间片内分别移动并解决碰撞。这是防止“穿模”和保证物理稳定的关键。
- 碰撞解决:如果胶囊体与环境重叠,将其沿法线方向“推”出来。
代码实战
1. 场景与碰撞体准备
为了获得最佳性能,我们需要将场景中所有静态的障碍物合并成一个几何体,并为其构建 BVH 索引。
import { MeshBVH, MeshBVHHelper } from 'three-mesh-bvh';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
// ... 加载模型后 ...
const colliderGeometries = [];
model.traverse((child) => {
if (child.isMesh) {
// 提取几何体并应用世界变换矩阵
const geom = child.geometry.clone();
geom.applyMatrix4(child.matrixWorld);
colliderGeometries.push(geom);
}
});
// 合并所有几何体
const merged = mergeGeometries(colliderGeometries, false);
// 构建 BVH 索引
merged.boundsTree = new MeshBVH(merged, { maxLeafTris: 10 });
// 创建用于物理检测的 Mesh(可以设为不可见)
const collider = new THREE.Mesh(merged);
collider.visible = false;
scene.add(collider);
2. 定义角色(胶囊体)
Three.js 提供了 Capsule 数学类,非常适合用来做角色包围盒。
import { Capsule } from 'three/addons/math/Capsule.js';
const capsuleRadius = 0.35;
const capsuleHeight = 1.0;
const capsule = new Capsule(
new THREE.Vector3(0, capsuleRadius, 0), // 底部球心
new THREE.Vector3(0, capsuleRadius + capsuleHeight, 0), // 顶部球心
capsuleRadius
);
3. 核心物理循环:子步进与碰撞解决
这是整个控制器最精华的部分。我们不直接在一帧里移动 velocity * delta,而是将其拆解。
const substepDt = 1 / 120; // 物理模拟的固定时间步长
function updatePlayer(delta) {
// ... 计算 moveDir (输入方向) ...
// 将当前帧的时间 delta 拆分为多个子步
const steps = Math.max(1, Math.ceil(delta / substepDt));
const stepDt = delta / steps;
for (let step = 0; step < steps; step += 1) {
// 1. 应用重力
velocityY -= gravity * stepDt;
capsule.translate(new THREE.Vector3(0, velocityY * stepDt, 0));
// 2. 解决重力导致的碰撞(主要是地面支持)
resolveCollisions();
// 3. 应用水平移动
if (moveDir.lengthSq() > 0) {
// ... 计算 moveDelta ...
capsule.translate(moveDelta);
// 4. 解决移动导致的碰撞(撞墙)
resolveCollisions();
}
}
}
4. 碰撞解决逻辑 (resolveCollisions)
three-mesh-bvh 提供了 shapecast 方法,可以高效地检测胶囊体与场景的相交情况。
function resolveCollisions() {
// 简单的包围盒预检查
tempBox.makeEmpty();
tempBox.expandByPoint(capsule.start);
tempBox.expandByPoint(capsule.end);
tempBox.min.addScalar(-capsule.radius);
tempBox.max.addScalar(capsule.radius);
collider.geometry.boundsTree.shapecast({
intersectsBounds: (box) => box.intersectsBox(tempBox),
intersectsTriangle: (tri) => {
// 计算三角形到胶囊体线段的最近点
const triPoint = tempVector;
const capsulePoint = tempVector2;
const distance = tri.closestPointToSegment(capsule, triPoint, capsulePoint);
if (distance < capsule.radius) {
// 发生碰撞!
const depth = capsule.radius - distance;
const normal = capsulePoint.sub(triPoint).normalize();
// 将胶囊体推出
capsule.translate(normal.multiplyScalar(depth));
// 如果法线朝上,说明踩在地板上
if (normal.y > 0.5) onGround = true;
return true;
}
return false;
},
});
}
5. 相机跟随系统
为了支持第一人称(FPP)和第三人称(TPP),我们需要根据胶囊体的位置动态更新相机。
function updateCamera() {
// 获取胶囊体中心点
const capsuleMid = tempVector.addVectors(capsule.start, capsule.end).multiplyScalar(0.5);
if (isThirdPerson) {
// 第三人称:计算相机偏移
const offset = new THREE.Vector3(0, cameraHeight, cameraDistance);
// 根据鼠标的 yaw (水平) 和 pitch (俯仰) 旋转偏移量
offset.applyAxisAngle(new THREE.Vector3(1, 0, 0), pitch);
offset.applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw);
camera.position.copy(capsuleMid).add(offset);
camera.lookAt(capsuleMid);
} else {
// 第一人称:相机位于头部
camera.position.copy(capsuleMid).add(new THREE.Vector3(0, capsuleHeight * 0.4, 0));
// 直接设置相机旋转
camera.rotation.set(pitch, yaw, 0, 'YXZ');
}
}
进阶优化技巧
在示例代码中,我们还实现了一些提升手感的细节:
- Coyote Time (土狼时间):允许玩家在离开平台边缘的一小段时间内(如 0.1秒)仍然可以跳跃,极大地提升了平台跳跃的容错率。
- Jump Buffer (跳跃预输入):如果玩家在落地前按下了跳跃键,角色会在落地的瞬间自动起跳,防止按键吞操作。
- 台阶检测:在移动受阻时,尝试将胶囊体稍微抬高。如果抬高后可以移动,说明遇到了矮台阶,自动跨越。
总结
通过组合 Three.js 的渲染能力和 three-mesh-bvh 的空间索引能力,我们仅用几百行代码就实现了一个功能完备的角色控制器。这种方法既保留了对物理手感的完全控制权,又避免了引入重型物理引擎的性能开销,非常适合 Web 端的中轻量级 3D 项目。
完整代码可见项目 - 开源三维低代码平台