ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Cesium航线规划全解析:从坐标转换到动态飞行实现

Cesium航线规划全解析:从坐标转换到动态飞行实现 简介面向无人机航线规划与三维可视化场景这是一个基于 Cesium 三维地球引擎的可运行源码包服务前端开发者和地理信息系统爱好者。项目复现大疆司空2的航线设计与动态渲染能力围绕航点、航段和航线容器分层建模重点解决航线箭头渲染尝试过着色器与纹理贴图最终改用离屏画布生成带透明通道的箭头位图再通过图元与材质接口绑定到折线几何体沿航段切线方向动态调节箭头大小与颜色。包体很轻量共3个文件约8KB包含可直接运行的 HTML 页面以及环境配置与忽略规则文件适合快速打开验证或嵌入现有项目。源码按模块组织覆盖坐标转换、航迹解算、碰撞检测等核心逻辑关键函数配有注释还支持外部地理数据导入、网络通信指令下发和三维剖面联动。目前已有50人学习下载对需要借鉴 Cesium 自定义图元与航线可视化的开发者这是一份浓缩排错思路的可运行样例。1. Ceisum航线规划先弄明白这标题到底在说什么先纠正一个拼写标题里的 Ceisum 基本可以确定是 Cesium 的笔误搜这个关键词的人九成是在找三维地球里的无人机/飞行器航线规划 Demo。这类需求最常见的场景是给出几个航点在地球上用一条线把路径画出来再让一架小飞机模型沿着这条线飞同时相机跟着走。很多人以为这活儿的难点在“画线”真正动手后才发现核心问题是坐标转换、时间轴驱动和姿态计算——而这些恰恰是 Cesium 里最容易翻车的三块。这篇笔记我按自己做航线规划 Demo 的顺序来写先讲底座原理再给最小可运行代码然后调参数、避开常见坑最后补上动态数据驱动的进阶玩法。如果你正要给某个巡检系统做航线展示或者刚拿到一个“航线规划源码包”却看不懂它在跑什么建议顺着章节一步步过一遍代码可以直接抄。2. Cesium 航线规划的底座坐标、Entity 与 Property航线规划在 Cesium 里的本质是把一串 WGS84 经纬度坐标变成三维空间里的点序列然后交给某个 Entity 去绘制或驱动。这三件事分别对应坐标体系、Entity 体系和 Property 体系。搞不懂这三层后面所有代码都是懵的。2.1 坐标系为什么经纬度不能直接拿来画线Cesium 的默认数据是 WGS84 椭球体地理上我们用经度、纬度、高度单位米描述一个点。但这个描述方式不能直接用于三维渲染——图形引擎需要的是地心笛卡尔坐标Cartesian3也就是以地球中心为原点的 X/Y/Z 坐标。所有实体对象的 position、polyline 的 positions接收的都是 Cartesian3 或它的数组。// 错误示范直接把经纬度塞进去 polyline: { positions: [[116.39, 39.9], [116.41, 39.91]] // 这样不会报错但画出来是乱的 } // 正确做法先转成 Cartesian3 const positions Cesium.Cartesian3.fromDegreesArray([ 116.39, 39.9, 116.41, 39.91, 116.43, 39.92 ]);这就是新手最常见的第一个坑经纬度和世界坐标混用。fromDegreesArray 是按 [经度, 纬度, 经度, 纬度...] 的扁平数组解析的不是二维数组也不是 [纬度, 经度] 的在地图服务里常见的顺序。我一般会在代码里写死顺序并用注释标清楚避免后面数据接入时被带着走偏。回到原理这些 Cartesian3 坐标是相对地心的单位是米。地球半径约 6371 公里所以如果你把坐标直接拿来当作平面 XY 用画出来的线会横穿地心而不是贴在地表。这就是为什么有人看到航线“穿地”或者“飞到大洋彼岸”——不是地球模型错了是坐标用错了。2.2 Entity 体系适合业务开发但要知道它怎么更新Cesium 有两套渲染 APIPrimitive 和 Entity。Primitive 贴近底层性能好但代码繁琐适合大批量数据Entity 面向业务数据驱动写起来像操作普通对象航线规划这种规模的数据完全够用。我自己的习惯是航线绘制、飞行模拟、相机跟踪全用 Entity 实现除非一次性要渲染上千条航线才会去考虑 Primitive 的 polyline collection。Entity 的另一个关键点是 Property。很多人第一次看到 CallbackProperty 不理解它其实是一个“每次渲染时重新取值”的函数这样你就能让实体动态变化。比如航线要随某个状态实时刷新就可以用它。// 动态刷新航线每次渲染都取最新的坐标数组 const dynamicPositions new Cesium.CallbackProperty(function () { return latestPositions; // 外部变量更新后直接生效 }, false); viewer.entities.add({ polyline: { positions: dynamicPositions, width: 2 } });第二个参数 false 表示这个回调不是常量每次渲染都要重新计算。如果误写成 trueCesium 会认为结果恒定不变后续更新就再也不生效了。这是很多人“数据变了但线不动”的原因。2.3 让飞机按时飞起来SampledPositionProperty 与时钟画线只是静态结果航线规划的重点是飞行过程。飞行意味着位置随时间变化Cesium 为此提供了 SampledPositionProperty它像一个采样表把“时间点 → 坐标”成对存进去渲染时按当前时钟插值出中间位置。配合 Cesium 自带的时钟系统就能精确控制起飞、飞行、降落的过程。const property new Cesium.SampledPositionProperty(); // 每个航点绑定一个时刻单位是 JulianDate property.addSample(Cesium.JulianDate.fromDate(new Date(2024-01-01T00:00:00Z)), Cesium.Cartesian3.fromDegrees(116.39, 39.9, 100)); property.addSample(Cesium.JulianDate.fromDate(new Date(2024-01-01T00:00:10Z)), Cesium.Cartesian3.fromDegrees(116.41, 39.91, 150)); // 把它赋给实体的 position实体就会按时间走动 entity.position property;SampledPositionProperty 的效果取决于两个因素采样点的时间间距以及插值方式默认线性。这两个参数直接影响飞行速度曲线后面第 4 章我会单独说怎么调。3. 跑通第一版最小可运行的航线绘制与飞行 Demo这章给一套能直接落地的代码结构。我习惯把这类 Demo 拆成三个文件HTML 容器、JS 逻辑、JSON 数据。数据独立出来方便后面接真实航点。3.1 工程结构三个文件跑起来先说运行时依赖你需要一个可用的 Cesium 库。开发环境用 npm 安装或 CDN 引入都行我图省事常用 CDN版本锁在一个大版本上避免 API 漂移。这里不写死版本号你按自己项目里已装好的来。!-- index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 / title航线规划 Demo/title style html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } /style link hrefhttps://cdn.jsdelivr.net/npm/cesiumlatest/Build/Cesium/Widgets/widgets.css relstylesheet / /head body div idcesiumContainer/div script srchttps://cdn.jsdelivr.net/npm/cesiumlatest/Build/Cesium/Cesium.js/script script src./flight.js/script /body /html这里要说明Cesium 的窗口部件 CSS 和核心 JS 都必须正确引入否则页面只有动画逻辑但地球渲染不出来。如果你的项目里有自己的构建流程建议直接用 import 方式CDN 方案适合快速验证。数据文件 flight-data.json 存航点格式我这样定义{ name: demo-route, startTime: 2024-01-01T00:00:00Z, points: [ { lon: 116.39, lat: 39.9, height: 100 }, { lon: 116.41, lat: 39.91, height: 150 }, { lon: 116.43, lat: 39.92, height: 200 }, { lon: 116.45, lat: 39.90, height: 120 } ] }字段含义lon 是经度lat 是纬度height 是海拔高度米。startTime 是飞行起始时间后面会用程序计算每个航点的到达时刻。数据组织成数组而不是扁平结构是为了可读性——读取时再转成 Cesium 需要的扁平坐标数组。3.2 绘制静态航线三行代码的事接下来写 flight.js。第一步加载数据并把航点转成 Cartesian3 数组然后用 Entities 添加一条 polyline。// flight.js const viewer new Cesium.Viewer(cesiumContainer, { animation: true, // 显示动画控件 timeline: true, // 显示时间轴 baseLayerPicker: true, geocoder: false, sceneMode: Cesium.SceneMode.VIEW_2D // 可选先二维后三维默认三维 }); async function loadRoute() { const res await fetch(./flight-data.json); const data await res.json(); const cartesianPositions data.points.map(p Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height) ); viewer.entities.add({ name: route-line, polyline: { positions: cartesianPositions, width: 4, material: new Cesium.PolylineGlowMaterialProperty({ color: Cesium.Color.fromCssColorString(#00ff88), glowPower: 0.2 }) } }); } loadRoute();这段逻辑看着简单但有几个点容易踩positions 必须是数组而且每个元素都要是 Cartesian3否则实体创建时不会报错但渲染时可能整条线消失。width 的单位是像素不是世界单位所以大地球缩小时线会显得很粗这在做标绘系统时要留意。用 PolylineGlowMaterialProperty 是为了让航线有发光效果巡检演示时比纯颜色更能突出重点生产环境如果线很多建议换回纯颜色材质减少渲染开销。3.3 让飞机飞起来SampledPositionProperty 与时钟设置静态线画完现在加飞行。思路是这样把 flight-data.json 里的每个航点按顺序分配一个到达时间比如每两个航点之间间隔 10 秒然后把这些时间-坐标对塞进 SampledPositionProperty。async function initFlight(data) { const start Cesium.JulianDate.fromIso8601(data.startTime); const timeStep 10; // 每个航段飞行秒数 const posProperty new Cesium.SampledPositionProperty(); posProperty.setInterpolationOptions({ interpolationDegree: 1, interpolationAlgorithm: Cesium.LagrangePolynomialApproximation }); data.points.forEach((p, i) { const time Cesium.JulianDate.addSeconds(start, i * timeStep, new Cesium.JulianDate()); const position Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height); posProperty.addSample(time, position); }); // 设置 viewer 的时钟范围让动画可以从头播到尾 const stop Cesium.JulianDate.addSeconds(start, (data.points.length - 1) * timeStep, new Cesium.JulianDate()); viewer.clock.startTime start.clone(); viewer.clock.stopTime stop.clone(); viewer.clock.currentTime start.clone(); viewer.clock.shouldAnimate true; const flightEntity viewer.entities.add({ position: posProperty, point: { pixelSize: 8, color: Cesium.Color.YELLOW }, // 如果想用模型替换点取消下一行注释并准备好 glb 文件 // model: { uri: /models/CesiumMan.glb, scale: 1.0 } }); viewer.trackedEntity flightEntity; } loadRoute().then(() { // 重新读取一次数据传给飞行初始化 fetch(./flight-data.json) .then(r r.json()) .then(initFlight); });这段代码里有三个容易出错的地方。一是 clock 必须显式设置 startTime 和 stopTime否则默认时钟区间可能不覆盖你的采样时间飞机会一直在初始位置不动。二是我把 viewer.clock.currentTime 设成 start保证动画从起点开始而不是从系统当前时间开始——这个细节很多人漏掉结果飞机直接消失或瞬间跳到终点。三是 setInterpolationOptions 里面的 interpolationDegree: 1 表示线性插值配合 LagrangePolynomialApproximation 是 Cesium 里最常见的组合如果采样点过多degree 设高了反而会造成震荡后文会有具体说明。3.4 相机跟踪让视角跟着航线走飞行实体创建完成后把 viewer.trackedEntity 指向它相机就会自动跟随。但这种跟随默认是“从外看目标”如果你想做成第一视角像坐在飞机上需要改一下相机模式viewer.scene.preUpdate.addEventListener(() { const pos flightEntity.position.getValue(viewer.clock.currentTime); if (!pos) return; if (viewer.camera.positionCartographic.height 50) { viewer.camera.lookAt(pos, new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-45), 0)); } });这段是临时方案随着时钟推进持续把相机对准飞机位置。注意我没用 viewer.trackedEntity 而是手动 lookAt是因为某些版本里 trackedEntity 的默认跟随方式是第三人称的切第一视角要么改 viewer.scene.screenSpaceCameraController 的 enableInputs要么这样手动控制。生产系统里建议用 Camera.lookAtTransform 配合固定距离实现平滑跟随但 Demo 阶段这样够直观。4. 让航线规划真正可交付速度插值、姿态与样式参数能飞只是开始。真实的航线规划 Demo 要能回答三个问题飞多久、怎么转向、姿态如何变化。这章把时间、插值、朝向和地形这几个必调参数说清。4.1 速度与时间换算航段间隔怎么设才自然上一节的 timeStep 我写死成 10 秒。真实业务中航点之间的地面距离不同用固定时间间隔会导致速度忽快忽慢。正确做法用航点间的大地距离除以期望速度得到每段耗时。function computeSegmentDuration(p1, p2, speedMps) { const carto1 Cesium.Cartesian3.fromDegrees(p1.lon, p1.lat, p1.height); const carto2 Cesium.Cartesian3.fromDegrees(p2.lon, p2.lat, p2.height); const distance Cesium.Cartesian3.distance(carto1, carto2); return distance / speedMps; } // 用法 const speedMps 15; // 15 米/秒 data.points.slice(0, -1).forEach((p, i) { const t computeSegmentDuration(p, data.points[i 1], speedMps); // 累加到你自己的时间序列里 });这里我用的是空间直线距离没考虑地球曲率和地形起伏。航点间距很小几百米内时误差可忽略如果跨省飞行建议改用 Cesium.EllipsoidGeodesic 计算测地线距离否则算出来的耗时偏短。另外速度单位务必统一——代码里是米/秒如果需求文档写的是公里/小时除以 3.6 再进来这种低级换算错误我帮人排查时遇到不下三次。4.2 插值方式线性、样条与代码切换第 3.3 节里我用了 degree: 1 线性插值。把它换成样条只需要改一个配置posProperty.setInterpolationOptions({ interpolationDegree: 3, interpolationAlgorithm: Cesium.HermitePolynomialApproximation });两种方案差异插值方式degree特性和适用场景典型问题线性插值1航段直来直去速度恒定适合航线简单、路径就是直线的情况转角处拐得很硬轨迹有折痕三次样条/厄米特3轨迹平滑转弯自然适合模拟真实飞行可能过冲航线可能冲出航点范围我自己的习惯做航迹回放或航线预演用线性就够因为要忠实还原设计航线做“仿真飞行”或无人机表演一类需求才换三次样条。注意样条过冲不是 bug——数学上这是插值函数的固有行为你需要检查插值后的轨迹是否穿山或离地太近。验证办法是把时间分成小步长、逐个求 position 值打印出来对比。4.3 朝向与姿态让机头自动对准前进方向只有位置变化的飞机在 Cesium 里看是“平移”的机头不转向很假。解决办法是用 VelocityOrientationProperty 自动从位置属性中计算速度方向再转为朝向。const flightEntity viewer.entities.add({ position: posProperty, orientation: new Cesium.VelocityOrientationProperty(posProperty), model: { uri: /models/CesiumMan.glb, scale: 0.5 } });这段代码把 orientation 与位置属性绑定让模型始终顺着运动方向前进。这是最省事的方案但要承认它的局限它只根据速度向量算偏航和俯仰不管飞机本身的滚转而且当速度接近零时比如航点处悬停朝向会变得不稳定因为单位向量方向不确定。如果你做的是四旋翼这种转向其实是对的如果做的是固定翼需要自己写 HeadingPitchRoll 的平滑函数而不是直接用 VelocityOrientationProperty。4.4 地形贴合为什么线会沉到山体里Cesium 默认的地形是椭球面不是真实地形起伏。你给的航点高度如果是相对地面的画出来自然可能穿山。这时需要直接让线贴合地形const polyline viewer.entities.add({ polyline: { positions: cartesianPositions, clampToGround: true } });clampToGround: true 可以让 polyline 自动贴合地形表面。但两个前提一是 viewer 已经加载了地形数据比如 createWorldTerrain否则它贴的还是椭球面二是这个属性对模型的 position 不起作用——模型位置始终是绝对三维坐标不会自动吸到地形上。所以如果你的飞机模型要求贴着山坡飞必须在业务层算好高度再传入别指望 Cesium 帮你处理。5. Cesium 航线规划避坑5 个翻车场景与修复方案这章写我实际调试中遇到频率最高的 5 个问题。每条按“现象 → 原因 → 解决”展开有的不一定在文档里写明属于经验型的坑。5.1 航线整条跑到地球背面去了现象加载完数据后视角切到目标区域却看不到航线缩到全球视角才发现线画在了地球另一侧。 原因绝大多数是经纬度顺序写反了。有人习惯 [lat, lon]像某些地图服务而 Cesium 的 fromDegrees 严格是 (lon, lat, height)。另一个常见诱因是 JSON 数据里字段名是 lat/lon但代码读取时写成了 p.lat, p.lon。 解决在 fetch 数据后立即打印第一组坐标做断言。if (data.points.length 0) { const p data.points[0]; console.log(第一点: lon${p.lon}, lat${p.lat}, height${p.height}); if (Math.abs(p.lon) 180 || Math.abs(p.lat) 90) { console.error(经纬度字段有误检查数据格式); } }这个断言简单粗暴能拦截大多数坐标顺序错误。我养成习惯任何外部经纬度数据进来先做范围校验再转 Cartesian3。5.2 两个航点之间画出一条穿地心的直线现象航点明明在国内画出来却是一条穿透地球内部的直线两侧是陆地、中间是海洋。 原因positions 数组中混入了非法值比如 height 为 null 或字符串。fromDegrees 接收非法参数时不会抛异常而是返回一个 NaN 坐标Cesium 渲染时把所有 NaN 点连起来就出现了看似“穿过地心”的线条。 解决构造 Cartesian3 时统一用 Number 强制转换并过滤掉无效值。const validPoints data.points .map(p ({ lon: Number(p.lon), lat: Number(p.lat), height: Number(p.height) || 0 // 没有高度就按 0 处理 })) .filter(p !isNaN(p.lon) !isNaN(p.lat)); if (validPoints.length 2) { console.error(有效航点少于 2 个无法绘制航线); return; }这段代码把“非法数据”拦截在进入 Cesium 之前。值得说明的是Number(p.height) || 0 有一个副作用如果高度字段是 0 也会被保留因为 0 是 falsy会变成 0不影响结果但如果你语义上要区分“未填”和“海拔 0”还是要单独判断字段是否存在。5.3 飞行实体一动不动或时间轴不走现象飞机已经添加到 scene 里但点位实体就是不动点击时间轴播放按钮也没反应。 原因clock.currentTime 不在位置采样的时间范围内。最常见的是忘了设置 clock.startTime / stopTimeCesium 默认时间范围是当前系统时间附近而你 addSample 的时间是 2024 年的固定值根本不在范围里于是当前位置查无数据实体停在初始点或者干脆不渲染。 解决显式设置时钟区间并检查 shouldAnimate。viewer.clock.startTime start.clone(); viewer.clock.stopTime stop.clone(); viewer.clock.currentTime start.clone(); viewer.clock.shouldAnimate true; viewer.clock.multiplier 2; // 2 倍速播放调完代码后在浏览器控制台手动执行 viewer.clock.currentTime.toString()跟数据里的采样时间对比——如果时间范围对不上飞行动画一定出问题。这条排查路径能省下大量“翻源码查为什么不动”的时间。5.4 样条插值后航线异常过冲、飞出屏幕现象把插值从线性换成 degree: 3 的样条后航线变得不贴合航点甚至在转折处冲出很远。 原因样条插值在数据点少尤其只有 3-4 个航点且航点间距不均匀时会产生龙格现象。这不是代码 bug是数学特性。初学 Cesium 的人遇到这个很容易怀疑插值实现有毛病其实方向错了。 解决航点少于 5 个时建议继续用线性点数较多且间距均匀时再用样条。如果前后速度差异大先对采样时间做均匀化处理。// 统一时间步长把总时长分成 N-1 等份不考虑实际距离 function uniformTimeSamples(points, totalSeconds) { return points.map((p, i) { const time Cesium.JulianDate.addSeconds(start, totalSeconds * i / (points.length - 1), new Cesium.JulianDate()); return { time, position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height) }; }); }这个函数会把时间等分消除因采样时间间距不一致带来的样条抖动。代价是速度不再恒定——远距离航段显得快。到底是“速度恒定”还是“路径平滑”你要先想清楚需求再选。5.5 clampToGround 设置了线还是穿山现象代码里写了 clampToGround: true航线还是插进山体里。 原因clampToGround 只有在场景里有地形数据时才生效。如果 viewer 没加载地形它贴合的“地面”就是椭球面在真实山区自然会穿山。此外这个属性对样式有要求使用 PolylineGlowMaterialProperty 时clampToGround 会失效因为发光材质需要特殊渲染通道。 解决先确认地形是否已到位再检查材质类型。const viewer new Cesium.Viewer(cesiumContainer, { terrainProvider: Cesium.createWorldTerrain() }); // 绕开发光材质与 clamp 的兼容问题 polyline: { positions: validPositions, clampToGround: true, material: Cesium.Color.fromCssColorString(#00ff88), // 不要用 Glow 材质 width: 3 }这条经验来自我自己趟过的泥某次做山区输电线路巡检展示线明明是 perching 状态死活贴不了地排查一圈发现是材质拖了后腿。所以涉及地形贴合时样式越朴素越稳妥。6. 进阶玩法动态数据驱动与实时跟踪走到这一步你的 Demo 已经具备静态航线、飞行模拟、相机跟踪三个基本能力。现在说说怎么把它从“演示版”推向“能接真实业务”。核心就一句话把航点数据从静态 JSON 换成外部输入。6.1 把航点换成实时数据流实际项目里航点往往来自服务端——可能是无人机实时回传的 GPS 轨迹也可能是后台上传的规划航线。动态更新航线不需要重新创建实体只需要更新 positions 或 property 的数据源。// 轮询接口每次 fetch 更新全局变量 let livePositions []; async function pollRoute() { const res await fetch(/api/flight/current); const data await res.json(); livePositions data.points.map(p Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height)); } // 定时更新1 秒拉一次 setInterval(pollRoute, 1000); // 用 CallbackProperty 驱动航线 const liveLine viewer.entities.add({ polyline: { positions: new Cesium.CallbackProperty(() livePositions, false), width: 3, material: Cesium.Color.CYAN } });这段代码中CallbackProperty 的 false 参数务必保留否则 Cesium 会缓存第一次的结果后面数据再变也画不出来。轮询间隔我写的是 1 秒具体看你的后端数据频率如果是无人机回传通常 2-5 秒一个点是常态没必要拉太频繁。6.2 为已飞和未飞航段分段着色飞行过程中业务方通常希望看到一段分界绿色表示已飞过灰色表示尚未执行。实现思路很简单在飞行实体更新的同时根据当前时钟时间去截断 polyline 的 positions。viewer.scene.preUpdate.addEventListener(() { const currentTime viewer.clock.currentTime; const upToIndex flightProperty.getNumberOfSamples() - 1; let activeCount 0; for (let i 0; i upToIndex; i) { const sampleTime flightProperty.getSampleTime(i); if (Cesium.JulianDate.lessThanOrEquals(sampleTime, currentTime)) { activeCount i 1; } } const activePositions []; for (let i 0; i activeCount; i) { activePositions.push(flightProperty.getValue(flightProperty.getSampleTime(i))); } flownLine.positions activePositions; });这段逻辑里有个效率隐患遍历采样点并重新构建数组每次渲染都执行。当采样点数量超过几百时计算量开始明显。更优做法是在每添加一个采样点时缓存当前已飞集合而不是每次都重算。6.3 收起线结尾的几句实在话回看这套方案真正决定它“能用还是玩具”的边界有几个数据来源是否真实、时间轴是否与业务时钟同步、姿态是否适配机型。我做这类 Demo 的教训是永远在产品侧把坐标和时间这两条写清楚不要直接在渲染层猜——猜来的时间轴会让整个演示像一部按剧本演的戏一旦接真实数据就崩。所以我现在的习惯是每接手一个新航线项目第一件事就是在入口处加一段打印打印起始时间、结束时间、航点数、首个航点坐标跑通了再去看三维效果。这个习惯帮我省下无数排查时间也推荐给你。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表