简介:面向Cesium开发者的路径导航线源码包,解决在三维地球场景中快速绘制移动轨迹与导航路线的需求。核心代码围绕PathLinePrimitive类实现,通过初始化一组经纬度坐标点并传入构造方法,即可生成可视化路径,同时支持调整颜色、宽度、透明度等视觉属性,便于区分不同类型的路线。该代码适用于实时交通监控、飞行历史航线回放、游戏NPC移动路径、旅行规划等Web GIS场景。压缩包共3个文件,包括可直接运行的HTML示例页面、Git管理配置以及在线编辑器辅助文件,整体仅6KB,结构紧凑,适合快速阅读和调试。已有71人学习下载。该源码的价值在于以极简示例展示坐标点到路径绘制的完整流程,开发者可直接复用或扩展,无需从零搭建Cesium环境,显著降低三维路径可视化的上手门槛,也为后续接入测距、路径优化等高级分析功能提供了清晰起点。
1. 整体思路与方案选型
路径导航线在Cesium里算是一个“看似简单、做起来细节很多”的功能。很多刚接触Cesium的朋友第一反应是:画一条线嘛,polyline加几个坐标点不就完事了?这话没毛病,但要做出能用、好看、能应对真实业务的导航线,远不止往viewer.entities.add里塞一条Polyline这么简单。
我们先拆一下需求。导航线这个需求,放到Cesium的语境下通常涉及这么几个点:
- 支持动态规划路线:用户或后端下发一串途经点,前端实时渲染
- 线的形态要贴合地形或模型表面,不能出现悬空或者穿地
- 视觉上要有明确的“导航”感,不能跟普通标绘线混在一起
- 性能要跟得上,尤其是点位多、场景复杂的时候
这一版我采用的方案是:基于viewer.entities.add的Polyline系统,配合CallbackProperty动态更新坐标集,再叠加自定义材质和贴地/深度测试配置。选Polyline而不是Primitive,主要原因是Entity API的封装度更高,状态管理省心,适合大多数中后台项目;而且CallbackProperty能天然支持播放器式的路径推进动画,不用自己维护Primitive的更新逻辑。
为什么不用PolylineCollection或Primitive?如果你只需要一次性静态显示一条线,Primitive确实更轻量,但一旦涉及动态更新、显隐控制、拾取交互,Primitive的代码量会线性上升。Entity虽然底层也走了Primitive,但它的状态追踪和事件机制能省掉不少脏活,导航线这种功能,开发效率优先级更高。
再补充一个选型上的心得:导航线的核心不在“画线”,而在“坐标源的连续性管理”。也就是说,用户点击生成轨迹、播放器按时间推进、后端推送实时路径,这三类业务场景下,线的数据来源和刷新节奏完全不同,设计时要把数据层和渲染层解耦。我这里用CallbackProperty做渲染层,外部只管维护一个坐标数组,刷新时调用回调,渲染层自动感知,这个模型后面会展开讲。
2. 核心细节解析与实操要点
2.1 坐标累加器:动态路径的骨架
导航线最常见的业务交互,是用户点击地图生成途经点,形成一条完整的导航路径。我这里的实现方式很简单:定义一个数组positions,每次点击时把拾取到的Cartographic坐标转成Cartesian3,push进数组,然后通过CallbackProperty让Polyline跟随数组变化。
很多教程会让你直接操作polyline.positions = newPositions这种方式,这在静态场景下没问题,但动态累加时频繁整体赋值,性能不够好,而且容易引入不必要的视图刷新。用CallbackProperty能实现“懒计算”——Cesium在需要重绘时才调用回调函数,数据更新和渲染更新解耦,实测在高频点击场景下帧率更稳。
const positions = []; const navigationLine = viewer.entities.add({ polyline: { positions: new Cesium.CallbackProperty(() => { return positions; }, false), width: 8, material: new Cesium.PolylineTrailLinkMaterialProperty({ color: Cesium.Color.CYAN, trailLength: 0.4, }), }, }); // 点击地图拾取坐标 const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement) => { const cartesian = viewer.scene.globe.pick(viewer.camera.getPickRay(movement.position), viewer.scene); if (cartesian) { positions.push(cartesian); } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);这段代码的核心,是所有逻辑围绕一个数组转。CallbackProperty的第二个参数传false,意思是这个回调不依赖其他Entity属性,Cesium可以跳过部分依赖检查,进一步减少渲染开销。
2.2 贴地配置:clampToGround和深度测试
导航线如果不贴地,在山地、倾斜摄影模型上会显得很“飘”。Cesium处理贴地有三种常用手段:
| 方式 | 适用场景 | 注意事项 |
|---|---|---|
clampToGround: true | 纯地形、无模型压平 | 不支持自定义高度,拐角处偶尔有拉伸 |
depthTestAgainstTerrain | 倾斜摄影、模型场景 | 线会穿模,需要配合groundLine材质使用 |
| 手动采样地形高度 | 需要精确控制高度 | 需要异步查询地形,代码量略大 |
我最常用的组合是:在倾斜摄影或BIM模型场景里,开启全局深度测试,再用一种半透明贴地材质,这样导航线不会陷入模型里,视觉上像“铺”在路面上。纯地形项目里则直接用clampToGround,省事且性能好。
有一处容易踩坑:开启depthTestAgainstTerrain后,Polyline的坐标如果低于地表,整条线会被地形“吃掉”。所以如果用了深度测试,必须保证路径坐标略高于地表,或者用后面要讲的贴线材质,通过透明度和颜色渐变来规避穿模。
2.3 导航箭头的两种实现
导航线要有方向感,光有颜色和宽度的渐变还不够。我常用两种方案:
第一种,官方自带的PolylineArrowMaterialProperty。优点是零成本,缺点是箭头太大、太“硬”,而且没有流动感,实际项目里观感一般。
第二种,自定义一个带“流动光带”的材质,在箭头的视觉基础上叠加随时间的偏移。这里我推荐一个自写的材质脚本,原理是让纹理坐标随时间循环移动,再配合渐变透明度,形成一股沿着路径推进的“光流”。
Cesium.Material.PolylineTrailLinkType = 'PolylineTrailLink'; Cesium.Material.PolylineTrailLinkSource = ` czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); vec2 st = materialInput.st; float t = fract(st.s + czm_frameNumber * 0.01); float alpha = smoothstep(0.0, 0.2, t) * (1.0 - smoothstep(0.6, 1.0, t)); material.diffuse = czm_saturation(vec3(0.0, 1.0, 1.0), 1.5); material.alpha = alpha * 0.8; material.emission = vec3(0.0, 0.6, 1.0); return material; } `; Cesium.Material._materialCache.addMaterial('PolylineTrailLink', { fabric: { type: 'PolylineTrailLink', uniforms: { color: new Cesium.Color(0.0, 1.0, 1.0, 1.0), trailLength: 0.4, }, }, source: Cesium.Material.PolylineTrailLinkSource, });这段材质的核心思路,是把st.x映射成路径的“里程”,再用czm_frameNumber驱动它周期循环,形成流动效果。smoothstep控制光带的头和尾的渐变,避免生硬的截断。
3. 实操过程与核心环节实现
3.1 完整接入步骤
从零到能跑,我按下面的顺序操作。假设你已经有一个初始化好的Cesium.Viewer。
第一步,引入并注册自定义材质。把上面那段PolylineTrailLink材质代码放在Viewer初始化之后、创建导航线之前执行,确保材质注册完成。
第二步,创建ScreenSpaceEventHandler,监听左键点击。拾取坐标时注意用viewer.scene.globe.pick而不是viewer.camera.pickEllipsoid,前者能正确拾取地形和3D Tiles表面,后者只针对地球椭球体,遇到模型就抓瞎。
第三步,维护途经点数组,同时创建一个临时实体用于实时预览最近一段路径,让用户看到“线在跟着鼠标走”。这个功能用mousemove事件配合临时坐标数组就能实现。
const tempPositions = []; const tempLine = viewer.entities.add({ polyline: { positions: new Cesium.CallbackProperty(() => tempPositions, false), width: 4, material: Cesium.Color.WHITE.withAlpha(0.4), }, }); handler.setInputAction((movement) => { const cartesian = viewer.scene.globe.pick( viewer.camera.getPickRay(movement.endPosition), viewer.scene ); if (cartesian) { tempPositions.length = 0; tempPositions.push(positions[positions.length - 1], cartesian); } }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);这里有个细节:临时线不要直接复制整条positions数组,只取“最后一个已确认的点 + 当前鼠标点”两段即可,性能消耗更小。
第四步,双击或右键完成绘制,清空临时线,保留导航线。同时可以加一个按钮或接口方法,用于清空全部路径、撤销上一点。
3.2 关键参数选择
路径线里几个重要参数在真实项目里需要根据场景调整,整理成一张表方便抄作业:
| 参数 | 推荐初值 | 调优方向 |
|---|---|---|
| width | 6~10 | 地形起伏大时适当加宽,避免“细线感” |
| trailLength | 0.3~0.6 | 值越小,光带越短,动态感越强 |
| 流动速度(0.01) | 0.01 | 值越大流动越快,但过大会显得闪烁 |
| alpha | 0.6~0.9 | 背景复杂时调低,避免遮挡模型细节 |
| depthTestAgainstTerrain | false(开发期)/ true(上线) | 开发期关掉方便排查坐标问题 |
流动速度参数在材质里,就是fract(st.s + czm_frameNumber * 0.01)中的0.01。改成0.02,流动速度快一倍,但视觉上会变“碎”,一般导航场景建议0.008~0.015之间。
3.3 导航起点和终点标记
光有路径线,用户分不清哪里是起点、哪里是终点。我一般在路径的首尾各加一个标记。起点用绿色圆点,终点用红色旗帜或圆环。实现上直接用viewer.entities.add添加Point或Billboard,然后通过CallbackProperty把坐标绑定到positions[0]和positions[positions.length - 1]。路径更新时,标记自动跟随。
有一个容易忽略的点:如果positions数组里只有一两个坐标,终点标记和起点标记会重叠。代码里要加一个长度判断,少于两个点时不显示终点标:
const endMarker = viewer.entities.add({ point: { pixelSize: 12, color: Cesium.Color.RED, disableDepthTestDistance: Number.POSITIVE_INFINITY, show: new Cesium.CallbackProperty(() => positions.length >= 2, false), }, position: new Cesium.CallbackProperty(() => { return positions.length >= 2 ? positions[positions.length - 1] : Cesium.Cartesian3.ZERO; }, false), });disableDepthTestDistance: Number.POSITIVE_INFINITY很关键,它能让标记在模型遮挡时依然显示,对导航场景非常实用。
4. 常见问题与排查技巧实录
4.1 线不贴地或者陷进地里
开发导航线时遇到最多的问题就是“线要么悬浮、要么被地形埋住”。排查步骤按下面的顺序来:
第一,确认坐标拾取方式。如果用camera.pickEllipsoid,拾取到的是椭球面坐标,不是地形表面坐标,在地形起伏区域就会出现悬浮。换成globe.pick之后大部分问题能解决。
第二,检查是否开启clampToGround。如果开了,线的坐标会被强制投影到地形表面,此时再设置高度坐标没有意义。如果你需要“离地高度”(比如无人机航线),就不能用clampToGround,而是手动给坐标添加高度偏移。
第三,检查depthTestAgainstTerrain。开启后线会被场景深度阻挡,如果线刚好贴着地面,可能是半像素精度问题导致整条线被“吃掉”。解决方法是给坐标抬高0.5~1米,或者用PolylineGround类型的材质(通过groundLine参数)绕开深度测试。
4.2 动态更新时线闪烁
CallbackProperty更新频率过高会导致渲染线程频繁计算,出现闪烁或卡顿。常见原因是positions数组在事件回调里被原地修改,Cesium无法准确追踪变更,导致部分帧渲染旧数据。
我的做法是:更新数据时用positions = newPositions整体替换,而不是push或splice。CallbackProperty闭包内部引用外部变量,外部变量被重新赋值后,callback下次调用时读到的就是新数组。为了保持这个模式,闭包里的positions要声明为let。
let positions = []; // 更新时 positions = newPositions;4.3 导航线在3D Tiles模型上穿模
这个问题在高精度的倾斜摄影或BIM模型上尤其明显。根因是模型表面本身有深度信息,Polyline默认在深度测试中被模型遮挡。
我的经验是分两层处理:第一层,先让线整体抬高到一个合理的“视线高度”,比如离地2~3米,保证线不被模型埋掉;第二层,给材质加一定的透明度,让线“叠”在模型表面上而不是完全盖住模型,观感上更通透。
如果模型高度差异较大,还可以用scene.pickPosition在鼠标点击时获取真正的模型表面坐标,而不是globe.pick只取地形。但这需要开启viewer.scene.pickTranslucentDepth,并且对性能有一定损耗,非必要不建议全局开启。
4.4 导航线在相机视角变化时出现断裂
这种问题一般出在Polyline跨过大范围区域时,Cesium对长距离线段做了水平分块切割,在视锥边缘会出现“闪断”现象。解决办法是缩短单条Polyline的长度,或者用arcType: Cesium.ArcType.GEODESIC让线段沿地球曲率弯曲,减少每段直线的跨度。
实测中,一条跨度超过100公里的路径线容易出现这种问题。如果业务确实需要跨区域导航,建议把路径切分成多段Polyline,每段控制在合理范围内,同时用同一种材质,视觉上看不出拼接痕迹。
4.5 导航线增加速度控制器
不少项目希望导航线能配合车辆/飞机的移动速度来显示,比如点击“开始导航”后,光带按真实速度推进。实现上,我给材质uniform增加一个speed动态参数,在刷新逻辑里调整它。
material.uniforms.speed = 0.02; // 可以随时改这种设计比硬编码在shader里更灵活,速度变化也不需要重建材质,实测运行中修改完全平滑。再进一步,还能把speed跟一个时间轴控制器绑定,用Cesium的clock来驱动,这样就能实现“播放、暂停、倍速播放”这类导航回放功能。
5. 从导航线延伸到更多玩法
路径导航线这个功能做顺手之后,我发现它的架构完全可以迁移到其他场景。
一个思路是动态围栏和预警区域。把点击坐标换成围栏顶点,把Polyline换成Polygon,配合CallbackProperty动态更新,就能实现越界预警、电子围栏这类功能。区别只是几何类型不同,数据流的组织方式完全一致。
另一个思路是轨迹回放。把车辆/飞行器实时上报的坐标流灌进positions数组,再加上一个用SampledPositionProperty驱动的移动点,就能同时展示“历史轨迹+当前实时位置”。这个架构在物流、巡检、共享出行等领域都有现成的需求。
还有一种是多导航线同时展示。思路是把坐标数组改成Map结构,每条导航线一个key,管理各自的可见性、颜色、状态。点击不同的导航线时,只需要切换对应的数组引用,线的样式可以独立控制。这个扩展方式对复杂业务非常实用。
我自己在做一个园区导航项目时,把所有公共地块、楼栋入口、内部道路的坐标都存成了JSON配置,前端动态加载成导航线,再按用户选择的起终点亮高。整套实现下来,核心渲染逻辑没有变,变的只是数据来源。
6. 实际操作中的几个坑
材质shader里写颜色时,用vec3(0.0, 1.0, 1.0)这种格式,但如果要调整透明度,不要只改material.alpha,还要把material.diffuse乘一个系数,否则在部分角度下会看到颜色发白。我一开始没注意这个,结果线在黄昏时段看起来像曝光过度。
路径线如果有多条,建议给每条线一个独立的id,用viewer.entities.getById来查询和管理,不要依赖数组索引。因为Entity对象在Cesium内部可能被自动排序,索引方式容易张冠李戴。
一次画上千个点的长轨迹时,CallbackProperty回调里做了大量坐标转换会明显掉帧。我测试过,500个点以内基本无感,超过1000个点性能开始下降,超过5000个点掉帧严重。如果业务需要长轨迹,务必使用Primitive而不是Entity,或者对点集做抽稀处理。
深色底图和高亮材质的对比度问题也值得一提。我默认用的青色在深色底图上很醒目,但换到亮色底图就几乎看不清。建议在材质里加一个统一的颜色uniform,方便根据业务底图随时调色。
用time管理导航推进时,Cesium默认时钟是按真实时间走的,想控制播放速度,要将viewer.clock.shouldAnimate和multiplier配合起来。否则会出现“时间走了但导航线不动”的诡异现象,查了半天才发现是时钟没设置。
最后再分享一个小技巧:如果导航线要配合声音提示或震动反馈,建议把路径转折点的坐标提前算好,不要在运行时逐帧去算“该不该提示”。我习惯在路径生成完成后立刻遍历一遍坐标,把所有拐角超过45度的点标记出来,运行时只需要监听当前位置和下一个拐点的距离,触发条件清晰,代码也简单很多。
本文还有配套的精品资源,点击获取