【threejs】第一人称视角之八叉树碰撞检测

目录

引言

在游戏开发、3D 仿真和物理引擎中,碰撞检测(Collision Detection)是一个核心问题。当场景中有成千上万的物体时,如何高效判断"谁撞上了谁"?如果简单粗暴地遍历所有物体两两检测,计算复杂度会高达 O(n²),性能直接爆炸!💥

这时,八叉树(Octree) 闪亮登场✨------它通过 空间分割 技术,将 3D 世界递归划分成小块,只检测 可能发生碰撞的物体,让计算复杂度骤降至 O(n log n),甚至更低!

本文将带你深入八叉树的原理,手把手实现一个高效的碰撞检测系统!

基本概念和原理

  1. 相机控制系统

(1)相机类型选择:

PerspectiveCamera(透视相机)适合第一/第三人称视角,参数:fov, aspect, near, far

typescript 复制代码
  // 相机(透视)
  const camera = new THREE.PerspectiveCamera(
    75, //视角
    window.innerWidth / window.innerHeight, //aspect视锥长宽比
    0.1, //near
    10000 //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博物馆浏览,简单场景漫游 模型展示,开发者调试场景
  1. 射线(Raycaster)

Raycaster 是用于 射线检测(Raycasting) 的核心类,其本质是从 3D 空间中的一个点向特定方向发射一条无限延伸的虚拟射线,检测该射线与场景中物体的交点。六个核心使用场景第一人称视角的碰撞检测、鼠标拾取(3D物体选择)、地面高度检测(角色站立/楼梯攀爬)、武器子弹命中检测、视线检测(AI敌人感知)、动态遮挡剔除(性能优化)

typescript 复制代码
	//射线由 起点(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索引
	*/
  1. 八叉树(Octree)

是一种 空间分割数据结构,用于高效管理 3D 空间中的物体。它通过递归地将立方体空间划分为 8 个子立方体(称为"节点"或"象限"),每个子立方体可继续分割,直到满足终止条件(如深度限制或物体数量阈值)。

  • 分层结构:树状组织,根节点代表整个空间,叶节点存储实际物体。
  • 动态适应:根据物体分布自动调整分割粒度。
  • 快速查询:利用空间位置跳过无关区域,优化碰撞检测、射线检测等操作。

为什么用八叉树?

在 3D 场景中,直接遍历所有物体进行碰撞检测的复杂度为 O(n²),而八叉树可将其降至 O(n log n) 或更低。典型应用场景包括:

①碰撞检测:快速筛选可能相交的物体对。

②射线检测:仅检测射线途径的节点内的物体。

③视锥剔除:只渲染相机可见区域的物体。

④动态场景管理:如游戏中的粒子系统、物理引擎。

(2)胶囊体(Capsule)

本质是碰撞几何体,由两个半球和一个圆柱组成的数学模型,用于简化角色或物体的碰撞形状。用于替代复杂网格碰撞体,提供更高效且自然的碰撞检测(尤其适合角色控制器)

  1. 平滑移动余阻尼

使用线性插值(LERP)实现平滑过渡

typescript 复制代码
const currentPosition = new THREE.Vector3().copy(startPosition);
const lerpFactor = 0.1; // 插值系数 (0~1,值越大过渡越快)
currentPosition.lerp(targetPosition, lerpFactor);

应用缓动函数(easing functions)改善手感

typescript 复制代码
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);

实现过程

  1. 加载模型和胶囊把场景分解成一些节点 this.octree.fromGraphNode(this.modelObj)
typescript 复制代码
    // 加载模型,并渲染到画布上
    loadGLTF(this.modelUrl).then((object: any) => {
      this.modelObj = object.scene;
      console.log(this.modelObj); // 返回组对象Group
      this.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);
    });
  1. 把胶囊体的位置传给网格对象,进行运动交互
typescript 复制代码
  //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);
  }
  1. 进行碰撞检测,模拟物理效果

在Octree对象中,我们可以通过capsuleIntersect方法来捕获Capsule胶囊体与所构建了八叉树节点的场景是否进行了碰撞,检测方式如下:const result = this.octree.capsuleIntersect(this.capsule);

  • depth: 碰撞的深度,可以理解为物体和场景中相机的比例
  • normal:碰撞的法线向量,可以理解为碰撞的方向
typescript 复制代码
  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. 移动镜头,通过键盘和鼠标操控镜头移动旋转实现浏览场景的基本操作

(1)PointerLockControls指针控制器+鼠标控制旋转视角

typescript 复制代码
// 添加相机控件-指针
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最新位置 → 同步更新胶囊和相机位置

typescript 复制代码
  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.radius
    this.mesh.position.copy(end);
    this.camera.position.copy(end);
  }

效果图如下:

总结

八叉树是一种高效空间索引工具,减少需处理的物体数量,适合动态场景的碰撞检测。胶囊体比复杂网格的碰撞计算快10-100倍,胶囊体+射线组 平衡精度与性能,是角色控制的黄金组合。简单场景用纯八叉树,复杂交互需集成 Cannon.js。

参考

  1. 基于three.js实现第一人称的碰撞检测
  2. threejs官方fps示例
相关推荐
格子软件4 小时前
2026年GEO优化系统源码级状态机与多模型调度拆解
java·前端·vue.js·人工智能·vue·geo
HUMHSX5 小时前
Vue 项目启动全流程解析:从入口文件到全局指令注册与页面渲染
前端·javascript·vue.js
有颜有货5 小时前
PMC生产排产的4种算法,一次讲清
java·服务器·前端
小虎牙0075 小时前
Android kotlin图片库Coil源码详解
android·前端
随风一样自由5 小时前
【前端领域】前端开发核心应用场景与落地实践
前端·前端框架
an317426 小时前
弹窗数据流设计的两种高阶架构实践
前端·vue.js·架构
谢尔登6 小时前
【React】 状态管理方案
前端·react.js·前端框架
用户2136610035726 小时前
Vue商品详情与放大镜组件
前端·javascript
半个落月6 小时前
从Tapas小Demo理清localStorage、事件与this
前端·javascript
李明卫杭州7 小时前
Vue2 中 v-model 处理不同数据结构的技巧
前端·javascript·vue.js