还在用 Cannon.js?Three.js + BVH 手撸 3A 级角色控制器,性能直接起飞!🚀

300 阅读5分钟

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

20260119_101323.gif

为什么选择 three-mesh-bvh?

three-mesh-bvh 是一个为 Three.js 几何体构建 Bounding Volume Hierarchy (BVH) 的库。它的主要优势在于:

  1. 极高的射线检测性能:比 Three.js 原生的 Raycaster 快成百上千倍。
  2. 几何体碰撞检测:支持检测胶囊体(Capsule)、球体、盒子与复杂场景 Mesh 的碰撞及穿透深度。
  3. 轻量:不需要物理引擎的模拟循环,完全由我们自己控制位置更新,更容易实现“游戏化”的手感。

核心实现原理

我们的核心思路是:使用胶囊体(Capsule)代表角色,使用 BVH 索引的静态 Mesh 代表环境。

每一帧的逻辑如下:

  1. 应用重力:让角色向下加速。
  2. 应用输入移动:根据 WASD 计算水平位移。
  3. 子步进(Sub-stepping):将一帧的时间切分为多个微小的时间片(如 1/120 秒),在每个时间片内分别移动并解决碰撞。这是防止“穿模”和保证物理稳定的关键。
  4. 碰撞解决:如果胶囊体与环境重叠,将其沿法线方向“推”出来。

代码实战

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');
    }
}

进阶优化技巧

在示例代码中,我们还实现了一些提升手感的细节:

  1. Coyote Time (土狼时间):允许玩家在离开平台边缘的一小段时间内(如 0.1秒)仍然可以跳跃,极大地提升了平台跳跃的容错率。
  2. Jump Buffer (跳跃预输入):如果玩家在落地前按下了跳跃键,角色会在落地的瞬间自动起跳,防止按键吞操作。
  3. 台阶检测:在移动受阻时,尝试将胶囊体稍微抬高。如果抬高后可以移动,说明遇到了矮台阶,自动跨越。

总结

通过组合 Three.js 的渲染能力和 three-mesh-bvh 的空间索引能力,我们仅用几百行代码就实现了一个功能完备的角色控制器。这种方法既保留了对物理手感的完全控制权,又避免了引入重型物理引擎的性能开销,非常适合 Web 端的中轻量级 3D 项目。

完整代码可见项目 - 开源三维低代码平台

GitHub Meteor3D

Meteor3D 官网