
简介Three.js算法寻路示例是一份面向前端开发者的3D路径规划项目融合Three.js、Vue框架以及A*、BFS、DFS等常见寻路算法。资源从三维场景搭建到路径搜索实现均有完整代码帮助读者理解启发式搜索与图遍历在实际障碍环境中的性能差异适用于游戏开发、虚拟现实、交互式应用等场景。压缩包共71个文件大小约3.56MB包含大量用于场景贴图与界面元素的PNG、SVG、JPG图片以及负责逻辑的JS文件、构建界面的Vue组件和说明用的HTML、MD文档其中图片用于场景装饰与界面展示脚本与组件承载核心算法与交互逻辑目录结构清晰。已有778人学习下载适合希望将WebGL三维渲染与算法编程结合的开发者研读。通过运行示例并对比不同算法读者能掌握在3D空间布置障碍、计算最短路径及优化寻路效率的完整实现思路。1. three.js算法寻路示例为什么在三维场景里寻路不只是调一个库很多人第一次接触 three.js 寻路示例时会觉得这就是「把 2D 地图换成 3D 场景然后跑一遍 A*」。真动手才发现坑全在坐标系换算、障碍物数据提取和路径回填上。二维寻路面对的是.block数组三维场景里的障碍物是各种不规则几何体你要先回答「哪些地方能走」才能谈「怎么走」。这个示例要解决的正是从 three.js 场景到可寻路数据结构、再到 A* 算法与路径可视化的完整链路适合用 three.js 做室内导航、游戏 Demo、数字孪生巡检路线的开发者参考。我习惯把这类项目拆成四步场景网格化、地图数据生成、A* 寻路、路径回填与角色移动。前两步最容易被跳过也恰恰是整条链路里最容易出问题的地方。下面按这个顺序展开每一步都给出可以直接跑的代码和参数说明。2. 先选寻路图网格、导航网格还是关键点2.1 三种寻路图对比谁更适合这个示例寻路的第一步不是写算法而是决定「算法跑在什么数据结构上」。常见的做法有三种。第一种是网格图Grid Graph把场景拆成均匀格子每个格子标记为可走或不可走。优点是实现简单、调试直观、热更容易缺点是精度受格子大小限制角色走路径时会有一段一段的折线感。第二种是导航网格NavMesh用凸多边形覆盖可走区域路径质量和内存表现都好但构建和动态更新成本高three.js 里没有内置的 NavMesh 工具需要依赖较大体量的库。第三种是关键点图Waypoint Graph手工放置路径点并连线适合固定路线比如展厅讲解动线但不适合开放空间。从复现成本来看网格图最适合做「示例」——场景里放几个障碍物用 100 行代码就能把场景变成地图数据。这个示例选网格图不是因为它是万能解而是因为它能把寻路的核心逻辑完整暴露出来也让后面替换成 NavMesh 时能看清差异。方案数据量动态更新路径平滑度实现成本网格图中等容易一般低导航网格小困难好高关键点图很小取决于点位取决于布线低2.2 示例中的坐标系约定把 three.js 的 XZ 平面当成地图3D 场景默认是 Y 轴向上的人在地面行走时只关心 X 和 Z 两个轴。所以示例里把 XZ 平面当成「地图平面」Y 轴只负责抬高路径和角色。这个约定是整篇代码的基础你需要在一开始就想明白所有寻路计算都在二维空间进行只有渲染时才把坐标映射回三维。下面这段代码搭建了最基础的试验场一个地面、两个障碍物、一个起点标记和一个终点标记。我通常会把地面和障碍物参数集中放在一个配置对象里方便后面调格子大小和场景尺寸。// scene-setup.js const config { groundSize: 20, // 地面边长单位米 gridSize: 1, // 寻路格子边长单位米 obstacles: [ // 障碍物在地面坐标系的中心位置和尺寸 { x: -3, z: -2, w: 3, d: 2 }, { x: 4, z: 1, w: 2, d: 4 } ] }; const scene new THREE.Scene(); scene.background new THREE.Color(0xf0f0f0); // 地面用 GridHelper 辅助对齐再用 PlaneGeometry 做物理边界 const gridHelper new THREE.GridHelper(config.groundSize, config.groundSize / config.gridSize); scene.add(gridHelper); const groundMat new THREE.MeshStandardMaterial({ color: 0xdddddd, side: THREE.DoubleSide }); const ground new THREE.Mesh( new THREE.PlaneGeometry(config.groundSize, config.groundSize), groundMat ); ground.rotation.x -Math.PI / 2; ground.position.y 0; scene.add(ground); // 障碍物按配置生成立方体y 方向高度仅影响视觉效果 config.obstacles.forEach((ob) { const box new THREE.Mesh( new THREE.BoxGeometry(ob.w, 1, ob.d), new THREE.MeshStandardMaterial({ color: 0xcc4444 }) ); box.position.set(ob.x, 0.5, ob.z); scene.add(box); });这段代码里的config对象是后面所有逻辑的数据源头地面尺寸决定数组行列数障碍物位置决定哪些格子不可走。ground.rotation.x -Math.PI / 2把平面从竖直转为水平这是 three.js 里地面常用的处理方式。GridHelper只是视觉辅助真正的寻路数据不依赖它。这样一个场景跑起来后你看到的只是一个普通的三维空间但它的逻辑结构已经具备可走的平面区域明确、障碍物坐标明确。下一步要做的就是把视觉世界翻译成寻路算法能直接使用的二维数组。3. 搭试验场把 three.js 场景转成寻路用的二维地图3.1 网格化过程从连续坐标到离散格子寻路算法不认识「米」和「厘米」它只认识「格子」。网格化要解决两个问题一是把地面划成多少行多少列二是每个格子对应的三维坐标范围是多少。示例里地面是 20x20格子边长是 1所以地图是 20 行 x 20 列数组下标从 0 到 19。这里有一个容易犯迷糊的地方three.js 的世界坐标原点在地面正中心所以 X、Z 的范围是 -10 到 10而数组下标是从 0 开始的。把世界坐标转成数组下标需要做一次偏移和取整反过来把格子下标转回世界坐标时要加上半个格子偏移让路径点落在格子中心而不是格子角上。// grid-map.js function worldToGrid(wx, wz, config) { const half config.groundSize / 2; const col Math.floor((wx half) / config.gridSize); const row Math.floor((wz half) / config.gridSize); return { row, col }; } function gridToWorld(row, col, config) { const half config.groundSize / 2; const x col * config.gridSize - half config.gridSize / 2; const z row * config.gridSize - half config.gridSize / 2; return { x, z }; } function buildMap(config) { const size config.groundSize / config.gridSize; const map []; for (let r 0; r size; r) { map.push(new Array(size).fill(0)); // 0 表示可走 } config.obstacles.forEach((ob) { const minX ob.x - ob.w / 2; const maxX ob.x ob.w / 2; const minZ ob.z - ob.d / 2; const maxZ ob.z ob.d / 2; for (let r 0; r size; r) { for (let c 0; c size; c) { const { x, z } gridToWorld(r, c, config); if (x minX x maxX z minZ z maxZ) { map[r][c] 1; // 1 表示不可走 } } } }); return map; }worldToGrid和gridToWorld是一对反函数调试时经常要用它们互推验证。buildMap里判断格子是否被障碍物覆盖时用的是「格子中心点是否在障碍物矩形内」的逻辑。这样做速度快但如果格子边长比障碍物尺寸大很多会出现障碍物边缘格子被误标的情况后续章节会专门讲这个避坑点。3.2 用 Raycaster 检查真实障碍物让地图数据不靠手算手写障碍物配置在前期的可行性验证阶段足够用但真实场景里你往往拿不到干净的坐标列表只有一堆 three.js 网格对象。这时候就不能再手动枚举障碍物了我一般用Raycaster从场景里动态探测网格化和发射线检测配合把「视觉上的碰撞体」直接变成「地图上的障碍格」。// generate-map-by-raycast.js function detectObstacles(scene, config, height 1) { const size config.groundSize / config.gridSize; const map []; const raycaster new THREE.Raycaster(); const down new THREE.Vector3(0, -1, 0); for (let r 0; r size; r) { const rowData []; for (let c 0; c size; c) { const { x, z } gridToWorld(r, c, config); const origin new THREE.Vector3(x, height, z); raycaster.set(origin, down); const hits raycaster.intersectObjects(scene.children, false); const blocked hits.some((hit) hit.object ! groundRef); rowData.push(blocked ? 1 : 0); } map.push(rowData); } return map; }Raycaster从每个格子中心的正上方往下打一条射线如果射线先撞到的东西不是地面就认为这个格子被障碍物挡住了。height参数决定射线起点距离地面的高度示例里取 1 米对应障碍物高度的一半。注意groundRef需要是你保存的地面对象引用否则射线会把地面本身也算成障碍物。这个方法比手写配置慢每次生成地图要对每个格子做一次射线检测20x20 的网格是 400 次体感不明显如果网格细到 0.1 米200x200 就是 4 万次会明显卡顿。常见做法是场景加载和障碍物变化时才调用一次而不是每帧都跑。4. A* 在 three.js 坐标系里落地二维数组上的核心算法4.1 从起点到终点的 A* 实现OpenList、代价与启发函数地图数据准备好后A* 算法本身并不复杂。它维护两个关键集合已探索的格子closedSet待探索的格子openList。每个格子记录三个值从起点走到这里的实际代价g这个格子到终点的估算代价h两者之和f。每次从openList里取出f最小的格子继续扩展直到命中终点。下面是我在这个示例里用的实现刻意不用外部库方便你看清每一步// astar-path.js function findPath(map, start, end) { const rows map.length; const cols map[0].length; const openList []; const closed new Set(); const cameFrom new Map(); const gScore {}; const fScore {}; const key (r, c) ${r},${c}; const startKey key(start.row, start.col); const endKey key(end.row, end.col); gScore[startKey] 0; fScore[startKey] heuristic(start, end); openList.push({ ...start, g: 0, f: fScore[startKey] }); while (openList.length 0) { openList.sort((a, b) a.f - b.f); const current openList.shift(); const curKey key(current.row, current.col); if (curKey endKey) { return reconstructPath(cameFrom, endKey); } closed.add(curKey); for (const neighbor of getNeighbors(current, map)) { const nKey key(neighbor.row, neighbor.col); if (closed.has(nKey) || map[neighbor.row][neighbor.col] 1) continue; const tentativeG current.g 1; if (tentativeG (gScore[nKey] ?? Infinity)) { cameFrom.set(nKey, curKey); gScore[nKey] tentativeG; fScore[nKey] tentativeG heuristic(neighbor, end); openList.push({ ...neighbor, g: tentativeG, f: fScore[nKey] }); } } } return null; // 无可行路径 } function heuristic(a, b) { // 曼哈顿距离适合四方向移动 return Math.abs(a.row - b.row) Math.abs(a.col - b.col); } function getNeighbors(node, map) { const dirs [[-1, 0], [1, 0], [0, -1], [0, 1]]; const result []; for (const [dr, dc] of dirs) { const nr node.row dr; const nc node.col dc; if (nr 0 nr map.length nc 0 nc map[0].length) { result.push({ row: nr, col: nc }); } } return result; }这段代码的代价计算很关键每一步的实际代价g固定 1因为当前只允许上下左右四个方向移动如果未来放开八个方向代价就要改成直走 1、斜走 1.414heuristic也要同步换成欧几里得距离或切比雪夫距离。openList.sort每轮都排序在示例规模下没问题地图特别大时应该改用优先队列否则性能会随节点数上升明显劣化。4.2 四方向还是八方向路径质量与安全性的取舍这个示例默认只做四方向也就是角色只能沿 X 轴或 Z 轴方向移动。这样路径永远是横平竖直的安全性高不会出现斜穿障碍物角落的情况代价是路径长度偏长、视觉上不够自然。八方向允许斜着走路径更短、更直但引入了一个经典问题斜穿墙角。比如一个拐角处的障碍格它的右上角格子理论上可以从左上角斜着走到但角色实际移动时可能会贴着障碍物边缘擦过去物理上不合理。常见做法是在八方向寻路时加一个「拐角检查」如果当前要向斜方向走先确认相邻的正方向格子是否可走如果两个正方向都被挡住就不允许斜走。实现时可以把getNeighbors换成下面的逻辑function getNeighbors8(node, map) { const rows map.length; const cols map[0].length; const dirs [ [-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [-1, 1], [1, -1], [1, 1] ]; const result []; for (const [dr, dc] of dirs) { const nr node.row dr; const nc node.col dc; if (nr 0 || nr rows || nc 0 || nc cols) continue; if (map[nr][nc] 1) continue; // 斜向时检查相邻正方向是否存在障碍物防止穿墙 if (dr ! 0 dc ! 0) { if (map[node.row dr][node.col] 1 || map[node.row][node.col dc] 1) { continue; } } result.push({ row: nr, col: nc }); } return result; }这里斜向判定用的是「两个正方向都要可走」的保守策略。用了getNeighbors8之后heuristic也应该从曼哈顿距离换成欧几里得距离不然 A* 会倾向于先走正方向再走斜方向搜索效率降低。实际调试时可以在四方向和八方向之间来回切换对比路径走向和长度差异你会更直观地理解这两种模式的代价模型。5. 路径回填与角色移动把寻路结果画进三维场景5.1 坐标回填数组下标重新变回 three.js 世界坐标A* 返回的是一串{ row, col }格子下标这些下标还不能直接丢给 three.js 渲染必须经过gridToWorld转换回世界坐标。常见的坑是忘记加 0.5 格偏移导致路径点落在格子边角角色走在边界上时会和障碍物产生视觉穿插。// apply-path.js function buildPathPoints(path, config, height 0.5) { if (!path) return []; return path.map(({ row, col }) { const { x, z } gridToWorld(row, col, config); return new THREE.Vector3(x, height, z); }); }height参数把路径抬高到地面上方 0.5 米这个值需要和角色模型的大小匹配。比如角色是一个半径 0.3 米的圆柱体重心抬高 0.5 米就正好让圆柱底部贴地如果角色换成更高的模型这个值要相应调整。拿到路径点数组后我通常再用THREE.CatmullRomCurve3做一次曲线插值把折线变成平滑曲线。这样角色移动时不会在每个格子中心点突然转向视觉上自然很多。需要说明的是曲线平滑只改变视觉路径不会改变寻路结果实际碰撞检测仍然以格子为准。// smooth-path.js function createSmoothLine(points, segmentsPerPoint 8) { const curve new THREE.CatmullRomCurve3(points, false, catmullrom, 0.5); const curvePoints curve.getPoints(points.length * segmentsPerPoint); const geometry new THREE.BufferGeometry().setFromPoints(curvePoints); return new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0x2255ff })); }CatmullRomCurve3的第三个参数0.5是张力系数数值越大曲线越紧贴原始折线越小越松弛。如果设置的张力太后路径会切穿障碍物角落张力不足则会出现多余的绕弯。这里没有完美数值只能用一两个简单障碍物场景反复验证曲线没有超出可走区域。5.2 角色沿路径移动时间参数与速度控制路径画出来只是静态展示要让示例「活」起来还需要让角色从起点移动到终点。常见的做法是用curve.getPointAt(t)获取曲线上某一位置坐标其中t从 0 变化到 1而每帧t的增长速度由角色移动速度和路径总长度决定。// move-along-path.js let t 0; const speed 2; // 单位米/秒 function updateMove(delta, curve, robot) { const totalLength curve.getLength(); const segment (speed * delta) / totalLength; t Math.min(t segment, 1); const pos curve.getPointAt(t); robot.position.copy(pos); // 让角色朝向路径方向需要先采样下一帧位置 const lookAhead curve.getPointAt(Math.min(t 0.01, 1)); robot.lookAt(lookAhead); }delta是 requestAnimationFrame 里计算出的上一帧耗时直接用它乘速度能让移动速度与帧率无关。curve.getPointAt是基于弧长均匀采样不会出现曲线拐弯处移动过快或过慢的问题这一点比getPoint(t)更适合移动控制。让角色lookAt下一点是为了让它始终面朝路径方向如果角色模型的方向轴不是 Z 轴正方向你可能需要再旋转 180 度或 90 度。6. three.js 寻路示例里躲不开的避坑与排查6.1 格子坐标与世界坐标偏移了半个格子现象起点标记和终点标记都放在格子中心但角色实际走的路径明显偏向格子的某一侧沿着障碍物边缘擦过去。原因worldToGrid用Math.floor取整把世界坐标映射到了格子的左下角索引但gridToWorld直接按col * gridSize计算没有加半格偏移导致同一个世界坐标和格子坐标之间往返映射相差半个格子。解决统一使用本文里的gridToWorld带 gridSize / 2的版本。调试时可以写几个断言比如验证gridToWorld(0, 0)的结果在worldToGrid之后仍然回到(0, 0)这一致性。6.2 对角线路径穿过障碍物墙角现象使用八方向寻路后路径在障碍物拐角处斜着穿过角色看起来擦着墙角过去。原因只检查了目标格子是否可走没有检查斜向移动时两侧的格子。这是一个非常典型的翻车点几乎每个第一次做八方向的人都遇到过。解决在斜向移动时强制要求两个正方向邻居都可行走。也就是把getNeighbors8里的拐角检查补上。如果你是先写完了findPath才发现这个问题不要侥幸加检查后再重新生成路径。6.3 地图数据比视觉场景更小或更大现象地图边界和地面不完全对齐角色走到地图边缘时悬空或陷进地面。原因buildMap里的格子中心点判断在障碍物贴着地图边缘时会把地图之外的区域也算成可行走区域或者在地面边缘用 Raycaster 检测时射线被地面之外的环境误伤。解决buildMap遍历时把边界判断加上确保col和row只在有效索引范围内。另 一种做法是把网格范围缩小半个格子让最外围格子中心始终落在场景地面积以内。6.4 角色移动时在路径转折点抖动现象角色到达某个路径点后转向时会出现轻微抖动或停顿尤其在用CatmullRomCurve3平滑后的路径上更明显。原因时间参数t按弧长均匀推进但lookAt采样点在曲线曲率大的地方跨越了过大的角度导致模型瞬间旋转幅度超出预期看起来像抖动。如果你用的是普通直线插值而不是弧长插值这种情况更容易出现。解决一是给lookAt加一个旋转插值不直接硬切朝向而是用lerp或四元数slerp做平滑过渡二是检查路径点是不是存在几乎重合的重复点去重后再生成曲线。7. 让示例进阶动态障碍、平滑路线与验证方法7.1 动态障碍物触发的局部重寻路静态地图跑通之后下一步一般是让障碍物可以移动比如巡检场景里临时出现的车辆。常见做法是保留场景里所有动态障碍物对象每帧或每隔几帧更新它们在网格地图上的标记如果发现角色当前路径上的某个格子被占据就重新执行一次findPath。实现上不必全图重扫——我一般只在障碍物位置变化时把它新旧两个位置所占的格子做增量更新。地图数组里预先存一个occupiedBy字段记录当前占用者是谁这样移除和添加剧烈运动时不容易误清他人的格子。要注意的是重寻路次数不能太频繁路径代价大时它可以成为性能瓶颈至少限制到每秒检测两次。7.2 验证路径质量的三个指标路径能跑通不代表路径合格。我通常在把示例交给下一个环节之前做三件验证的事。第一检查路径是否与所有障碍物边界保持至少一个格子的安全距离这一步用脚标遍历路径点即可。第二计算路径实际长度与曼哈顿距离的比值在无障碍场景里这个比值应该接近 1偏离过大多半是启发函数出了问题。第三在角色移动过程中持续做射线检测确认角色模型每一帧都没有与障碍物相交这比静态检查更能发现平滑曲线穿模。function validatePath(path, map) { if (!path) return false; for (let i 0; i path.length; i) { const cell path[i]; if (map[cell.row][cell.col] 1) return false; } return true; }这一步看起来朴素但它往往是最后一个能拦住问题的手段。很多路径在生成时是合法的却因为平滑处理或角色尺寸比格子大实际移动时发生了穿插。7.3 最后的实战习惯我做过几个 three.js 寻路类的演示项目踩得最多的不是 A* 本身而是坐标系和数据映射这类「看起来简单、错起来难查」的地方。现在的习惯是每写一步转换函数都要配一个断言函数绝不靠肉眼去验证坐标对不对。网格尺寸越大系统看起来越流畅但它掩盖的路径抖动越严重尺寸越小路径越精细但性能和内存会成倍上涨。建议先从相对粗的格子启动再逐步加密直到路径开起来自然为止。希望这篇 three.js 算法寻路示例的拆解能帮你少走几段弯路回头可以把自己的验证和坑经验也在评论区里讲出来互相补盲。本文还有配套的精品资源点击获取