【threejs】第一人称视角之八叉树碰撞检测
目录
- 引言
- 基本概念和原理
- 实现过程
- 总结
- 参考
引言
在游戏开发、3D 仿真和物理引擎中,碰撞检测(Collision Detection)是一个核心问题。当场景中有成千上万的物体时,如何高效判断“谁撞上了谁”?如果简单粗暴地遍历所有物体两两检测,计算复杂度会高达 O(n²),性能直接爆炸!💥
这时,八叉树(Octree) 闪亮登场✨——它通过 空间分割 技术,将 3D 世界递归划分成小块,只检测 可能发生碰撞的物体,让计算复杂度骤降至 O(n log n),甚至更低!
本文将带你深入八叉树的原理,手把手实现一个高效的碰撞检测系统!
基本概念和原理
- 相机控制系统
(1)相机类型选择:
PerspectiveCamera(透视相机)适合第一/第三人称视角,参数:fov, aspect, near, far
// 相机(透视)const camera = new THREE.PerspectiveCamera(75, //视角window.innerWidth / window.innerHeight, //aspect视锥长宽比0.1, //near10000 //far);camera.rotation.order = "YXZ"; //默认旋转顺序是 'XYZ',设置相机旋转的顺序的属性。这个属性指定了欧拉角(Euler angles)的旋转顺序camera.lookAt(0, 0, 0);camera.position.set(0, 1, 5);scene.add(camera);
(2)相机控制器:
PointerLockControls(指针锁定控制器) 精准的鼠标输入(无加速/边界限制),完全的移动逻辑控制权(可插入碰撞检测),更低的性能开销(无内置惯性计算),若项目需要真实的物理碰撞或竞技级FPS体验,自定义 PointerLockControls 是唯一选择
为什么选择 PointerLockControls实现第一人称视角碰撞检测而不是直接使用FirstPersonControls?
①PointerLockControls直接捕获鼠标输入,消除光标移动范围限制,实现无间断的视角旋转(适合FPS游戏),而FirstPersonControls依赖鼠标相对移动事件,无法完全隐藏系统光标,降低沉浸感。
②PointerLockControls与物理引擎/碰撞检测无缝集成,可自由扩展 update 逻辑,在每一帧计算移动前先检测碰撞(如射线检测或物理引擎查询)。FirstPersonControls 的封闭性,移动逻辑内置且不可干预,无法关闭自动的水平矫正(不适合需要自由旋转的场景)强制覆盖其 update 方法,可能破坏内部状态机。
需求 | PointerLockControls | FirstPersonControls | OrbitControls |
---|---|---|---|
核心用途 | FPS游戏/仿真 | 简易第一人称浏览 | 3D模型观察/场景调试 |
鼠标控制 | ✅ 锁定指针,无光标干扰 | ⚠️ 受系统光标限制,无法实现无光标 | ✅ 自由旋转/缩放(光标可见) |
键盘控制 | 需手动添加键盘移动和鼠标旋转 | 默认支持WASD移动和鼠标旋转 | 键盘只能控制左右俯仰,鼠标左点击旋转,右点击拖拽,围绕目标物体,target只能在一个小区域 |
视角限制 | ✅ 可限制俯仰角(如±90°) | ⚠️ 固定限制 | ✅ 可限制旋转范围/缩放距离 |
物理引擎集成 | ✅ 直接同步物理体位置 | ❌ 难以与物理体同步 | ❌ 完全独立,无物理交互 |
自定义碰撞响应 | ✅ 自由扩展检测逻辑 | ❌ 移动逻辑不可干预 | ❌ 固定交互逻辑 |
移动平滑性 | ⚠️ 需手动实现阻尼 | ✅ 内置惯性/阻尼 | ✅ 内置平滑旋转/缩放 |
UI兼容性 | ❌ 需额外处理UI交互(指针锁定) | ⚠️ 需隐藏光标 | ✅ 完美兼容UI(光标自由移动) |
典型场景 | FPS射击游戏,VR行走模拟 | 3D博物馆浏览,简单场景漫游 | 模型展示,开发者调试场景 |
- 射线(Raycaster)
Raycaster 是用于 射线检测(Raycasting) 的核心类,其本质是从 3D 空间中的一个点向特定方向发射一条无限延伸的虚拟射线,检测该射线与场景中物体的交点。六个核心使用场景第一人称视角的碰撞检测、鼠标拾取(3D物体选择)、地面高度检测(角色站立/楼梯攀爬)、武器子弹命中检测、视线检测(AI敌人感知)、动态遮挡剔除(性能优化)
//射线由 起点(origin) 和 方向(direction) 定义const raycaster = new THREE.Raycaster(origin, direction);const intersects = raycaster.intersectObjects(this.scene.children); //返回交叉部分数组[ { distance, point, face, faceIndex, object }, ... ]/*distance —— 射线投射原点和相交部分之间的距离。point —— 相交部分的点(世界坐标)face —— 相交的面faceIndex —— 相交的面的索引object —— 相交的物体uv —— 相交部分的点的UV坐标。uv1 —— 相交部分的点的第二组UV坐标normal - 交点处的内插法向量instanceId – 与InstancedMesh物体相交时的instance索引*/
- 八叉树(Octree)
是一种 空间分割数据结构,用于高效管理 3D 空间中的物体。它通过递归地将立方体空间划分为 8 个子立方体(称为“节点”或“象限”),每个子立方体可继续分割,直到满足终止条件(如深度限制或物体数量阈值)。
- 分层结构:树状组织,根节点代表整个空间,叶节点存储实际物体。
- 动态适应:根据物体分布自动调整分割粒度。
- 快速查询:利用空间位置跳过无关区域,优化碰撞检测、射线检测等操作。
为什么用八叉树?
在 3D 场景中,直接遍历所有物体进行碰撞检测的复杂度为 O(n²),而八叉树可将其降至 O(n log n) 或更低。典型应用场景包括:
①碰撞检测:快速筛选可能相交的物体对。
②射线检测:仅检测射线途径的节点内的物体。
③视锥剔除:只渲染相机可见区域的物体。
④动态场景管理:如游戏中的粒子系统、物理引擎。
(2)胶囊体(Capsule)
本质是碰撞几何体,由两个半球和一个圆柱组成的数学模型,用于简化角色或物体的碰撞形状。用于替代复杂网格碰撞体,提供更高效且自然的碰撞检测(尤其适合角色控制器)
- 平滑移动余阻尼
使用线性插值(LERP)实现平滑过渡
const currentPosition = new THREE.Vector3().copy(startPosition);
const lerpFactor = 0.1; // 插值系数 (0~1,值越大过渡越快)
currentPosition.lerp(targetPosition, lerpFactor);
应用缓动函数(easing functions)改善手感
let damping = Math.exp(-4 * deltaTime) - 1; //阻尼,随着deltaTime指数增加damping越小(减去 1。这可能是为了调整阻尼值的范围)
if (!this.onFloor) {this.playerVelocity.y -= this.gravity * deltaTime;damping *= 0.1;
}
this.playerVelocity.addScaledVector(this.playerVelocity, damping);
const deltaPosition = this.playerVelocity.clone().multiplyScalar(deltaTime);
this.capsule.translate(deltaPosition);
实现过程
- 加载模型和胶囊把场景分解成一些节点
this.octree.fromGraphNode(this.modelObj)
// 加载模型,并渲染到画布上loadGLTF(this.modelUrl).then((object: any) => {this.modelObj = object.scene;console.log(this.modelObj); // 返回组对象Groupthis.scene.add(this.modelObj);// 遍历场景中的所有几何体数据this.modelObj.traverse((child: any) => {if (child.isMesh) {child.castShadow = true;child.receiveShadow = true;}});//八叉树this.octree = new Octree();this.octree.fromGraphNode(this.modelObj); // 通过Octree对象来构建节点// OctreeHelper// const helper = new OctreeHelper(this.octree);// helper.visible = true;// this.scene.add(helper);});
- 把胶囊体的位置传给网格对象,进行运动交互
//player类中的部分方法init() {//胶囊体,用于碰撞检测,Capsule不是一个几何体//this.capsule位置方向大小设置很重要,this.height要将其底部与场景中其他几何体的基准线对齐this.capsule = new Capsule(new THREE.Vector3(0, this.radius, 0), //第一个端点new THREE.Vector3(0, this.height + this.radius, 0), //第二个端点this.radius //半径);this.mesh = new THREE.Mesh(new THREE.CapsuleGeometry(this.radius, this.height),new THREE.MeshNormalMaterial());this.mesh.rotation.order = "YXZ";this.scene.add(this.mesh);this.sync();this.addkeyBoard();}sync() {const end = this.capsule.end.clone();end.y -= this.radius;this.mesh.position.copy(end);}
- 进行碰撞检测,模拟物理效果
在Octree对象中,我们可以通过capsuleIntersect方法来捕获Capsule胶囊体与所构建了八叉树节点的场景是否进行了碰撞,检测方式如下:const result = this.octree.capsuleIntersect(this.capsule);
- depth: 碰撞的深度,可以理解为物体和场景中相机的比例
- normal:碰撞的法线向量,可以理解为碰撞的方向
handleCollider() {//检查场景空间和胶囊的碰撞const result = this.octree.capsuleIntersect(this.capsule);this.onFloor = false;if (result) {const { normal, depth } = result;this.onFloor = normal.y > 0;if (!this.onFloor) {this.speedVel.addScaledVector(result.normal, -result.normal.dot(this.speedVel));} else {this.time = 0;this.speedVel.y = 0;}this.capsule.translate(normal.multiplyScalar(depth));//实现不同平面的行走,镜头可以向下或向上移动一定距离}}
- 移动镜头,通过键盘和鼠标操控镜头移动旋转实现浏览场景的基本操作
(1)PointerLockControls指针控制器+鼠标控制旋转视角
// 添加相机控件-指针
this.controls = new PointerLockControls(this.camera, this.canvas);
this.controls.lock(); // 锁定鼠标到画布,隐藏光标, 注:Tween操作需要在this.controls.lock()之前
this.controls.unlock(); //释放鼠标,恢复光标//鼠标控制
addMouseEvent() {let mouseX;let mouseY;document.onmousedown = (event) => {event.preventDefault();mouseX = event.pageX;mouseY = event.pageY;};document.onmousemove = (event) => {libraryState.isDraging = true;event.preventDefault();if (mouseX && mouseY) {var deltaX = event.pageX - mouseX;var deltaY = event.pageY - mouseY;mouseX = event.pageX;mouseY = event.pageY;// 根据触摸事件的移动量调整相机的角度this.camera.rotation.y -= deltaX * 0.003; //左右旋转this.camera.rotation.x -= deltaY * 0.003; //俯仰旋转}};document.onmouseup = (event) => {event.preventDefault();if (libraryState.viewing) return;mouseX = null;mouseY = null;};}
(2)键盘事件移动方向
在requestAnimationFrame方法种执行keyControls和updatePlayer
监听键盘事件修改方向向量playerVelocity → 根据碰撞检测handleCollider计算出胶囊体capsule最新位置 → 同步更新胶囊和相机位置
keyControls(deltaTime) {const speedDelta = deltaTime * (this.onFloor ? 25 : 8);if (this.keyStates["KeyW"]) {this.playerVelocity.add(this.getForwardVector().multiplyScalar(speedDelta));}if (this.keyStates["KeyS"]) {this.playerVelocity.add(this.getForwardVector().multiplyScalar(-speedDelta));}if (this.keyStates["KeyA"]) {this.playerVelocity.add(this.getSideVector().multiplyScalar(-speedDelta));}if (this.keyStates["KeyD"]) {this.playerVelocity.add(this.getSideVector().multiplyScalar(speedDelta));}if (this.onFloor) {if (this.keyStates["Space"]) {this.playerVelocity.y = 5;}}}async updatePlayer(deltaTime: number) {let damping = Math.exp(-4 * deltaTime) - 1;//随着deltaTime指数增加damping越小(减去 1。这可能是为了调整阻尼值的范围)if (!this.onFloor) {this.playerVelocity.y -= this.gravity * deltaTime;damping *= 0.1;}this.playerVelocity.addScaledVector(this.playerVelocity, damping);const deltaPosition = this.playerVelocity.clone().multiplyScalar(deltaTime);this.capsule.translate(deltaPosition);this.handleCollider(); //碰撞检测this.sync(); //同步mesh胶囊this.check(); //回归中心点// 同步到缩略图上this.handleMiniMapMove();this.handleMiniMapRoate();}sync() {// 同步胶囊和相机位置const end = this.capsule.end.clone();// end.y -= this.radiusthis.mesh.position.copy(end);this.camera.position.copy(end);}
效果图如下:
总结
八叉树是一种高效空间索引工具,减少需处理的物体数量,适合动态场景的碰撞检测。胶囊体比复杂网格的碰撞计算快10-100倍,胶囊体+射线组 平衡精度与性能,是角色控制的黄金组合。简单场景用纯八叉树,复杂交互需集成 Cannon.js。
参考
- 基于three.js实现第一人称的碰撞检测
- threejs官方fps示例