Skip to content

Three.js 复杂场景交互体验攻坚:拾取加速 + 相机碰撞约束

在高密度、多模型、大场景的 Three.js 应用中,“能渲染”往往不等于“能交互”。交互体验的两大杀手通常来自:

  • 拾取卡顿:Raycaster 全量遍历与高精度相交测试导致主线程抖动
  • 相机穿模:控制器缺乏碰撞约束与空间边界,导致视角失控

本文给出一套闭环方案:先统一口径与度量,再用加速结构优化拾取,用碰撞体约束相机,最后以验收指标确认收益。

配套阅读(统一指标口径与阈值):/thoughts/threejs/threejs-performance-panel-core-metrics-analysis

一、问题定义:两类体验故障

1.1 拾取卡顿(Raycaster)

  • 典型表现:鼠标移动/点击时出现明显掉帧、延迟,尤其在重叠模型与万级对象时更明显
  • 主要原因:候选集过大(遍历/过滤成本高)+ 单次相交测试过重(高面数几何体)

1.2 相机穿模(OrbitControls)

  • 典型表现:相机穿过墙体/地面;近距离旋转时视角抖动、漂移;推近后无法稳定观察
  • 主要原因:控制器没有“不可进入空间”的约束;参数边界缺失(最小距离、俯仰角等)

二、目标与验收:做成可交付的闭环

建议把交互优化的验收拆成三类指标:

  • 体验:交互操作下 FPS/帧时间分位稳定,输入响应无明显延迟
  • 性能:拾取单次耗时可控(例如 p95<几毫秒),相机碰撞检测可控
  • 功能:拾取命中正确、相机不穿模、关键交互规则不被破坏

三、总体架构:空间加速 + 碰撞约束 + 控制器治理

  • 空间层:减少拾取候选集(空间索引)或减少相交测试成本(BVH)
  • 物理层:构建轻量化碰撞体,不用渲染网格做碰撞
  • 控制层:控制器参数边界 + 碰撞处理逻辑 + 交互节流

四、放在底座还是放在项目:边界建议(推荐分层)

这套方案要做到可复用与可交付,建议做成“底座提供能力 + 项目提供策略”的分层架构:底座负责通用机制与接口,项目负责语义、规则与取舍。

模块/能力更适合放在底座引擎更适合放在项目层
拾取(Picking)PickManager(节流、过滤、候选集生成、命中结果结构)、可观测(耗时/候选规模/相交次数)pickable 规则、命中优先级、业务选择/高亮/联动
加速结构BVH/空间索引抽象与更新机制、开关与配置加载分区粒度与更新时机(楼层/房间/区域)、动态对象策略
碰撞(Camera Collision)CollisionWorld/Collider 体系、碰撞查询 API、控制器扩展点哪些物体可碰撞/可穿透(门/玻璃)、禁入区与业务边界
参数治理控制器参数模板与档位体系不同项目的镜头模式、交互手感与客户偏好

四、模块 1:拾取加速(先减候选,再做精确相交)

拾取加速有两类思路,分别解决不同瓶颈:

  • 空间索引:把“全量遍历”变成“局部候选”
  • BVH:把“高成本相交”变成“对数级剪枝”

4.1 方案选择建议

场景特征优先选择原因
静态场景、模型面数大BVH(geometry 级)单 mesh 内相交加速明显
动态物体多、对象数量极多空间索引(对象级)先缩小候选集更关键
复杂场景通用策略空间索引筛候选 + BVH 精确相交对象规模与几何规模两头都压

4.2 落地步骤(空间索引)

核心是建立“可拾取集合”与“索引更新机制”,避免把所有对象都纳入拾取。

bash
pnpm add three-octree

索引构建(示例):

js
import * as THREE from 'three'
import { Octree } from 'three-octree'

const octree = new Octree({
  bounds: new THREE.Box3(sceneMin, sceneMax),
  maxDepth: 8
})

scene.traverse((child) => {
  if (child.isMesh && child.userData?.pickable) {
    octree.insert(child)
  }
})

拾取改造(示例):

js
function pick(raycaster) {
  const candidates = octree.findRayIntersections(raycaster.ray)
  return raycaster.intersectObjects(candidates, false)
}

动态更新(关键约束):

  • 新增/删除可拾取对象:同步 insert/remove
  • 可拾取对象移动:按你的索引实现选择 update 或“删除后再插入”

4.3 落地步骤(BVH 精确相交)

若你的场景面数高、单 mesh 相交成本高,建议对可拾取 mesh 的 geometry 建 BVH,并替换 Raycaster 的相交逻辑。

bash
pnpm add three-mesh-bvh
js
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
import * as THREE from 'three'

THREE.Mesh.prototype.raycast = acceleratedRaycast

function enableBvh(mesh) {
  mesh.geometry.computeBoundsTree = computeBoundsTree
  mesh.geometry.disposeBoundsTree = disposeBoundsTree
  mesh.geometry.computeBoundsTree()
}

建议仅对 pickable 的静态 mesh 开启 BVH,避免在频繁变形几何上引入维护成本。

4.4 交互节流与过滤(低成本但收益很大)

  • 指针移动拾取节流:例如只在每帧或固定频率触发拾取
  • 只拾取可拾取层:使用 LayersuserData.pickable
  • 尽量减少 intersectObjects 深度遍历:只传候选集合

五、模块 2:碰撞体构建(碰撞只用“简化世界”)

相机碰撞检测不应直接使用渲染网格,建议构建独立的“碰撞世界”:

5.1 碰撞体的三种形态

  • 基础体:Box3/Sphere 等包围体,性能最好,精度一般
  • 简化网格:用低面数网格表达可碰撞表面,精度更高
  • 合并碰撞体:将大量小物体合并为少量碰撞体,减少检测对象数

5.2 关键规则

  • 碰撞体只参与计算,不进入渲染管线
  • 碰撞体要按“区域/楼层/房间”等维度组织,便于分区加载与更新

六、模块 3:相机碰撞约束(杜绝穿模与视角失控)

6.1 碰撞约束的基本逻辑

  • 以相机 target 为起点,向相机位置方向做碰撞检测
  • 若命中碰撞体,将相机位置推回到碰撞点之前(保留安全距离)
  • 每帧或每次控制器更新时执行,保证连续性

6.2 控制器参数边界(必须设定)

js
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.minDistance = 1
controls.maxDistance = 100
controls.maxPolarAngle = Math.PI / 2
controls.screenSpacePanning = false

建议把这些参数按“场景类型/镜头模式”配置化,避免不同项目靠人工反复调参。

七、闭环与验收:如何证明方案生效

7.1 采集建议(面板或埋点)

  • 拾取耗时:一次拾取从生成射线到得到命中结果的耗时(p95/p99)
  • 帧时间:交互期间 frame time 的 p95/p99(比 FPS 更能反映抖动)
  • 候选集规模:候选对象数量/相交测试次数(用于验证索引/BVH 是否真正生效)

7.2 验收清单(建议)

  • 万级对象场景:拾取无明显卡顿,操作连续性良好
  • 重叠区域:拾取命中稳定且耗时可控
  • 相机:不穿模、不掉进墙里;近距离推拉与旋转稳定

架构思考

复杂场景的交互体验并不是“再优化一点点性能”那么简单,而是把交互链路工程化:用空间结构控制复杂度,用独立碰撞世界控制语义,用可度量指标验证收益。只有把“拾取、相机、观测”三者做成可复用模块,方案才具备可规模化交付的能力。