nVisual 3D 第一人称漫游实战:Three.js 的 FPS 移动、逐轴碰撞与自动巡航
项目背景:nVisual 网络基础设施可视化平台的数据中心 3D 场景,支持机柜、房间、桥架等海量模型的第一人称漫游(WASD 移动 + 鼠标环视 + 点击传送 + 开关机柜门)。
关键词:Three.js、第一人称漫游、Pointer Lock、逐轴碰撞解析、贴墙滑动、球体 vs AABB、漫游状态机、Raycaster
摘要
第一人称漫游是 3D 场景的"临场感"功能,也是物理与交互细节的密集区:nVisual 的机房漫游包含 FPS 式移动、碰撞阻挡、贴墙滑动、自动巡航、点击传送、开关机柜门等一整套交互。
本文基于 nVisual 线上运行的组件完整拆解这套系统的实现:
- FPS 控制:Pointer Lock 的完整状态机(含浏览器安全限制的绕坑)、YXZ 欧拉角环视、帧率无关的指数阻尼速度模型;
- 碰撞检测:球体 vs 机柜
Box3的包围盒索引,以及逐轴解析这一"贴墙滑动"的关键技巧; - 自动巡航:
moving → blocked → turning状态机,射线探测 + 多角度扫描评分转向; - 交互细节:点击传送的射线设计(为什么必须用有限地板网格而不是无限数学平面)、机柜门开关与设备惰加载、资源释放与相机持久化。
一、整体架构:一个自包含的第一人称场景
第一人称场景组件职责边界非常清晰:
View (Vue 组件)
├── 数据层:API 直接取图数据(vis_nodes / vis_links),不经任何中间引擎
├── 场景层:THREE.Scene + 自建 WebGLRenderer + PerspectiveCamera
│ ├── initFloor 地板 + 阴影接收面 + 地板背景贴图
│ ├── initLights 环境光 + 平行光(带阴影)+ 半球光
│ ├── setupEnvironment PMREMGenerator 渐变环境贴图(金属机柜的反射来源)
│ ├── buildMeshesFromData 节点 → Three.js 网格(机柜/建筑/桥架/shape)
│ └── buildLinks 链路 → TubeGeometry 线缆 / OBJ 桥架
├── 物理层:updateFpsMovement + 逐轴碰撞 + clampPosition
├── 巡航层:roamUpdate 状态机(moving / blocked / turning)
├── 交互层:Pointer Lock、门开关、点击传送、滚轮 FOV、编辑模式
└── 生命周期:initFirstPerson / destroyFirstPerson(资源全量回收)
几个关键设计决策:
- 渲染循环自持:
renderer.setAnimationLoop是唯一的驱动源,移动、碰撞、门动画全部挂在循环里,没有外部定时器; - 碰撞索引与渲染解耦:碰撞不遍历场景图,而是用一份预构建的
Box3数组(只在建场景/开门加载设备后刷新); - 相机是唯一的状态源:移动、环视、传送、保存视角,全部直接读写
camera.position / quaternion,没有封装额外的 Player 实体。
为什么不引入 Cannon.js 之类的物理引擎?因为需求只有"水平面行走 + 阻挡 + 滑动",没有刚体、重力、堆叠。自建 100 行碰撞代码换来零依赖、可控手感,这个取舍在本案例中是完全正确的。
二、FPS 控制:Pointer Lock 状态机与环视
2.1 Pointer Lock 的完整生命周期
第一人称环视依赖浏览器的 Pointer Lock API。它有一堆易踩的坑,组件用一组状态变量完整建模:
| 状态 | 含义 | 解决的问题 |
|---|---|---|
pointerLocked | 当前是否锁定 | 判断 FPS 逻辑是否激活 |
pointerLockPending | 已请求、事件未回调 | 请求是异步的,窗口期仍要渲染移动 |
pointerLockCooldown | ESC 退出后的 50ms 冷却 | 浏览器抛出 SecurityError(解锁后 1 秒内禁止再次请求锁定的安全策略);加上冷却避免连点 |
doorToggleTime | 最近一次点门时间 | 点门开关后 400ms 内,canvas 的 click 不重新进入锁定 |
关键代码:
requestPointerLock() {
if (performance.now() - this.doorToggleTime < 400) return // 点门后不误锁
this.hintDismissed = true
this.pointerLockPending = true
const canvas = this.renderer?.domElement
if (canvas && canvas.requestPointerLock) {
canvas.requestPointerLock().catch(() => { this.pointerLockPending = false })
} else {
this.pointerLockPending = false
}
}
ESC 退出的清理放在了 pointerlockchange 回调里,而不是 keydown:
this.pointerlockchangeHandler = () => {
const wasLocked = this.pointerLocked
this.pointerLocked = document.pointerLockElement !== null
this.pointerLockPending = false
if (wasLocked && !this.pointerLocked) {
// ESC 退出 — 浏览器拦截 keydown,这里补做清理
this.roaming = false; this.roamState = 'moving'
this.roamTurnTarget = null; this.roamCommitDist = 0
this.moveTarget = null
this.pointerLockCooldown = true
this.hintDismissed = false
setTimeout(() => { this.pointerLockCooldown = false }, 50)
}
}
为什么:浏览器对 ESC 的处理优先于页面——keydown 根本收不到 ESC,等发现时移动标志位还挂着,相机就会"幽灵漂移"。所以清理逻辑必须挂在指针锁状态变化的回调上。
另外还有两个细节:按 WASD 但未锁定时自动触发锁定(降低操作门槛);窗口失焦(blur)时 resetAllKeys() 清空全部按键标志,避免切窗口回来相机自己走。
2.2 环视:YXZ 欧拉角 + 俯仰钳制
const LOOK_SPEED = 0.0015
onMouseMoveForLook(e) {
const dx = e.movementX || 0
const dy = e.movementY || 0
if (dx === 0 && dy === 0) return
this.euler.setFromQuaternion(this.camera.quaternion)
this.euler.y -= dx * LOOK_SPEED
this.euler.x -= dy * LOOK_SPEED
// 俯仰钳制,防止翻转到"倒立"视角
this.euler.x = Math.max(-Math.PI / 2.1, Math.min(Math.PI / 2.1, this.euler.x))
this.camera.quaternion.setFromEuler(this.euler)
}
要点:
- 欧拉角顺序必须用
'YXZ'(初始化new THREE.Euler(0, 0, 0, 'YXZ')):先偏航(yaw)后俯仰(pitch),FPS 的标准做法。用默认'XYZ'顺序会导致偏航轴被俯仰污染,出现"歪头"现象; - 组件常驻一个
euler实例复用,而不是每帧 new(巡航状态机也要读它); - 俯仰钳制在
±π/2.1而非±π/2:留一点余量避免万向锁附近的数值抖动。
2.3 速度模型:增量速度 + 指数阻尼
updateFpsMovement(time) {
const delta = Math.min((time - this.prevTime) / 1000, 0.1) // 帧率无关 + 防切页大跳
this.prevTime = time
const speed = MOVE_SPEED * delta // MOVE_SPEED = 200
// 由相机四元数推导"前"与"右",水平化后归一
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.camera.quaternion)
forward.y = 0; forward.normalize()
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.camera.quaternion)
right.y = 0; right.normalize()
// 输入合成方向并累加速度
const dx = Number(this.moveRight) - Number(this.moveLeft)
const dz = Number(this.moveForward) - Number(this.moveBackward)
const dy = Number(this.moveUp) - Number(this.moveDown)
if (dx !== 0 || dz !== 0) {
const dir = new THREE.Vector3().addScaledVector(right, dx).addScaledVector(forward, dz).normalize()
this.velocity.addScaledVector(dir, speed)
}
if (dy !== 0) this.velocity.y += dy * speed
// 指数阻尼:帧率无关的"惯性"
this.velocity.multiplyScalar(Math.pow(0.1, delta))
// …碰撞检测(见第四节)…
this.camera.position.add(this.velocity)
this.velocity.set(0, 0, 0)
}
这个速度模型值得展开讲:
- 不是"位置 += 方向 × 速度",而是"速度 += 输入 → 阻尼 → 位置 += 速度"。好处是加速和减速都有过渡,起步不瞬移、松键有滑行惯性,手感接近 FPS 游戏;
- 阻尼系数
0.1^delta是帧率无关的:每帧乘0.1^delta,等价于连续时间下v(t) = v0 · 0.1^t——60fps 下每秒衰减到 10%,120fps 下也是每秒衰减到 10%,刷新率不同手感一致; - 稳态速度有解析解:每帧
v = (v + s) × d(s = 200·delta,d = 0.1^delta),平衡时v∞ = s·d/(1-d)。60fps 时 d≈0.9623,代入得v∞ ≈ 85单位/秒,约 0.4 秒达到稳态的 90%——加速曲线就是这么被两个常数"调"出来的; delta钳制到 0.1:切换标签页后time跳变,若不钳制,回来的第一帧会按几十秒的 delta 计算,速度爆炸、相机飞出场景。钳制后最多按 100ms 补一帧;- 斜向移动(D+W)先合成方向再归一,斜走速度与直走一致,不会出现"对角线加速"的经典 bug。
三、碰撞几何:球体 vs 机柜包围盒
3.1 障碍物索引:只索引机柜
// 构建障碍物包围盒索引(只对机柜 nodeTypeTypeGroup===8 做碰撞)
// 机柜内设备是开门时惰加载的,跳过以保证索引结果与加载时机无关(且它们本就在机柜体积内)
_buildObstacleIndex() {
this._obstacleBBoxes = []
Object.entries(this.meshMap).forEach(([id, g]) => {
const entry = this.nodeMap[id]
if (!entry || entry.item?.nodeTypeTypeGroup !== 8) return
g.traverse(c => {
if (!c.isMesh || c.name === 'floor') return
if (this._isInsideDevice(c)) return
const bbox = new THREE.Box3().setFromObject(c)
if (!bbox.isEmpty()) this._obstacleBBoxes.push(bbox)
})
})
}
设计选择及原因:
- 只把机柜当障碍:地板、线缆、桥架在真实机房里本就可以"跨过/穿过",全部参与碰撞反而让人寸步难行;
- 机柜的每个子 mesh 单独建盒(门、侧板、立柱各一个
Box3),而不是整个机柜一个大盒——机柜门开着时,门的位置是变化的,分开的盒子更贴合实际遮挡; - 跳过机柜内的设备 mesh(
_isInsideDevice沿 parent 链向上找:设备是开门后才惰加载的,如果参与索引,障碍集就与"是否开过门"相关,行为不可复现;且设备本来就在机柜体积内,跳过不影响正确性; - 索引在建场景完成后构建一次,不逐帧重建。
3.2 碰撞查询:球体 vs 包围盒数组
// 球体碰撞检测:pos 为相机中心,radius 为碰撞半径
_sphereCollides(pos, radius) {
const sphere = new THREE.Sphere(pos, radius)
for (const bbox of this._obstacleBBoxes) {
if (bbox.intersectsSphere(sphere)) return true
}
return false
}
Box3.intersectsSphere 内部正是经典的"最近点距离 vs 半径"判定(把球心 clamp 到盒内取最近点,比较距离平方与半径平方),直接用 Three.js 现成实现,无需手写。
参数:碰撞球半径 COLLISION_RADIUS = 10,球心就是相机位置。这个半径不是"人的腰围",而是工程调参结果——太小会擦边穿透、穿进门缝;太大会在狭窄过道里卡住。10 是配合机柜间距(约 40~60 单位)调出来的舒适值。
没有 Y 轴碰撞:垂直移动(Space/C 升降)不做检测,由
clampPosition兜底(见第五节)。这是有意的简化——机房是平面场景,不存在楼梯和楼层。
四、逐轴解析:贴墙滑动的关键技巧
碰撞检测只能回答"是否碰撞",如何响应才是手感的分水岭。nVisual 用的是逐轴解析(per-axis resolution):
// 碰撞检测:逐轴解析,实现贴墙滑动
if (this.velocity.x !== 0 || this.velocity.z !== 0) {
const COLLISION_RADIUS = 10
const testPos = this.camera.position.clone()
// 试 X 轴
testPos.x += this.velocity.x
if (this._sphereCollides(testPos, COLLISION_RADIUS)) {
this.velocity.x = 0
}
// 试 Z 轴(含已通过的 X 位移)
testPos.copy(this.camera.position).add(this.velocity)
if (this._sphereCollides(testPos, COLLISION_RADIUS)) {
this.velocity.z = 0
}
}
this.camera.position.add(this.velocity)
4.1 三种响应方案对比
| 方案 | 行为 | 手感 |
|---|---|---|
| 整体拒绝:整个速度向量碰撞则全部清零 | 斜着撞墙时原地卡死,必须垂直转向才能走 | ❌ 很差 |
| 整体投影:把速度投影到碰撞面切线 | 正确,但需要求碰撞法线,对多盒体复杂 | 好,实现重 |
| 逐轴解析:X、Z 分量独立检测 | 垂直于墙的分量被清、平行分量保留 → 自然贴墙滑动 | ✅ 好,实现轻 |
4.2 几何直觉
斜向撞墙时,速度可以分解为"指向墙的分量"和"沿墙的分量":
墙
┌────────────┐
│ │
│ v_z(被清零,垂直于墙)↕
│ │
└────────────┘
← v_x(保留,沿墙滑动)
玩家 ──→ 合速度方向
逐轴解析不需要知道墙的法线:先只叠加 X 分量测一次,撞了就说明 X 方向被墙挡住,清零;再测 Z(此时已包含通过的 X 位移)。两个轴都试完后,剩下的速度就是"不会撞墙的合法移动"。
细节:第二次检测用的是"相机位置 + 完整速度"(含已通过的 X),而不是"相机位置 + 仅 Z 分量"——这样保证最终提交的位置确实无碰撞(如果只测 Z 分量,可能出现 X 通过后 Z 恰好撞上的漏检)。
4.3 局限(诚实交代)
- X 先于 Z 的检测顺序让滑动行为有微小方向偏差,极端斜角下可能偏向某轴;
- 垂直方向没有碰撞(见 3.2),升降可能穿进桥架;
- 这是离散检测:速度过大时理论上可能穿透薄障碍(本项目机柜盒厚、稳态速度 85 单位/秒 vs 柜厚数百单位,不存在此问题;速度更快的场景需要扫掠检测/CCD)。
五、边界保护与点击传送
5.1 地板边界钳制
clampPosition() {
if (!this.floorBounds) return
const p = this.camera.position
p.x = Math.max(this.floorBounds.minX, Math.min(this.floorBounds.maxX, p.x))
p.z = Math.max(this.floorBounds.minZ, Math.min(this.floorBounds.maxZ, p.z))
p.y = Math.max(2, p.y) // 高度下限:不会掉到地板下面
}
floorBounds 在地板尺寸上外扩 10%(expand = 0.1),适应贴边布局;Y 轴钳到 2 作为"地面"。注意边界钳制发生在碰撞检测之后——碰撞解决速度、钳制解决范围,职责分离。
5.2 点击传送:为什么用有限地板网格
锁定时点击地面,准星处的地板交点成为目标,相机平滑 lerp 过去:
this.moveTarget = targetPos
// 每帧:
const dist = this.camera.position.distanceTo(this.moveTarget)
if (dist < 0.3) { this.camera.position.copy(this.moveTarget); this.moveTarget = null }
else { this.camera.position.lerp(this.moveTarget, 0.12) }
传送点的射线检测藏着一个只有踩过坑才会写的细节:
// 用有限的 floorMesh 而非无限数学平面:后者只要射线略向下就必定有交点,
// 瞄准天空/地板外背景时交点落在极远处,再被 clampPosition 拉到边界 → "点天空也瞬移"
getCenterFloorIntersection() {
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(new THREE.Vector2(0, 0), this.camera)
// 地板与障碍物一起检测(结果按距离升序):首个命中不是地板说明准星落在机柜/门等物体上
const hits = raycaster.intersectObjects([this.floorMesh, ...this.getRoamObstacles()], false)
if (hits.length === 0 || hits[0].object !== this.floorMesh) return null
const p = hits[0].point
return new THREE.Vector3(p.x, this.camera.position.y, p.z)
}
两个要点:
- 射线必须与地板 mesh 求交,而不是与 y=0 的数学平面求交:数学平面无限大,朝天空看也会在身后极远处"命中",目标被钳制到边界——表现为"点哪都瞬移到地图边缘";
- 地板与障碍物一起检测,且要求最近命中是地板:准星指着机柜时,最近命中是机柜,直接返回 null 不传送,避免"点机柜瞬移到柜子背后"。
六、自动巡航:射线驱动的三态状态机
按 Enter 进入巡航:自动向前走,遇障自动转向,在机房里"逛"起来。状态机:
┌────────────── moving:直走,射线探测前方
│ fwdDist < 35 或 前方出界?
│ │ 是
│ ▼
│ blocked:多角度扫描评分
│ │ 选定转向角
│ ▼
└──────── turning:原地转向 → 到位
│
└─→ moving(带 80 单位"免检测距离")
6.1 moving:射线探测
const STOP_DIST = 35
const fwdDist = this.castRoamRay(origin, forward)
if (fwdDist < STOP_DIST || outOfBounds) { this.roamState = 'blocked'; return }
this.velocity.addScaledVector(forward, ROAM_SPEED * delta)
castRoamRay 用 Raycaster 对全部障碍 mesh 求交,返回最近距离。距离小于 35 单位(约半台机柜)就停,而不是撞上才停——留出转向空间。
6.2 blocked:扫描 + 评分转向
const scanAngles = [-2.356, -1.571, -0.785, 0.785, 1.571, 2.356, 3.142] // -135° ~ 180°
let bestAngle = 3.142 // 默认掉头
let bestScore = -Infinity
for (const a of scanAngles) {
const dir = forward.clone().multiplyScalar(Math.cos(a))
.addScaledVector(right, Math.sin(a)).normalize()
const dist = this.castRoamRay(origin, dir)
if (this.isHeadingOutOfBounds(dir, Math.min(dist, 150))) continue // 排除会出界的方向
const score = dist - Math.abs(a) * 20 // 距离越远越好,转角越小越好
if (score > bestScore) { bestScore = score; bestAngle = a }
}
this.roamTurnTarget = this.euler.y - bestAngle
this.roamState = 'turning'
评分的妙处在于双目标平衡:
dist:这个方向上的可视距离——看得越远越"开阔",越值得走;-|a| × 20:惩罚大转角——同样开阔时优先小转向,避免原地打转;- 7 个候选角度覆盖 -135° 到 180°,每个都做一次射线检测,选分最高者。
6.3 turning:角度归一化插值
let diff = this.roamTurnTarget - this.euler.y
while (diff > Math.PI) diff -= Math.PI * 2 // 角度差归一化到 [-π, π]
while (diff < -Math.PI) diff += Math.PI * 2
const step = TURN_SPEED * delta
if (Math.abs(diff) < 0.05) {
this.euler.y = this.roamTurnTarget
this.camera.quaternion.setFromEuler(this.euler)
this.roamState = 'moving'
this.roamCommitDist = COMMIT_DIST // 80:转向后强制走一段,防贴墙循环
} else {
this.euler.y += Math.sign(diff) * Math.min(step, Math.abs(diff))
this.camera.quaternion.setFromEuler(this.euler)
}
两个防抖细节:
- 角度差归一化:转向目标与当前 yaw 都是"模 2π"的角度,直接相减可能差出 350°,必须 wrap 到
[-π, π]再按符号转向; roamCommitDist = 80:转向完成后,moving状态先强制直走 80 单位期间不检测障碍(见 6.1 开头if (this.roamCommitDist > 0)分支)。没有这个机制,机器人会"转向 → 走一步 → 又检测到墙 → 再转向",在角落里抖动循环。
手动按 WASD 会立即重置状态机退出巡航分支,回到手动速度模型——两者在 updateFpsMovement 里是并列分支,互不干扰。
七、机柜门交互:射线 + 惰加载
机柜门是漫游中最出彩的交互:准星对准门点击开门,首次开门时异步拉取机柜子图数据、渲染柜内设备。
// 开门:门 mesh 挂在 group.userData.doors 下,射线命中后找到 pivot
toggleDoor(doorPivot) {
toggleDoorPivot(doorPivot) // 翻转开门状态
if (!doorPivot.userData.isOpen) return // 关门不处理(设备保留,避免重复请求)
this.renderRackDevices(doorPivot) // 首次开门 → 惰加载柜内设备
}
async renderRackDevices(target) {
const rackGroup = findRackGroup(target) // 沿 parent 链找机柜 Group
if (!rackGroup) return
const ud = rackGroup.userData
if (!ud.item || !ud.dims || ud.devicesLoaded || ud.devicesLoading) return // 幂等
const { w, d, h } = ud.dims
await loadRackDevices(rackGroup, ud.item, w, d, h, this.textureManager, this.$store.state.diagramScale)
}
门动画独立于 Pointer Lock:每帧 updateDoorAnimations 推进所有门的 pivot 插值,即使玩家按 ESC 退出锁定,门也会完成开合——动画挂在渲染循环上而不是输入状态上。
配套的防冲突:doorToggleTime 记录点门时刻,400ms 内 canvas 的 click 不再触发 pointer lock(2.1 节),保证"点门"和"点画布重新锁定"互不误伤。
八、工程细节清单
- 资源回收:
destroyFirstPerson逐一移除 9 类事件监听、exitPointerLock()、断开ResizeObserver、释放 PMREMrenderTarget与几何体。切换场景时组件销毁重建,泄漏会在长时间使用中累积; - 自适应尺寸:
window.resize+ResizeObserver双监听——侧边栏折叠不会触发 window resize,必须观察容器; - 渲染配置:
logarithmicDepthBuffer: true(场景跨度大,防近处 z-fighting)、pixelRatio ≤ 2(高分屏性能兜底)、PCF 软阴影 + 平行光阴影盒覆盖全场景; - 滚轮 FOV 缩放:锁定状态下滚轮调
camera.fov(10°~120°),不移动相机; - 相机持久化:
saveCameraPosition把当前位置写回图表数据,下次进入场景在initThreeJS里恢复——漫游位置作为"视图配置"的一部分保存; - 性能:碰撞索引只建一次;
_sphereCollides线性遍历Box3数组(机柜数量级,百级),每帧开销可忽略;真正的渲染大头(柜内设备)靠开门惰加载摊薄。
九、踩坑与教训总结
| 坑 | 症状 | 解法 |
|---|---|---|
| 数学平面做传送射线 | 点天空瞬移到地图边缘 | 与有限地板 mesh 求交,并要求最近命中是地板 |
| 欧拉角默认顺序 | 偏航后视角"歪头" | Euler(0,0,0,'YXZ') |
| 角度直接相减 | 转向时绕远路/反向转 | wrap 到 [-π, π] |
| 无 commit 距离的巡航 | 角落抖动循环 | 转向后强制直走 80 单位 |
| ESC 在 keydown 里清理 | 退出锁定后相机幽灵漂移 | 在 pointerlockchange 里清理 |
| 解锁后立即重锁 | 浏览器 SecurityError | 50ms 冷却 + promise catch |
| 切标签页回来 | delta 爆炸,相机飞出 | delta 钳制 0.1 + blur 清按键 |
| 斜走速度叠加 | 对角线加速 | 合成方向后 normalize |
| 门点击与重锁冲突 | 点门后立刻进入锁定 | 400ms 门操作守卫 |
十、总结
这套第一人称系统的价值在于用极小的依赖面换取了完整的游戏手感:
- 相机即玩家:所有状态收敛到
camera.position/quaternion,移动、环视、传送、持久化共用一套坐标; - 增量速度 + 指数阻尼:两个常数(
MOVE_SPEED、0.1^delta)就调出了起步加速、松键滑行的 FPS 手感,且帧率无关; - 逐轴解析:约 15 行代码实现贴墙滑动,是对"碰撞响应"性价比最高的工程解;
- 状态机 + 射线:自动巡航不是路径规划,而是"感知(射线)→ 决策(评分)→ 执行(转向)"的简单闭环,却能在机房里稳定地逛起来;
- 细节即品质:点天空不瞬移、ESC 不漂移、开门不误锁、切页不飞出——漫游体验的差距全在这些边界处理里。
附录:最小可运行骨架
剥离业务后的核心骨架(移动 + 逐轴碰撞 + 贴墙滑动),可直接在任意 Three.js 场景中使用:
import * as THREE from 'three'
const MOVE_SPEED = 200
const COLLISION_RADIUS = 10
const state = {
velocity: new THREE.Vector3(),
euler: new THREE.Euler(0, 0, 0, 'YXZ'),
keys: {}, prevTime: 0,
obstacles: [], // THREE.Box3[] ,建场景后填充一次
floorBounds: { minX: -100, maxX: 1100, minZ: -100, maxZ: 1100 },
}
function buildObstacleIndex(rackGroups) {
state.obstacles = []
rackGroups.forEach(g => g.traverse(c => {
if (!c.isMesh) return
const bbox = new THREE.Box3().setFromObject(c)
if (!bbox.isEmpty()) state.obstacles.push(bbox)
}))
}
function sphereCollides(pos, radius) {
const sphere = new THREE.Sphere(pos, radius)
return state.obstacles.some(b => b.intersectsSphere(sphere))
}
function update(camera, time) {
const delta = Math.min((time - state.prevTime) / 1000, 0.1)
state.prevTime = time
const speed = MOVE_SPEED * delta
// 方向
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion)
forward.y = 0; forward.normalize()
const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion)
right.y = 0; right.normalize()
// 输入
const dx = Number(state.keys['KeyD']) - Number(state.keys['KeyA'])
const dz = Number(state.keys['KeyW']) - Number(state.keys['KeyS'])
if (dx !== 0 || dz !== 0) {
const dir = new THREE.Vector3().addScaledVector(right, dx).addScaledVector(forward, dz).normalize()
state.velocity.addScaledVector(dir, speed)
}
// 阻尼
state.velocity.multiplyScalar(Math.pow(0.1, delta))
// 逐轴碰撞解析(贴墙滑动)
const testPos = camera.position.clone()
testPos.x += state.velocity.x
if (sphereCollides(testPos, COLLISION_RADIUS)) state.velocity.x = 0
testPos.copy(camera.position).add(state.velocity)
if (sphereCollides(testPos, COLLISION_RADIUS)) state.velocity.z = 0
camera.position.add(state.velocity)
state.velocity.set(0, 0, 0)
// 边界钳制
const b = state.floorBounds
camera.position.x = Math.max(b.minX, Math.min(b.maxX, camera.position.x))
camera.position.z = Math.max(b.minZ, Math.min(b.maxZ, camera.position.z))
camera.position.y = Math.max(2, camera.position.y)
}
// 接入渲染循环
renderer.setAnimationLoop((time) => update(camera, time))
在此骨架上按第五节补 Pointer Lock 环视、按第六节补巡航状态机,即是完整版。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)