基于three.js的室内路径规划实战:从网格坐标到A*寻路
简介这是一份基于 Three.js 和 WebGL 技术的室内路径规划功能示例主要面向前端三维可视化开发者、WebGL 初学者以及需要实现室内导航交互的工程人员。项目在已有路径与节点数据的基础上通过 Dijkstra 算法完成室内地图上的点选起终点与最短路径计算能够帮助使用者快速理解三维场景下的导航实现流程。资源包共十八个文件以十五个 JavaScript 脚本为主体涵盖场景搭建、路由算法、交互控制等模块另含两个 HTML 页面和一张 PNG 图片压缩后大小仅两百九十三KB整体轻量且便于阅读调试。目前该资源已有五千五百七十人学习下载代码结构清晰、运行环境简单特别适合用来对照学习 Three.js 场景创建、节点数据处理和最短路径算法的前端落地方式借助这份示例可以快速搭建属于自己的室内路径规划演示程序为进一步扩展多楼层导航或可视化分析提供基础。1. 室内路径规划demo的难点不在算法而在坐标系统一先抛一个反直觉的结论用three.js做室内路径规划demo真正耗时的地方不是A*寻路本身而是把「网格坐标」「世界坐标」「视觉坐标」三套坐标系对齐。很多人在二维网格上跑通BFS一接到three.js场景里就发现路径穿墙、角色飘在空中原因只有一个——网格的row/col和THREE.Vector3的映射没做对。室内路径规划demo解决的是这样一个问题给定一张建筑平面图或楼层草图需要让一个虚拟角色从A点移动到B点并且不穿过墙体、不走回头路。这里的核心不是「最短路径」而是「可行走路径的表示方式」。three.js正好适合做这件事因为它的场景树、材质系统和射线拾取Raycaster能直接支撑「点选起点终点→计算路径→渲染路径→角色沿路径移动」的完整闭环。这个demo适合两类读者一类是准备在Web端做数字孪生、智慧园区、展馆导览的工程师另一类是学习three.js但不想只停留在旋转立方体的新手。前者需要的是一个能扩展成生产级系统的骨架后者需要一个能塞进作品集的可交互项目。下面按「场景建模 → 网格寻路 → 路径可视化 → 角色运动与调试」的顺序展开每一章都会给出可复现的代码。2. 用二维网格数据驱动three.js室内场景墙体和可行走区域的坐标映射2.1 为什么路径规划的数据结构必须是二维网格而不是three.js的三维物体室内路径规划最常用的数据表示是栅格地图Grid Map因为A*、BFS、JPS这些算法都建立在离散网格上。three.js里的Mesh是连续空间概念没法直接跑寻路。所以正确做法是先用一个二维数组描述楼层平面再根据这个数组生成three.js场景。这样做的另一个好处是网格数据和可视化层完全解耦后续如果服务端下发真实房源数据只需要改数据源场景渲染代码不用动。网格编码规则通常是这样// 0 空地1 墙体2 出发区3 目标区 const layout [ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 1], [1, 0, 1, 1, 0, 1, 1, 1, 0, 1], [1, 0, 1, 2, 0, 0, 0, 1, 0, 1], [1, 0, 1, 0, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 1, 1, 0, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 0, 0, 1], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] ];这段数组的第3行第3列是出发区坐标2第2行第3列旁边是墙体。这里的行就是Z轴方向列就是X轴方向注意不是Y轴因为three.js默认Y轴向上。写代码时很容易把layout[y][x]和Vector3(x, y, z)搞混后面会专门讲这个坑。网格数据和三维场景的映射关系常见做法是每个格子生成一个BoxGeometryfunction buildSceneFromGrid(layout, cellSize, wallHeight) { const group new THREE.Group(); for (let row 0; row layout.length; row) { for (let col 0; col layout[row].length; col) { const cell layout[row][col]; if (cell 1) { const geometry new THREE.BoxGeometry(cellSize, wallHeight, cellSize); const material new THREE.MeshStandardMaterial({ color: 0x8a9bb5 }); const wall new THREE.Mesh(geometry, material); // 关键把网格的行列映射到场景的XZ平面 // 公式世界坐标X 列号 * 格宽世界坐标Z 行号 * 格宽 wall.position.set(col * cellSize, wallHeight / 2, row * cellSize); group.add(wall); } } } return group; }这里wall.position.z用的是row而不是col很多人第一次写就写反。之所以世界坐标Z对应网格行号是因为二维数组的索引顺序是“先行后列”而三维空间中我们习惯把“行”当成纵深方向Z轴。如果反过来写整个场景等于旋转了90度寻路结果看起来就是斜着走的。2.2 网格坐标与three.js世界坐标的双向转换是demo的地基寻路算法输入的是网格坐标row colthree.js渲染用的是世界坐标x y z。所以必须写两个转换函数一个是网格转世界一个是世界转网格。世界转网格主要用于鼠标点击时反算当前格子的位置这个后面还要和Raycaster配合。function gridToWorld(row, col, cellSize) { // 格子中心点偏移0.5个cellSize避免落在边界上 return { x: col * cellSize cellSize / 2, z: row * cellSize cellSize / 2 }; } function worldToGrid(worldX, worldZ, cellSize) { return { row: Math.floor(worldZ / cellSize), col: Math.floor(worldX / cellSize) }; }gridToWorld里加0.5个cellSize的偏移是因为格子的原点通常定义在左下角而角色站位在格子中心。不加这个偏移路径点会落在格子的边线上视觉上像贴着墙走。worldToGrid里的Math.floor是向下取整只要世界坐标在格子范围内都能正确反算出行列号。这里的cellSize建议统一用1简化调试。2.3 寻路网格的边界验证墙体不能只用来渲染还要参与碰撞检测生成场景后要走一步验证把layout里的所有墙体格子单独存一份作为寻路算法的障碍物列表。不能直接从场景中遍历Mesh来判断因为场景里还可能放装饰物。更保险的方案是写一个isWalkable(row, col)函数在算法执行前做越界检查function isWalkable(row, col) { // 1. 越界检查 if (row 0 || col 0 || row layout.length || col layout[0].length) { return false; } // 2. 墙体检查 if (layout[row][col] 1) { return false; } return true; }这段代码放在寻路算法的最外层调用每个节点扩展前先过一道门槛。路径规划demo里80%的诡异bug都出在越界和墙体误判上isWalkable写得越保守后面越省心。3. 在three.js项目里实现A*寻路算法并设计可复用的路径规划模块3.1 BFS能吃但室内路径规划demo建议直接从A*起步很多教程用BFS广度优先搜索做演示因为代码短。但室内路径规划的实际场景里BFS会遍历大量无关格子比如出发点和目标点在同一排BFS仍然会把整层楼都扫一遍。A*引入启发式代价h值优先朝目标方向扩展搜索效率明显更高。这里说的更高不是理论优越性而是在demo里表现为「点击墙面时路径计算几乎瞬间完成同时CPU不飙高」。A*算法的核心是维护两个集合openSet待访问节点和closedSet已访问节点。每次从openSet里取f值最小的节点f g h。g是起点到当前节点的实际代价h是当前节点到目标点的预估代价这里用曼哈顿距离因为室内路径只能横竖走不能斜穿墙体。下面给出一个完整的A*实现可以直接复制到three.js项目里用class AStar { constructor(grid) { this.grid grid; // 二维数组 } findPath(startRow, startCol, endRow, endCol) { const openList []; const closedSet new Set(); const cameFrom new Map(); const gScore new Map(); const fScore new Map(); const startKey ${startRow},${startCol}; const endKey ${endRow},${endCol}; const h (row, col) Math.abs(row - endRow) Math.abs(col - endCol); const getKey (row, col) ${row},${col}; gScore.set(startKey, 0); fScore.set(startKey, h(startRow, startCol)); openList.push({ row: startRow, col: startCol, f: fScore.get(startKey) }); // 优先队列用最小堆更合适但demo规模下线性遍历可接受 while (openList.length 0) { // 找出 f 值最小的节点 let currentIdx 0; for (let i 1; i openList.length; i) { if (openList[i].f openList[currentIdx].f) { currentIdx i; } } const current openList[currentIdx]; const currentKey getKey(current.row, current.col); // 到达终点回溯路径 if (current.row endRow current.col endCol) { return this.reconstructPath(cameFrom, current); } openList.splice(currentIdx, 1); closedSet.add(currentKey); // 四方向扩展上下左右 const directions [ { dr: -1, dc: 0 }, // 上Z轴负方向 { dr: 1, dc: 0 }, // 下Z轴正方向 { dr: 0, dc: -1 }, // 左X轴负方向 { dr: 0, dc: 1 } // 右X轴正方向 ]; for (const dir of directions) { const newRow current.row dir.dr; const newCol current.col dir.dc; // 跳过不可行走的格子 if (!this.isWalkable(newRow, newCol)) continue; // 跳过已经处理过的格子 if (closedSet.has(getKey(newRow, newCol))) continue; // g 值从起点到当前节点的代价 移动到相邻格子代价1 const tentativeG (gScore.get(currentKey) ?? Infinity) 1; const neighborKey getKey(newRow, newCol); if (tentativeG (gScore.get(neighborKey) ?? Infinity)) { cameFrom.set(neighborKey, { row: current.row, col: current.col }); gScore.set(neighborKey, tentativeG); const f tentativeG h(newRow, newCol); fScore.set(neighborKey, f); openList.push({ row: newRow, col: newCol, f }); } } } return null; // 无可行路径 } reconstructPath(cameFrom, current) { const path []; let node { row: current.row, col: current.col }; const key ${node.row},${node.col}; while (cameFrom.has(key)) { path.unshift(node); node cameFrom.get(key); } path.unshift(node); // 加入起点 return path; } isWalkable(row, col) { // 与第2章的网格验证逻辑一致 if (row 0 || col 0 || row this.grid.length || col this.grid[0].length) { return false; } return this.grid[row][col] 0 || this.grid[row][col] 2 || this.grid[row][col] 3; } }tentativeG的计算每次都从起点到当前节点的实际代价加1含义是移动一格耗散1。这个数值决定了路径对拐弯的敏感度。如果你想避免路径贴墙走可以把墙边格子的代价值提高比如斜贴墙的格子算2而不是1这样路径会自然往走廊中央靠——这个技巧在室内路径规划里叫「墙体膨胀」或「障碍物外扩」。曼哈顿距离用绝对值差求和比欧几里得距离更符合室内横平竖直的走法。如果用欧几里得距离做启发式A*可能在某些情况下给出斜穿墙角的路径虽然代码不会崩但视觉上会穿模。3.2 路径规划模块的接口设计不直接操作three.js对象只返回坐标序列路径规划模块应该是一个纯函数库不依赖THREE对象。这样设计有两个好处一是可以在Node.js环境里单独测试寻路逻辑二是未来换地图格式比如从GeoJSON读真实房源数据不用改算法代码。推荐的数据流是这样的// gridToWorld转换完成后生成路径点数组 function computePathInWorld(layout, startRow, startCol, endRow, endCol, cellSize) { const aStar new AStar(layout); const gridPath aStar.findPath(startRow, startCol, endRow, endCol); if (!gridPath) { return null; // 调用方处理无路径情况 } // 把网格路径转成世界坐标路径 const worldPath gridPath.map(point { const pos gridToWorld(point.row, point.col, cellSize); return new THREE.Vector3(pos.x, 0, pos.z); }); return worldPath; }这个函数的返回值是一个THREE.Vector3数组直接交给第4章的路径渲染模块使用。注意这里y轴上设置的是0实际渲染时可以根据楼层高度抬升。如果是多层建筑每层的y值不同路径应该在楼层切换处做垂直分段——这是进阶玩法后面在第5章展开。3.3 A*参数调整栅格细化、代价值、障碍物膨胀的边界栅格大小直接影响计算效率和路径平滑度。cellSize1时路径比较粗糙转向是直角弯放大到2或4时路径更平滑但会损失细节——太小的门洞可能直接被抹掉。室内场景建议cellSize保持在0.5到1.0之间具体看房间面积。障碍物膨胀是室内路径规划里一个常用且有效的处理手段。原理很简单把墙体的外圈一格也标记为不可通行这样路径点和墙壁之间天然留出安全距离。在A*开始前预处理网格function dilateObstacles(layout, radius) { const rows layout.length; const cols layout[0].length; const dilated layout.map(row [...row]); // 浅拷贝 for (let r 0; r rows; r) { for (let c 0; c cols; c) { if (layout[r][c] 1) { // 把周围 radius 格标记为不可通行 for (let dr -radius; dr radius; dr) { for (let dc -radius; dc radius; dc) { const nr r dr; const nc c dc; if (nr 0 nc 0 nr rows nc cols) { if (layout[nr][nc] 0) { dilated[nr][nc] 1; } } } } } } } return dilated; }dilateObstacles会在原网格上额外标记一圈墙体。radius1就是向外扩展1格radius2适合角色模型较大、需要更多余量的场景。注意这个操作会把原本緊贴墙的可行走区域也去掉所以起点和终点如果紧邻墙壁膨胀后可能落入障碍区。解决办法是先锚定起点终点的网格坐标膨胀完再验证一次isWalkable如果不可走就对起点终点做邻域搜索找最近的可走点。4. three.js中路径可视化Line线段、TubeGeometry管道和角色沿路径匀速运动4.1 用Line先画出粗糙路径再用CatmullRomCurve3做平滑拿到WorldPath的Vector3数组后第一步最简单的渲染方式是THREE.Line。它开销低、调试直观快速确认算法输出正确再考虑美观。function createPathLine(points, color 0x00ff88) { const geometry new THREE.BufferGeometry().setFromPoints(points); const material new THREE.LineBasicMaterial({ color }); return new THREE.Line(geometry, material); }setFromPoints返回的路径在拐角处是折线视觉太生硬。室内导航demo一般会做平滑处理把网格路径点作为控制点生成CatmullRom曲线。注意这里不能用BezierCurve3因为Bezier要求控制点数量固定为3或4而CatmullRom可以接受任意数量的点。function createSmoothPath(points) { const curve new THREE.CatmullRomCurve3(points, false, centripetal, 0.5); const curvePoints curve.getPoints(100); // 采样100个点 const geometry new THREE.BufferGeometry().setFromPoints(curvePoints); return { curve, geometry }; }centripetal模式比catmullrom模式更能抵抗点分布不均匀时的过冲现象。如果室内走廊很窄路径点间距较大catmullrom模式会在拐弯时向外凸出看起来像穿墙。centripetal模式的表现更稳定代价是曲线更贴近原始折线。0.5是张力参数值越大曲线越紧绷建议在0.3到0.7之间调。4.2 用TubeGeometry做路径管道视觉层次更清晰如果demo要给别人演示或放进作品集Line太单薄TubeGeometry才会有立体感。TubeGeometry围绕一条曲线生成管道直接看代码function createPathTube(curve, radius 0.08) { const tubularSegments Math.max(curve.getPoints(100).length, 64); const geometry new THREE.TubeGeometry(curve, tubularSegments, radius, 8, false); const material new THREE.MeshStandardMaterial({ color: 0x00ccff, emissive: 0x004466, // 轻微自发光让路径在暗色场景里更明显 transparent: true, opacity: 0.85 }); return new THREE.Mesh(geometry, material); }radius是管道半径0.08在cellSize1的室内场景里大约覆盖格子宽度的8%视觉上不会遮挡太多视野。tubularSegments是分段数分段越多管道越光滑但顶点数也线性增长。在demo里64段足够太高反而不好看因为分段过密会出现纹理摩尔纹。4.3 让角色沿路径移动位置插值、朝向旋转和行走状态机路径渲染完成后需要一个移动的物体来体现“导航”效果。常见做法是放一个简单的胶囊体或立方体代表角色然后用路径曲线驱动function moveAlongCurve(character, curve, speed, deltaTime) { // 角色当前行走的累计距离 if (!character.userData.isMoving) { character.userData.isMoving true; character.userData.distance 0; character.userData.pathLength curve.getLength(); } character.userData.distance speed * deltaTime; const t character.userData.distance / character.userData.pathLength; if (t 1) { // 到达终点 character.userData.isMoving false; const endPoint curve.getPointAt(1); character.position.copy(endPoint); return; } const point curve.getPointAt(t); character.position.copy(point); // 让角色朝向路径方向取当前附近两个采样点来算方向向量 const tangent curve.getTangentAt(Math.min(t 0.01, 1)); const lookTarget point.clone().add(tangent); character.lookAt(lookTarget); }speed参数建议使用「每秒钟走过的格子数」配合cellSize换算成真实距离。比如角色速度是2格/秒那么speed * cellSize就是真实的三维移动速度。getPointAt的参数是归一化距离0到1所以每次更新时用累积距离除以总长度。getTangentAt返回切线方向向量加在当前位置上得到一个「前方点」配合lookAt让角色朝向移动方向。这套实现里有个藏着的问题getPointAt和getTangentAt每帧都做曲线采样如果场景里同时有几十个角色移动性能会吃紧。对于demo一个角色完全够用但如果你要接类似「智能机器人常见的路径规划算法」的多智能体需求需要使用「先批量计算路径点、再按点移动」的方式每帧只做位置插值。5. 路径规划demo的交互闭环Raycaster点选地图、点击后重新规划、无路径状态提示5.1 点击地面确定起点和终点用Raycaster做射线拾取路径规划的交互方式有很多种最常见的是「一键随机起点终点」和「鼠标点击指定」。鼠标点击需要把屏幕坐标转换到世界坐标再通过射线检测落在哪块地面上。这里有一个关键点如果地面是用BoxGeometry一片片铺的射线会命中墙体需要把地面和墙体分到两个不同的层layer里或者用材质ID做过滤。推荐做法单独生成一个透明地面的PlaneGeometry只接收点击事件function createClickableFloor(scene, width, depth) { const geometry new THREE.PlaneGeometry(width, depth); const material new THREE.MeshStandardMaterial({ color: 0xffffff, transparent: true, opacity: 0 }); const floor new THREE.Mesh(geometry, material); floor.rotation.x -Math.PI / 2; // 转到水平方向 floor.position.set(width / 2, 0.01, depth / 2); // 略高于Y0避免和墙体z-fighting return floor; }这个透明Plane在渲染时几乎看不见但它占据了整个可行走区域鼠标点击只命中它不会误点墙体。它的y坐标设置在0.01而不是0是为了防止和地面网格出现深度冲突z-fighting。raycaster检测时用这个floor做唯一交点判断简单又可靠。监听点击事件后先用vector2归一化鼠标坐标再更新raycaster并做intersectObjectfunction handleClick(event, raycaster, camera, floor) { const mouse new THREE.Vector2(); mouse.x (event.clientX / window.innerWidth) * 2 - 1; mouse.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(mouse, camera); const intersects raycaster.intersectObject(floor); if (intersects.length 0) { const point intersects[0].point; // 把世界坐标转成网格坐标 const { row, col } worldToGrid(point.x, point.z, cellSize); // 交替设置起点和终点 handlePathRequest(row, col); } }mouse坐标换算的公式是固定的x方向从-1到1y方向从-1屏幕底部到1屏幕顶部。注意y需要取反因为浏览器窗口坐标系的y轴是向下的而three.js是向上的。这也是新手经常搞反的地方结果就是点击地面时选中了完全相反的位置。5.2 寻路请求的统一处理入口起点、终点、重新规划当用户点击一次设置起点点击第二次设置终点并触发寻路。第三次点击则重新设置起点。用一个简单的状态机来管理let startPoint null; let endPoint null; function handlePathRequest(row, col) { if (!isWalkable(row, col)) { showMessage(该位置不可通行请重新选择); return; } if (!startPoint) { startPoint { row, col }; // 在场景里放一个绿色Marker标记起点 renderMarker(row, col, 0x00ff00); } else if (!endPoint) { endPoint { row, col }; // 放红色Marker标记终点 renderMarker(row, col, 0xff0000); computeAndRenderPath(); // 重置状态下次点击重新开始 startPoint null; endPoint null; } }状态切换逻辑不复杂但要注意寻路结果的缓存。如果每次点击都重新计算A*算法本身开销不大室内网格几百个节点以内毫秒级完成但路径曲线的CatmullRomCurve3重新采样和TubeGeometry重建会比较慢。所以更好的做法是只在起点或终点变化时才重新生成路径管道角色移动过程中不重建路径只更新位置。5.3 无路径和不可达状态的可视化反馈低成本但很重要的demo完成度指标很多demo在A*返回null时什么都不做用户以为点了没反应。一个合格的路径规划demo必须处理无路径状态。比如在页面顶部弹一个提示条或者让相机做一个抖动动画。function computeAndRenderPath() { const worldPath computePathInWorld(layout, startPoint.row, startPoint.col, endPoint.row, endPoint.col); // 清理旧的路径和角色 clearPathObjects(); if (!worldPath) { // 渲染一个红色闪烁球在终点位置提示不可达 const warningPos gridToWorld(endPoint.row, endPoint.col, cellSize); const warningSphere new THREE.Mesh( new THREE.SphereGeometry(0.2, 16, 16), new THREE.MeshBasicMaterial({ color: 0xff3333 }) ); warningSphere.position.set(warningPos.x, 0.5, warningPos.z); scene.add(warningSphere); return; } // 生成平滑曲线和管道 const { curve } createSmoothPath(worldPath); const pathMesh createPathTube(curve, 0.08); scene.add(pathMesh); }网格坐标为不可走时红球出现在终点位置。这个反馈成本极低但对用户理解「哪里走不通」非常有帮助。红球直径0.2高度0.5悬在空中能清楚地标示目标点位置。这一步做完demo的完整度就达到了可以给别人演示的水平。6. 进阶调试路径贴边检测、坐标轴混淆自查和曲线穿模的三步修法这一步不是写新功能而是把前面实现里最不容易发现的三个坑的排查方法讲透。路径规划demo能不能经得起老手检视拼的就是对这三个坑的敏感度。第一个坑是坐标轴混淆。检查你自己的代码layout[row][col]在生成墙体时用的是position.set(col*cellSize, height/2, row*cellSize)在A*里又是neighborRow对应dr±1、neighborCol对应dc±1那转换函数worldToGrid是不是返回(z/cellSize, x/cellSize)如果任何一个环节把row和col的对应关系搞反路径就会在场景里横着走或者原地打转。最快速的自查方法是在场景原点正北方向放一个柱子然后设定路径「从(0,0)到(0,2)」看路径是否往正Z方向延伸。如果路径沿X方向走那映射肯定错了。第二个坑是曲线穿模。CatmullRomCurve3在连续多个紧邻路径点处容易过冲特别是「两堵墙之间刚好可以通过一个格子」的窄走廊场景。排查方法也很简单在曲线生成后做逐点采样每采一个点判断它在网格里是否可走。如果采样点落在墙体里说明曲线在墙内穿行。修复思路有两条一是改用样条插值模式下的centripetal参数并调小张力值二是把原始路径点的坐标往走廊中心方向内缩0.2个格子宽度人为拉开与墙体的距离。第三个坑是路径与地面之间的Y值关系。第2章把路径点y设为0但如果地面有厚度、墙体有高度路径管道会潜在地面以下视觉上像被截断。推荐的修法统一用一个FLOOR_OFFSET常量比如0.02路径点生成时y直接设为FLOOR_OFFSET同时把可视化的路径管道也维持在FLOOR_OFFSET高度这样无论怎么调地面厚度都不会导致路径沉底。另外角色移动时character.position的y要保持在角色模型臀位高度或胶囊体中心而不是紧贴地面否则行走姿态会陷入地板。验证一个室内路径规划demo的标准方法不是看路径动画绚不绚丽而是做这样三个固定的测试用例起点终点隔一堵墙、起点终点在同一走廊两端但中间有柱子、起点位于死胡同。第一个用例应给出不可达提示第二个用例应对柱子绕行一个标准直角第三个用例应从胡同口原路返回。把这三个用例跑通你的路径规划demo才算真正立住了。本文还有配套的精品资源点击获取