简介:一份基于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而不是MeshStandardMaterial,Lambert 的片元着色器只有漫反射计算,没有 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 渲染器配置和纹理的移动端上限
场景搭建的最后一步是调整渲染器参数。桌面端的贪心配置在移动端必须收敛:
| 配置项 | 桌面端习惯 | 移动端建议 | 理由 |
|---|---|---|---|
setPixelRatio | devicePixelRatio | Math.min(devicePixelRatio, 2) | DPR 为 3 的旗舰机如果把像素数跑满,光片元填充就能吃掉全部 GPU 余量 |
shadowMap.enabled | true | false 或 PCFSoft | 阴影在嵌入式 GPU 上往往是比几何体更大的单项开销 |
| 纹理各向异性 | 16 | 4 | 只有地面斜视角看远处才需要高各向异性,墙面完全用不上 |
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、INTERRUPTED,INTERRUPTED 状态下用户松手后保持当前姿态。
导航途中的相机朝向不用强制转向,保留用户当前的观察方向,玩家可以在自动走动的同时自由环视周围环境。如果产品需求是“导览模式”,则可以单独写一个转向逻辑,把 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,在开发面板里实时输出这个值,就能在不同设备上快速找到合适的转向参数。
本文还有配套的精品资源,点击获取