Cesium基于pathfinding.js室内路径规划实现思路及步骤
作者:lemoggy
😶 前面都是废话,有需要直接拉到底看完整代码。
目标:
单楼层、同一区域内路径规划
不穿墙
场景:室内、单楼层
起点 / 终点:Cesium 拾取
不需要复杂拓扑(楼梯、电梯)
一、方案选择
YUKA / NavMesh:构建与调试成本偏高
pathfinding.js:
API 简单
A* 开箱即用
非常适合室内最短路径
最终选择:二维网格 + A*。
二、整体思路
一句话概括:
把三维室内空间,抽象成一个二维可通行网格,用 A 算最短路,再映射回 Cesium 三维坐标*。
拆开来看,就是下面几个步骤:
选定规划平面(通常是楼层地面)
将平面划分为规则网格
标记障碍物(墙体、不可通行区域)
使用 pathfinding.js 计算路径
将路径点转换回 Cesium 世界坐标并可视化
三、网格化建模
Demo 中直接在经纬度空间内对室内可行走区域进行网格化,这在小范围室内场景下是完全可行的。
可行走区域定义(多边形)
在 Demo 中,室内可行走区域由一个不规则多边形定义:
let moveArea = [
{ longitude: 118.1799664, latitude: 39.6243076 },
{ longitude: 118.1799603, latitude: 39.6243600 },
...
];
该多边形本质上就是「室内地面轮廓」,在 Cesium 中也同步绘制成了一个 Polygon:
viewer.entities.add({
polygon: {
hierarchy: polygonPositions,
material: Cesium.Color.AQUAMARINE.withAlpha(0.5)
}
});
这样做有两个好处:
规划区域和可视区域完全一致
后续调试路径是否越界非常直观
网格尺寸与经纬度换算
通过 gridStep 控制网格精度:
gridStep: 0.000005
为了避免经纬度在不同纬度下比例失真,构造网格时做了一次修正:
const avgLat = (minLat + maxLat) / 2; const latFactor = Math.cos(Cesium.Math.toRadians(avgLat)); this.latStep = gridStep; this.lonStep = gridStep / latFactor;
实践中的经验值:0.3m ~ 0.5m 一个格子,在室内效果比较平衡。
点在多边形内判断(含洞)
本 Demo 没有直接使用 NavMesh,而是采用 Point In Polygon 的方式判断某个网格中心点是否可行走:
#isWalkable(lon, lat) {
const point = { longitude: lon, latitude: lat };
const inOuter = this.#pointInPolygon(point, this.walkableArea);
const inHole = this.holes.some(hole => this.#pointInPolygon(point, hole));
return inOuter && !inHole;
}
这样可以非常自然地支持:
不规则边界
室内中庭 / 禁行区域(holes)
常见做法是:
提前知道哪些区域是墙体
或从模型中提取墙体 Mesh 的包围盒
这一步不追求 100% 准确,只要保证不穿墙即可。
四、路径计算
路径计算部分交给 pathfinding.js:
初始化 Grid
import PF from 'pathfinding'; const grid = new PF.Grid(width, height, matrix);
起点 / 终点拾取与网格映射
在 Demo 中,起点和终点通过 Cesium 点击拾取:
const pos = viewer.scene.globe.pick(ray, viewer.scene);
随后将世界坐标映射到网格坐标:
x = Math.floor((lon - minLon) / lonStep); y = Math.floor((lat - minLat) / latStep);
const finder = new PF.AStarFinder(); const path = finder.findPath( startX, startY, endX, endY, grid.clone() );
路径平滑处理
A* 算法输出的是严格沿网格的折线路径:
const smoothPath = PF.Util.smoothenPath(this.grid, path);
平滑后的路径在视觉和实际行走逻辑上都会更自然。
得到的 path 是类似这样的数组:
[ [3, 5], [4, 5], [5, 5], [5, 6] ]
这是 网格坐标,不是 Cesium 世界坐标。
五、映射回 Cesium 世界坐标
网格 → 局部坐标
const x = originX + gridX * cellSize; const y = originY + gridY * cellSize;
这里的 origin 是网格左下角对应的世界坐标。
局部坐标 → Cesium 世界坐标
如果场景是局部坐标系:
const position = new Cesium.Cartesian3(x, y, height);
如果是地理坐标系,需要再做一次转换。
六、路径可视化
Demo 中加入了 debug 模式,用于辅助调试:
绿色半透明线框:可行走网格
蓝色路径:A* 原始路径
黄色路径:最终输出路径
new PathFinder(moveArea, [], {
viewer,
debug: true,
gridStep: 0.000005
});
常见方式是用 Polyline 或 Primitive:
viewer.entities.add({
polyline: {
positions: positionsArray,
width: 3,
material: Cesium.Color.CYAN
}
});
至此,一条完整的室内最短路径就出来了。
后续可以结合模型中添加楼梯口及楼梯转角节点然后在cesium中根据节点名称获取坐标来打通上下楼路径。
七、完整示例代码:
<!DOCTYPE html>
<html>
<head>
<script src="https://cesium.com/downloads/cesiumjs/releases/1.110/Build/Cesium/Cesium.js"></script>
<script type="text/javascript" src="js/pathfinding-bower-0.4.18/pathfinding-browser.min.js"></script>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.110/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
<style>
html,
body,
#cesiumContainer {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
#toolbar {
position: absolute;
top: 10px;
left: 10px;
z-index: 999;
background: rgba(42, 42, 42, 0.8);
padding: 10px;
border-radius: 5px;
}
button {
margin: 5px;
padding: 5px 10px;
}
</style>
</head>
<body>
<div id="toolbar">
<button id="reSet">重新绘制</button>
</div>
<div id="cesiumContainer" style="width: 100%; height: 100vh;"></div>
<script>
class PathFinder {
constructor(walkableArea, holes = [], options = {}) {
this.walkableArea = walkableArea;
this.holes = holes;
this.gridStep = this.gridStep = (typeof options.gridStep === 'number' && options.gridStep > 0)
? options.gridStep
: 0.00001;
this.upHeight = options.upHeight ?? 0.5;
this.debug = options.debug ?? false;
this.viewer = options.viewer ?? null;
if (this.debug && !this.viewer) {
console.warn('[PathFinder] 开启 debug 但未提供 viewer,调试图形将不会绘制');
}
this.#calculateBounds();
this.#buildGrid();
}
#calculateBounds() {
const longitudes = this.walkableArea.map(p => p.longitude);
const latitudes = this.walkableArea.map(p => p.latitude);
this.minLon = Math.min(...longitudes);
this.maxLon = Math.max(...longitudes);
this.minLat = Math.min(...latitudes);
this.maxLat = Math.max(...latitudes);
const avgLat = (this.minLat + this.maxLat) / 2;
const latFactor = Math.cos(Cesium.Math.toRadians(avgLat));
this.latStep = this.gridStep;
this.lonStep = this.gridStep / latFactor;
}
#pointInPolygon(point, polygon) {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const { longitude: xi, latitude: yi } = polygon[i];
const { longitude: xj, latitude: yj } = polygon[j];
const intersect = ((yi > point.latitude) !== (yj > point.latitude)) &&
(point.longitude < ((xj - xi) * (point.latitude - yi)) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
#isWalkable(lon, lat) {
const point = { longitude: lon, latitude: lat };
const inOuter = this.#pointInPolygon(point, this.walkableArea);
const inHole = this.holes.some(hole => this.#pointInPolygon(point, hole));
return inOuter && !inHole;
}
#buildGrid() {
this.gridWidth = Math.ceil((this.maxLon - this.minLon) / this.lonStep);
this.gridHeight = Math.ceil((this.maxLat - this.minLat) / this.latStep);
this.grid = new PF.Grid(this.gridWidth, this.gridHeight);
for (let y = 0; y < this.gridHeight; y++) {
for (let x = 0; x < this.gridWidth; x++) {
const lon = this.minLon + x * this.lonStep + this.lonStep / 2;
const lat = this.minLat + y * this.latStep + this.latStep / 2;
const walkable = this.#isWalkable(lon, lat);
this.grid.setWalkableAt(x, y, walkable);
if (walkable && this.debug && this.viewer) {
this.#drawDebugCell(x, y, lon, lat);
}
}
}
}
#drawDebugCell(x, y, lon, lat) {
const halfLon = this.lonStep / 2;
const halfLat = this.latStep / 2;
const corners = [
{ longitude: lon - halfLon, latitude: lat - halfLat },
{ longitude: lon + halfLon, latitude: lat - halfLat },
{ longitude: lon + halfLon, latitude: lat + halfLat },
{ longitude: lon - halfLon, latitude: lat + halfLat }
];
const positions = corners.map(c =>
Cesium.Cartesian3.fromDegrees(c.longitude, c.latitude, this.upHeight)
);
this.viewer.entities.add({
polyline: {
positions,
width: 1,
material: Cesium.Color.GREEN.withAlpha(0.2)
}
});
}
#getGridPosition(cartesian) {
const carto = Cesium.Cartographic.fromCartesian(cartesian);
const lon = Cesium.Math.toDegrees(carto.longitude);
const lat = Cesium.Math.toDegrees(carto.latitude);
let x = Math.floor((lon - this.minLon) / this.lonStep);
let y = Math.floor((lat - this.minLat) / this.latStep);
if (this.#isValidWalkable(x, y)) return { x, y };
const offsets = [
[0, 0], [-1, 0], [1, 0], [0, -1], [0, 1],
[-1, -1], [-1, 1], [1, -1], [1, 1]
];
for (const [dx, dy] of offsets) {
const nx = x + dx, ny = y + dy;
if (this.#isValidWalkable(nx, ny)) return { x: nx, y: ny };
}
return null;
}
#isValidWalkable(x, y) {
return x >= 0 && y >= 0 && x < this.gridWidth && y < this.gridHeight && this.grid.isWalkableAt(x, y);
}
#drawDebugPath(path) {
if (!this.viewer || !this.debug) return;
const positions = path.map(([x, y]) => {
const lon = this.minLon + x * this.lonStep + this.lonStep / 2;
const lat = this.minLat + y * this.latStep + this.latStep / 2;
return Cesium.Cartesian3.fromDegrees(lon, lat, this.upHeight);
});
this.viewer.entities.add({
polyline: {
positions,
width: 4,
material: Cesium.Color.BLUE.withAlpha(0.2)
}
});
}
findPath(startCartesian, endCartesian) {
const start = this.#getGridPosition(startCartesian);
const end = this.#getGridPosition(endCartesian);
if (!start || !end) {
const missing = [];
if (!start) missing.push('起始点');
if (!end) missing.push('结束点');
return { success: false, error: `${missing.join('、')} 不在有效路径区域内` };
}
const finder = new PF.AStarFinder();
const path = finder.findPath(start.x, start.y, end.x, end.y, this.grid.clone());
if (path.length === 0) {
return { success: false, error: '无法找到路径' };
}
this.#drawDebugPath(path);
const smoothPath = PF.Util.smoothenPath(this.grid, path);
return {
success: true,
path: smoothPath.map(([x, y]) => {
const lon = this.minLon + x * this.lonStep + this.lonStep / 2;
const lat = this.minLat + y * this.latStep + this.latStep / 2;
return [lon, lat];
})
};
}
}
Cesium.Ion.defaultAccessToken = '你的token';
// Cesium初始化
const viewer = new Cesium.Viewer('cesiumContainer', {
geocoder: false,// 搜索
homeButton: false,// 返回初始视图
baseLayerPicker: false,// 默认基础图层选择器
selectionIndicator: false,// 显示选择指示器
navigationHelpButton: false,// 显示导航帮助
sceneModePicker: false,// 显示场景模式选择器
infoBox: false,
});
let moveArea = [
{ longitude: 118.17996647742031, latitude: 39.62430762883727, height: 1 },
{ longitude: 118.17996035796666, latitude: 39.62436000550777, height: 1 },
{ longitude: 118.17988686458494, latitude: 39.62436156070048, height: 1 },
{ longitude: 118.17988748779658, latitude: 39.62422268343063, height: 1 },
{ longitude: 118.17965715859314, latitude: 39.62422045106713, height: 1 },
{ longitude: 118.17966204622869, latitude: 39.62416348004258, height: 1 },
{ longitude: 118.17988793209759, latitude: 39.62416297918548, height: 1 },
{ longitude: 118.17988054674845, latitude: 39.62399184423954, height: 1 },
{ longitude: 118.17995540035955, latitude: 39.6239953325062, height: 1 },
{ longitude: 118.1799627247085, latitude: 39.62425940608208, height: 1 },
{ longitude: 118.17998743297957, latitude: 39.6242616728335, height: 1 },
{ longitude: 118.17998278738285, latitude: 39.62399311184268, height: 1 },
{ longitude: 118.18006237646806, latitude: 39.62399267126263, height: 1 },
{ longitude: 118.18004939518086, latitude: 39.624301197839344, height: 1 },
{ longitude: 118.17996647742031, latitude: 39.62430762883727, height: 1 },
]
// 定义一个不规则的多边形(例如,边缘不规则的多边形)
const polygonPositions = moveArea.map(el => Cesium.Cartesian3.fromDegrees(el.longitude, el.latitude))
// 创建一个Cesium Polygon
const polygon = viewer.entities.add({
id: 'polygon',
polygon: {
hierarchy: polygonPositions,
material: Cesium.Color.AQUAMARINE.withAlpha(0.5),
}
});
// 拾取点击点设置起点终点
let start = null;
let end = null;
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((click) => {
const ray = viewer.camera.getPickRay(click.position);
if (!ray) return;
const pos = viewer.scene.globe.pick(ray, viewer.scene);
if (!pos) return;
if (!start) {
start = pos;
console.log(pos)
viewer.entities.add({
position: pos,
point: { pixelSize: 10, color: Cesium.Color.CYAN },
allowPicking: false,
});
} else if (!end) {
end = pos;
viewer.entities.add({
position: pos,
point: { pixelSize: 10, color: Cesium.Color.GREENYELLOW },
allowPicking: false,
});
const pf = new PathFinder(moveArea, [], { viewer, debug: true, gridStep: 0.000005});
const result = pf.findPath(start, end);
if (result.success) {
let p = result.path.map(([lon, lat]) => {
return Cesium.Cartesian3.fromDegrees(lon, lat, 0.1);
})
viewer.entities.add({
polyline: {
positions: p,
width: 4,
material: Cesium.Color.YELLOW
}
});
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 设置相机视角
viewer.flyTo(polygon);
document.getElementById('reSet').addEventListener("click", (event) => {
const entities = viewer.entities.values;
const toRemove = entities.filter(
(entity) => entity.id && !String(entity.id).includes("polygon")
);
// 移除这些实体
toRemove.forEach((entity) => viewer.entities.remove(entity));
start = null;
end = null;
})
</script>
</body>
</html>总结
到此这篇关于Cesium基于pathfinding.js室内路径规划实现思路及步骤的文章就介绍到这了,更多相关Cesium室内路径规划内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
