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

发布于:2025-07-13 ⋅ 阅读:(16) ⋅ 点赞:(0)

引言

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

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

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

基本概念和原理

  1. 相机控制系统

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

  // 相机(透视)
  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敌人感知)、动态遮挡剔除(性能优化)

	//射线由 起点(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)实现平滑过渡

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

实现过程

  1. 加载模型和胶囊把场景分解成一些节点 this.octree.fromGraphNode(this.modelObj)
    // 加载模型,并渲染到画布上
    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. 把胶囊体的位置传给网格对象,进行运动交互
  //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:碰撞的法线向量,可以理解为碰撞的方向
  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指针控制器+鼠标控制旋转视角

// 添加相机控件-指针
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.radius
    this.mesh.position.copy(end);
    this.camera.position.copy(end);
  }

效果图如下:
请添加图片描述

总结

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

参考

  1. 基于three.js实现第一人称的碰撞检测
  2. threejs官方fps示例

网站公告

今日签到

点亮在社区的每一天
去签到