资讯动态

移动端Three.js室内漫游:场景、操控与碰撞

发布时间:2026/9/20 9:23:15 来源:尧图企业网站定制
简介一份基于Three.js的WebGL室内漫游与导航功能源码面向移动端App、小程序及H5场景开发者。项目使用GLB模型完整实现了室内第一人称视角漫游、导航路径展示与交互后续可接入蓝牙定位实现实时室内导航适合需要快速搭建室内导航Demo或学习WebGL空间计算的中高级前端工程师。压缩包共54个文件以37个JavaScript业务代码为核心包含9个GLB三维模型另有HTML入口、CSS样式与贴图PNG等资源整体仅10.11MB结构简单便于移植与二次开发。已有3881人学习下载。通过该项目可掌握Three.js中GLTFLoader加载模型、OrbitControls/第一人称控制器、射线检测与导航路径绘制等关键技巧。源码目录划分清晰配合作者文章中对实现难点如模型适配、移动端手势处理的解析能极大降低入门门槛是实践WebGL室内应用的参考项目。1. 移动端 threejs 室内漫游的三件事场景预算、触控、通行判定用 threejs 在桌面端做室内漫游流程已经是模板级的加载一个 GLB 模型挂上 PointerLockControls绑上 WASD再加一个物理引擎或者 Raycaster 防止穿墙一个能看的 Demo 就出来了。但这套链路放到移动端几乎每一步都在断裂PointerLock 在 iOS Safari 上从来不是完整支持手机上没有物理键盘GPU 内存也扛不住整层楼带 PBR 贴图的模型。真正让“室内漫游-导航功能”在移动端落地要做的不是把桌面方案搬过去而是围绕手机浏览器的三个硬约束重新设计场景怎么瘦身、触控怎么映射、通行边界怎么判断。本文按这个顺序把能直接跑的代码和参数一起讲清楚。2. 室内场景搭建移动端优先的建模、合并与渲染配置2.1 用白盒房间先确定漫游边界与导航锚点不管最后用的是景园模型还是手工建模我都建议第一步先拿 BoxGeometry 把房间的通行空间搭出来。白盒的作用不是视觉预览而是把墙面位置、可走动区域、门洞位置这些信息提前固定下来后续替换成正式模型时碰撞体和导航路点已经有了可对照的坐标基准。一个 8 米乘 6 米、层高 3 米的白盒房间最小写法是这样// room.js — 白盒房间单位米 const ROOM_W 8, ROOM_D 6, ROOM_H 3; const wallMat new THREE.MeshLambertMaterial({ color: 0xcccccc }); const walls []; // 这个数组就是后面碰撞检测的对象集 function addWall(w, d, h, x, z) { const wall new THREE.Mesh( new THREE.BoxGeometry(w, h, d), wallMat ); wall.position.set(x, ROOM_H / 2, z); scene.add(wall); walls.push(wall); } addWall(ROOM_W, 0.2, ROOM_H, 0, -ROOM_D / 2); // 北墙 addWall(ROOM_W, 0.2, ROOM_H, 0, ROOM_D / 2); // 南墙 addWall(0.2, ROOM_D, ROOM_H, -ROOM_W / 2, 0); // 西墙 addWall(0.2, ROOM_D, ROOM_H, ROOM_W / 2, 0); // 东墙 const floor new THREE.Mesh( new THREE.PlaneGeometry(ROOM_W, ROOM_D), wallMat ); floor.rotation.x -Math.PI / 2; scene.add(floor);这段代码有两个移动端相关的细节一是墙体用BoxGeometry而不是平面因为射线检测需要物体有真实厚度平面只有单面从背面打过去的射线会直接穿过二是材质用了MeshLambertMaterial而不是MeshStandardMaterialLambert 的片元着色器只有漫反射计算没有 PBR 的 GGX 高光和菲涅尔项在低端安卓机上每一帧能省下不少 GPU 周期。把墙体引用存到walls数组里是必须养成的习惯后面导航和碰撞都要反复用到这个集合。如果你直接加载美术模型也一定要为每面墙单独添加不可见的碰撞盒不要让程序直接拿显示模型做射线检测——显示模型的顶点数动辄几万射线求交的开销会直逼渲染本身。2.2 加载 GLB 后合并几何体把 draw call 压到个位数白盒验证通过之后替换成真正的场景模型。移动端显卡的 draw call 承受能力通常只有桌面端的三分之一到五分之一而一个完整的客厅模型沙发、茶几、灯具、墙面、摆件随随便便就是几百个网格实例。常见做法是在导入后运行时合并几何体核心代码在 three.js 提供的BufferGeometryUtils// merge.js — 加载 GLB 后按材质分组合并 import { GLTFLoader } from three/addons/loaders/GLTFLoader.js; import { mergeGeometries } from three/addons/utils/BufferGeometryUtils.js; const loader new GLTFLoader(); const gltf await loader.loadAsync(/models/living_room.glb); const groups new Map(); // material - geometries[] gltf.scene.traverse((child) { if (!child.isMesh) return; if (child.material.transparent) return; // 透明物体保持独立避免渲染顺序错乱 child.updateWorldMatrix(true, false); const key child.material.id; if (!groups.has(key)) groups.set(key, []); groups.get(key).push( child.geometry.clone().applyMatrix4(child.matrixWorld) ); child.visible false; // 源网格先隐藏合并完再移除 }); for (const [matId, geos] of groups) { const merged mergeGeometries(geos); const mesh new THREE.Mesh(merged, gltf.scene.getObjectById(matId).material); scene.add(mesh); }合并后整层楼的网格实例数能从几百个降到个位数draw call 数量直接降一个数量级。但有两个边界必须知道一是不同贴图的网格不能合并进同一个 geometry否则 UV 会互相污染所以代码里按material.id分组二是透明材质不要参与合并透明物体在渲染管线里是按距离从远到近单独排序的一旦合并绘制顺序被打乱就会出穿帮。如果你不想在运行时做合并也可以用gltf-transform在构建期处理把合并结果直接烘焙进 GLB 文件。运行时合并对开发者友好模型源文件保持可编辑状态构建期合并省掉了用户首帧加载时的计算时间。2.3 渲染器配置和纹理的移动端上限场景搭建的最后一步是调整渲染器参数。桌面端的贪心配置在移动端必须收敛配置项桌面端习惯移动端建议理由setPixelRatiodevicePixelRatioMath.min(devicePixelRatio, 2)DPR 为 3 的旗舰机如果把像素数跑满光片元填充就能吃掉全部 GPU 余量shadowMap.enabledtruefalse 或 PCFSoft阴影在嵌入式 GPU 上往往是比几何体更大的单项开销纹理各向异性164只有地面斜视角看远处才需要高各向异性墙面完全用不上renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled false; renderer.outputColorSpace THREE.SRGBColorSpace;纹理尺寸方面我一般把 2048 作为最大值1024 作为主力档位。移动端浏览器的 WebGL 实现有内存配额限制超过限制会出现上下文丢失或加载失败。纹理尽量锁定 2 的幂1024、2048让 three.js 内部能走 Mipmap 加速路径地面和墙面的重复贴图建议直接用 512 分辨率的平铺纹理乘上 repeat 参数视觉上并不差但显存占用是 2048 贴图的十六分之一。3. 移动端操控实现摇杆、触摸转向与帧循环的配合3.1 为什么桌面端 PointerLock 方案在移动端不可用桌面端室内漫游的体验基础是PointerLockControls点击画布锁定鼠标指针浏览器持续派发移动事件玩家转动鼠标看方向按 WASD 移动。这套机制的隐含前提是有物理键盘和可锁定的指针。移动端浏览器对 Pointer Lock API 的兼容性参差不齐iOS Safari 至今不会真正把光标锁在页面上Android WebView 的行为更是各厂各异。移动端触控实现必须回到touchstart / touchmove / touchend原语自己完成摇杆和转向两个交互通道。另一个移动端独有的问题是页面手势和触摸事件的冲突。触摸滑动默认会触发页面滚动和双指缩放这需要在控制区域加touch-action: none并且在事件监听里主动调用preventDefault()。少了这一步玩家在屏幕上左右滑动时页面会跟着滚走室内漫游根本没法操作。3.2 左半屏摇杆移动指令的采集与归一化移动端射击游戏和漫游类应用里主流方案是“半屏摇杆”左半屏滑动控制位移方向右半屏滑动控制视角。相比固定摇杆按钮半屏方案的好处是玩家手指落到屏幕左侧任意位置就能立刻开始移动不需要先找按钮这在大屏手机上尤其重要。// joystick.js — 半屏虚拟摇杆只接管屏幕左半部分 class HalfScreenJoystick { constructor(area) { this.area area; this.active false; this.touchId -1; this.baseX 0; this.baseY 0; this.value { x: 0, y: 0 }; // 归一化到 -1..1 this.area.addEventListener(touchstart, (e) { const t e.changedTouches[0]; if (t.clientX window.innerWidth * 0.5) return; this.active true; this.touchId t.identifier; this.baseX t.clientX; this.baseY t.clientY; e.preventDefault(); }, { passive: false }); this.area.addEventListener(touchmove, (e) { if (!this.active) return; const t [...e.changedTouches].find(t t.identifier this.touchId); if (!t) return; const dx t.clientX - this.baseX; const dy t.clientY - this.baseY; const len Math.hypot(dx, dy); const deadzone 10; // 10px 内不产生移动防止手指轻微抖动 if (len deadzone) { this.value.x 0; this.value.y 0; return; } // 摇杆行程限定在 80px 内超过后方向保持数值饱和为 1 const clamped Math.min(len, 80) / 80; this.value.x (dx / len) * clamped; this.value.y (dy / len) * clamped; e.preventDefault(); }, { passive: false }); this.area.addEventListener(touchend, (e) { if ([...e.changedTouches].some(t t.identifier this.touchId)) { this.active false; this.value.x 0; this.value.y 0; this.touchId -1; } }); } }这里有两个很容易踩的坑。第一touchstart的监听必须传{ passive: false }否则浏览器会忽略preventDefault()摇杆滑动还会触发页面滚动。第二单点触控下Right 半屏转视角和 Left 半屏摇杆各有一根手指touchmove里必须通过identifier找到属于自己的那根手指。如果用changedTouches[0]取第一根手指当一个手指先抬起、另一个滑动时摇杆值就会跳变。3.3 右半屏视角转向与移动朝向的合成右半屏的逻辑更简单水平滑动量转换成相机的 yaw 角变化不做垂直方向的俯仰限制也行但室内场景一般把 pitch 控制在正负 75 度以内避免翻转到头顶产生眩晕// look.js — 右半屏转向 const look { yaw: 0, pitch: 0 }; let lookStartX 0, lookStartY 0; let lookActive false, lookTouchId -1; document.addEventListener(touchstart, (e) { const t e.changedTouches[0]; if (t.clientX window.innerWidth * 0.5) return; lookActive true; lookTouchId t.identifier; lookStartX t.clientX; lookStartY t.clientY; e.preventDefault(); }, { passive: false }); document.addEventListener(touchmove, (e) { if (!lookActive) return; const t [...e.changedTouches].find(t t.identifier lookTouchId); if (!t) return; look.yaw - (t.clientX - lookStartX) * 0.006; look.pitch - (t.clientY - lookStartY) * 0.004; look.pitch Math.max(-Math.PI / 3, Math.min(Math.PI / 3, look.pitch)); lookStartX t.clientX; lookStartY t.clientY; }, { passive: false });灵敏度0.006 rad/px是经验值表示手指在屏幕上滑动 1 像素相机转 0.006 弧度。这个值在 iPad 和手机上应该不同——大屏设备滑动同样的角度需要更大的手指位移建议做成参数放在设置面板里让玩家自行调节。位移合成放在每一帧的更新循环里执行function updateMovement(dt) { // 把相机的欧拉角转成四元数再用于旋转位移向量 const euler new THREE.Euler(look.pitch, look.yaw, 0, YXZ); camera.quaternion.setFromEuler(euler); const move new THREE.Vector3(joystick.value.x, 0, joystick.value.y); move.applyQuaternion(camera.quaternion); move.y 0; // 去掉垂直分量防止上仰时把人带飞 if (move.lengthSq() 0) move.normalize(); const vel 3.2; // 步行速度 m/s const delta move.multiplyScalar(vel * dt); applyCollisionAndMove(delta); // 第 4 章实现 }头部move.y 0是个容易漏掉的细节。如果不处理玩家抬头看天花板时点击前进位移向量会带上向下的分量人物会往地里钻或者跳起来。先应用四元数让摇杆方向和相机朝向对齐再把 Y 轴清零人物的移动方向就只会落在水平面上。4. 碰撞检测与室内导航射线采样、滑动贴墙与路点寻路4.1 移动端可行碰撞方案对比室内漫游的碰撞需求其实很窄只需要回答一个问题以玩家为圆心、半径约 0.3 米的圆柱按当前帧的位移推进后是否与场景碰撞体相交。在移动端我基本不挂物理引擎方案精度性能开销适合场景完整物理引擎Ammo.js / cannon-es高高刚体同步要维护需要物品拾取、敲门、碰撞反弹等完整物理Raycaster 射线采样中极低几条射线即可纯漫游和移动导航最常见的做法NavMesh 专有碰撞体高中需要离线烘焙大规模复杂场景路网已经建立射线采样的思路是不检测整圈包围体而是朝移动方向的左侧、右侧各发一条射线每条射线加上一个碰撞半径的偏移。只要三条射线都没被阻挡就允许移动被挡了就尝试做滑动。4.2 用 Raycaster 做防穿墙与滑动贴墙// collision.js — 射线采样 轴向拆分滑动 const PLAYER_RADIUS 0.3; function canPass(position, direction, distance, walls) { const dir direction.clone().normalize(); const raycaster new THREE.Raycaster(position, dir, 0, distance); if (raycaster.intersectObjects(walls, false).length 0) return false; // 玩家是有体积的在左右两侧各偏移一个半径再发两条射线 const side new THREE.Vector3(0, 1, 0).cross(dir).normalize(); const left new THREE.Raycaster( position.clone().addScaledVector(side, PLAYER_RADIUS), dir, 0, distance ); const right new THREE.Raycaster( position.clone().addScaledVector(side, -PLAYER_RADIUS), dir, 0, distance ); return left.intersectObjects(walls, false).length 0 right.intersectObjects(walls, false).length 0; } function applyCollisionAndMove(delta) { // 拆分 X / Z 两个轴向试探移动产生“贴墙滑动”效果 const xStep new THREE.Vector3(delta.x, 0, 0); const zStep new THREE.Vector3(0, 0, delta.z); if (xStep.lengthSq() 0 canPass(camera.position, xStep, xStep.length(), walls)) { camera.position.add(xStep); } if (zStep.lengthSq() 0 canPass(camera.position, zStep, zStep.length(), walls)) { camera.position.add(zStep); } }轴向拆分的原理是如果完整位移被墙挡住就把位移拆成 X 和 Z 两个轴向量分别试探。比如玩家以 45 度角撞向一面墙X 方向被阻断Z 方向没有阻挡那么只执行 Z 方向位移玩家就会沿着墙滑过去视觉上非常自然这是 FPS 游戏里标准的碰撞滑动算法。如果不做拆分撞到墙以后整个 del 被丢弃玩家就死死地卡在墙边无法动弹体验会非常僵硬。4.3 室内导航的路点网络实现导航功能是室内漫游从“随便走”到“能导览”的关键。在没有完整 NavMesh 烘焙条件的移动端项目里路点网络Waypoint Graph是最稳妥的折中方案在走廊转角和房间门口放置几个透明的三维标记点记录相邻点之间的连接关系寻路时用 BFS 找出一条通顺的路。// nav.js — 路点定义与 BFS 寻路 const waypoints [ { id: 0, pos: new THREE.Vector3(-3, 0, 0), near: [1] }, { id: 1, pos: new THREE.Vector3( 0, 0, 0), near: [0, 2, 3] }, { id: 2, pos: new THREE.Vector3( 3, 0, 0), near: [1] }, { id: 3, pos: new THREE.Vector3( 0, 0, -2), near: [1, 4] }, { id: 4, pos: new THREE.Vector3( 0, 0, -4), near: [3] }, ]; function buildPath(startId, targetId) { const prev new Map(); const visited new Set([startId]); const queue [startId]; while (queue.length) { const cur queue.shift(); if (cur targetId) break; for (const nb of waypoints[cur].near) { if (!visited.has(nb)) { visited.add(nb); prev.set(nb, cur); queue.push(nb); } } } // 从 targetId 往回回溯生成完整路径 const path []; let p targetId; while (prev.has(p)) { path.unshift(p); p prev.get(p); } path.unshift(startId); return path; }BFS 找出的路径天然是“跳数最少”的但每个相邻路点之间必须是一条无障碍的直线这要求你在布置路点时用第 4.2 节的canPass验证每一条相邻边。路径点数量少于 30 个时BFS 的耗时在毫秒级以下对帧率完全无损。玩家点击导航目标后把整个路径存入状态逐段推进// follow.js — 路径跟随 let navPath []; let navIndex 1; let isNavigating false; function startNavigate(targetPos) { const startId nearestWaypoint(camera.position); const endId nearestWaypoint(targetPos); navPath buildPath(startId, endId); navIndex 1; isNavigating navPath.length 1; } function updateNavigation(dt) { if (!isNavigating) return; const target waypoints[navPath[navIndex]].pos; const toTarget target.clone().sub(camera.position); const dist toTarget.length(); if (dist 0.2) { navIndex; if (navIndex navPath.length) { isNavigating false; return; } return; } const step toTarget.normalize().multiplyScalar(3.0 * dt); if (canPass(camera.position, step, step.length(), walls)) { camera.position.add(step); } else { // 路被挡说明路点网络有 bug跳过一个路点继续避免卡死 navIndex; } }自动导航应该在收到用户触控输入时立即中断——摇杆推了 direction 或者右边手指转了视角就设isNavigating false。如果用户方向导航边手控操作会出现角色来回拉扯。这个状态机只有三个状态IDLE、NAVIGATING、INTERRUPTEDINTERRUPTED 状态下用户松手后保持当前姿态。导航途中的相机朝向不用强制转向保留用户当前的观察方向玩家可以在自动走动的同时自由环视周围环境。如果产品需求是“导览模式”则可以单独写一个转向逻辑把 camera 缓慢地 lerp 到前进方向但 lerp 的系数要控制在每帧不超过 0.05否则快速转角时画面会甩动得让人头晕。5. 真机调优从 renderer.info、降级策略到触摸延迟5.1 用 renderer.info 判断瓶颈在渲染还是内存不要只看帧率帧率下降时你并不知道瓶颈在哪。three.js 在renderer.info里暴露了底层统计数据定时采样后再决定优化方向let frame 0; function render() { requestAnimationFrame(render); frame; if (frame % 120 0) { const info renderer.info; console.log({ calls: info.render.calls, // draw call 数量 triangles: info.render.triangles, // 三角形数 textures: info.memory.textures, // 纹理数量 geometries: info.memory.geometries // 几何体数量 }); } renderer.render(scene, camera); }一般判断逻辑是这样的calls如果超过 300说明场景还需要回到第 2 章的几何体合并思路textures如果超过 50 张考虑做纹理图集把多张小图并到一张大图里geometries超过 100可能是合并过程中把同类几何体漏掉了。三角形数量反而是移动端最不容易成为瓶颈的指标现在的 SoC 对三角形填充的吞吐量远大于对 draw call 的调度能力。5.2 降级策略用 URL 参数控制画质档位真机测试时不同机型的表现差距可以到三倍以上。我的做法是在 URL 后挂一个level参数完成降级切换const level new URLSearchParams(location.search).get(level) || high; if (level low) { renderer.setPixelRatio(1); renderer.shadowMap.enabled false; lowModelRef.visible true; // 替代方案低模模型 highModelRef.visible false; }低端策略会自动切到像素比 1、关闭阴影同时切换一套低模模型。如果项目不允许预置两套模型退而求其次的做法是在运行时降低渲染分辨率setSize改成实际像素的一半再通过 CSS 拉伸到全屏画质会有明显模糊但帧率能稳定返回。5.3 排查“WebGL 初始化失败”与触摸延迟移动端浏览器上经常出现 WebGL context 初始化失败的问题典型报错是 “the browser supports WebGL, but initialization failed”。常见原因是前一页的 WebGL 上下文没有释放新的页面拿不到 GPU 配额。使用路由切换的 SPA 项目要在页面卸载时调用renderer.dispose()并把canvas从 DOM 里移除。我一般会写一个全局资源释放函数遍历场景里所有 mesh释放 geometry 和 material 的 GPU 缓存再调forceContextLoss()确保 WebView 回到干净状态。至于触摸延迟iOS Safari 在touchmove上施加了默认的 300ms 延迟多点触控时尤其明显。除了 CSS 里给整个页面加touch-action: none还可以在touchmove事件里手动标记时间戳记录最近一次触摸移动的时间差和相机实际转动角度用来校准灵敏度。代码上用一个简单公式sensitivity desiredDegrees / averageSwipePixels在开发面板里实时输出这个值就能在不同设备上快速找到合适的转向参数。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价