
简介这是一份基于Cesium的无人机飞行模拟器完整源码面向三维GIS、Web前端及无人机仿真方向的学习者与开发者适合用于搭建可交互的飞行训练环境也可作为Cesium二次开发与WebGL图形编程的实战范例。压缩包共1555个文件、约83MB以1015个JavaScript源文件与210个GLSL着色器文件为核心配合CSS/HTML界面、JSON配置、PNG/JPG/SVG图像资源及GIF动图覆盖三维地图渲染、着色器效果、前端交互与静态资源优化的完整实现。目前已有859人学习下载。源码目录结构清晰除三维地球渲染与无人机航线控制等核心逻辑外还包含gulpfile构建脚本、package.json依赖管理等工程化内容便于系统研究Cesium API调用、GLSL着色器编写、图像资源取舍以及现代前端项目的组织方式。项目采用CSS与HTML分离的页面结构并结合SVG矢量图保证界面在不同分辨率下的清晰度这一设计思路也值得前端开发者借鉴。 做一个能跑起来的基于Cesium的无人机飞行模拟器最难的从来不是起飞而是怎么把“飞行”这件事在三维地球里变得可信。我最早用Cesium做模拟器时先在官方示例里铺了OSM建筑物然后画了一条航线把一个glTF的无人机模型挂到viewer.entities上结果模型是能动了但姿态僵硬得像在传送带上平移相机也跟不上更别提雷达波、动态光照这些效果了。后来花了几周把整个源码骨架重写了一遍才真正摸清这套体系的门道。这篇文章就围绕“基于Cesium技术的无人机飞行模拟器设计源码”这条主线把从数据结构、仿真循环到特效落地、排错优化的完整思路讲一遍。适合那种不想只用现成飞行模拟软件、想在Web端自己搭一套可视化仿真原型或者准备把Cesium塞进数字孪生项目里的开发者。1. 为什么选Cesium而不是专用飞行模拟引擎一个偏“数据可视化”的定位1.1 无人机模拟器对渲染引擎的硬性要求先明确一件事这里说的“无人机飞行模拟器”不是那种带全套气动模型、遥控器输入、物理碰撞的飞行训练软件而是一个偏数据可视化、航线预演、态势显示的技术原型。它要满足几个硬性条件能加载真实地形和影像能显示大范围地理环境能按经纬度和高度飞行能接入实时仿真数据还要能在浏览器里跑。专用飞行模拟引擎在这个场景下有两个问题一是全球地形和影像的加载能力弱二是和WebGIS生态打通成本高。而Cesium本身就是一个虚拟地球平台自带全球高程、影像服务、3D Tiles、glTF模型、时间轴动画天然适合“让无人机按坐标飞到某个位置”这件事。所以我最终选择了Cesium作为渲染核心同时自己写一套仿真调度逻辑而不是直接用现成的无人机模拟软件。1.2 Cesium现有API能覆盖哪些缺的又是什么Cesium能直接覆盖的部分很多地球场景、相机控制、entity动画、3D Tiles加载、粒子、广告牌、聚光灯等。其中对我这个项目最关键的几个API是Cesium.SampledPositionProperty用来按时间采样飞行位置配合viewer.clock驱动。Cesium.VelocityOrientationProperty根据速度向量自动计算模型朝向配合SampledPositionProperty用。Cesium.headingPitchRollQuaternion处理姿态四元数实现转向和俯仰。viewer.clock.onTick作为仿真循环入口比自己去requestAnimationFrame更贴合Cesium的时间体系。缺的部分也很明显没有现成的航线编辑器、没有任务状态机、没有雷达波扫描的组件、没有无人机仪表盘UI。这些都需要在源码层面自己补。如果你只想快速搭原型官方示例已经够用但要做到“模拟器”而不是“动画播放器”就必须把时间、状态、数据、渲染四层拆清楚。2. 仿真循环与场景数据结构模块怎么拆状态怎么流转2.1 模块清单与数据流我最终把整个项目拆成了七个模块每个模块只做一件事SceneManager负责初始化Viewer、影像、地形、3D Tiles。RouteManager负责航点的解析、插值、生成航线。FlightSimulator核心状态机维护飞行任务阶段待机、起飞、巡航、执行动作、降落。UAVEntityManager负责把无人机模型挂到场景里并绑定位置、姿态、可见性。CameraRig负责跟随相机、自由视角切换、视角平滑。TelemetryDispatcher从仿真循环里订阅位置、姿态、速度同时推送仪表盘和图层特效。FxLayerManager负责动态光照、雷达波、矢量箭头、地形压平、热力图等扩展效果。数据流的核心思想是“单向流动”航点数据进入FlightSimulatorFlightSimulator根据clock时间计算当前状态然后把结果写进UAVEntityManagerTelemetryDispatcher再从实体属性里读取数据分发出去。不要在UI回调里直接改无人机位置否则时间轴一拖拽所有状态就全乱了。2.2 仿真循环设计用Cesium的Clock串联所有状态很多人一开始会在viewer.scene.postRender里做更新我试过能跑但很别扭。postRender每帧触发但和Cesium的clock时间不同步拖拽时间轴时容易跳变。更好的做法是用viewer.clock.onTick作为唯一驱动源在这个事件里根据viewer.clock.currentTime计算任务进度。viewer.clock.shouldAnimate true; viewer.clock.multiplier 2; // 两倍速飞行 viewer.clock.onTick.addEventListener((clock) { const time clock.currentTime; flightSimulator.tick(time); telemetryDispatcher.tick(time); fxLayerManager.tick(time); });这里有个重要的设计取舍所有模块都通过tick(time)接收同一个时间参数而不是各自去读取clock。这样测试的时候可以直接传入任意时间戳不需要真的让时钟跑起来单元测试会好写很多。FlightSimulator内部维护了一个状态机每个航点不仅有经纬度高度还带有动作标记比如到达该点后转弯、开启雷达、悬停5秒等。状态流转用一张表来描述状态触发条件动作TAKEOFF开始任务高度从地面爬升到航线起点CRUISE到达航线起点按航点顺序飞行HOLD航点动作为悬停悬停指定时间EXECUTE航点动作为载荷操作触发雷达波/光照变换LAND所有航点完成下降到降落点这个状态机是整个模拟器的核心比Cesium的entity动画更可靠。因为entity动画只是“让模型动起来”状态机负责“为什么动”。3. 航线规划、相机追踪与仪表盘联动核心源码逐段说3.1 航点插值经纬度坐标转成时间采样航线数据最麻烦的是速度控制。只给航点坐标不行还要给出到达每个航点的时间然后反推速度。我的做法是先用大圆航线做空间插值再根据预设速度生成采样点最后把采样点塞进SampledPositionProperty。function buildFlightPath(waypoints, speedMs) { const positions new Cesium.SampledPositionProperty(); const start Cesium.JulianDate.now(); let elapsed 0; for (let i 0; i waypoints.length - 1; i) { const p1 Cesium.Cartesian3.fromDegrees(waypoints[i][0], waypoints[i][1], waypoints[i][2]); const p2 Cesium.Cartesian3.fromDegrees(waypoints[i 1][0], waypoints[i 1][1], waypoints[i 1][2]); const distance Cesium.Cartesian3.distance(p1, p2); const duration distance / speedMs; const interval Math.max(2, Math.floor(duration * 10)); for (let j 0; j interval; j) { const t j / interval; const time Cesium.JulianDate.addSeconds(start, elapsed duration * t, new Cesium.JulianDate()); const lat Cesium.Math.lerp(waypoints[i][1], waypoints[i 1][1], t); const lon Cesium.Math.lerp(waypoints[i][0], waypoints[i 1][0], t); const alt Cesium.Math.lerp(waypoints[i][2], waypoints[i 1][2], t); positions.addSample(time, Cesium.Cartesian3.fromDegrees(lon, lat, alt)); } elapsed duration; } return positions; }这段代码里用了一个小技巧经纬度和高度的线性插值在绝大多数场景下够用但如果航线跨度过大经纬度线性插值会偏离大圆航线。真正严谨的做法是先做球面插值Cesium.Cartesian3.lerp之后转制图坐标再修正高度。我在项目里为了省事用了线性插值结果500公里以上的航线在中高纬度会有肉眼可见的偏航后来才换成球面插值。这是第一个值得记住的坑。3.2 模型加载与姿态插值不要只绑一个SampledPositionProperty把无人机模型挂到entity上很简单但要让姿态自然必须把位置和朝向一起处理。位置用SampledPositionProperty朝向用VelocityOrientationProperty就可以做到基本沿航线方向飞行。但如果航点转弯角度很急模型会突然甩头这时候需要给航向角额外做平滑处理。const droneEntity viewer.entities.add({ position: sampledPosition, orientation: new Cesium.VelocityOrientationProperty(sampledPosition), model: { uri: /models/drone.gltf, minimumPixelSize: 64, maximumScale: 2000, }, });这里有个比较隐蔽的问题VelocityOrientationProperty只根据速度方向算偏航角不会补偿俯仰角。无人机爬升或俯冲时模型机头仍然平着看起来不像在爬升。我的解决方案是额外监听每个采样点的垂直速度生成一个俯仰偏移角度然后用矩阵乘法把俯仰角叠加到VelocityOrientationProperty的参考系上。姿态插值部分我建议用Cesium.Transforms.headingPitchRollQuaternion在每一个采样点构造四元数而不是让Cesium自动推断。代价是代码多一点换来的是姿态完全可控悬停、侧飞、倒飞都能表达。3.3 相机跟随平滑与碰撞如何平衡相机跟随有很多种实现方式最简单的就是每帧把viewer.camera.lookAt指向无人机。但这样体验很差转向时镜头会剧烈晃动。我最后采用的是“环形缓冲跟随”方案维护一个长度为10的相机目标位置历史相机始终朝着历史目标插值形成自然延迟效果。function updateCamera(dronePosition, droneHeading) { cameraTargetHistory.push(dronePosition.clone()); if (cameraTargetHistory.length 10) cameraTargetHistory.shift(); const target cameraTargetHistory[0]; const offset new Cesium.Cartesian3.fromDegrees( Cesium.Math.toDegrees(dronePosition.longitude) 0.008, Cesium.Math.toDegrees(dronePosition.latitude) - 0.006, 260 ); viewer.camera.lookAt(target, offset); }偏移量需要根据飞行高度动态调整否则无人机飞高后相机会离得太远。另一个必须处理的问题是相机碰撞在山区飞行时相机可能钻到地形里所以我在lookAt之前会检测相机位置是否低于该处地形高度如果低于就抬升相机高度。这个检测用的是viewer.scene.globe.getHeight性能开销不大但能明显减少穿模。3.4 从仿真循环到DOM仪表盘状态数据怎么展示仪表盘我用的是纯DOM元素每秒更新10次而不是每帧更新。因为高度、速度、剩余航程这些数据不需要60fps刷新频繁操作DOM反而掉帧。TelemetryDispatcher在tick里把实体数据写入一个共享对象仪表盘组件用setInterval读取并渲染。class TelemetryDispatcher { tick(time) { if (!droneEntity.position) return; const cartographic Cesium.Cartographic.fromCartesian( droneEntity.position.getValue(time) ); this.telemetry { longitude: Cesium.Math.toDegrees(cartographic.longitude), latitude: Cesium.Math.toDegrees(cartographic.latitude), altitude: cartographic.height, heading: this.currentHeading, pitch: this.currentPitch, speed: this.currentSpeed, }; } }速度计算不直接读SampledPositionProperty因为连续两个采样点之间差值算出的速度会抖动。我在FlightSimulator里维护了一个“最近5帧位移集合”用滑动窗口平均得出平滑速度。这样仪表盘里的速度值不会一直在35和42之间跳动观感好很多。4. 动态光照、雷达波、地形压平这些“看得见”的细节是怎么加进去的4.1 动态光照与阴影让无人机周围的光线跟随时间变化Cesium默认光照能把全球照亮但阴影效果需要显式开启。动态光照不只是让太阳光跟着时间转更关键的是阴影生成和衰减。我用的是viewer.scene.globe.enableLighting true加方向光配置同时为无人机加了一个聚光灯模拟夜间搜索场景。viewer.scene.enableLighting true; viewer.scene.globe.enableLighting true; viewer.scene.light new Cesium.DirectionalLight({ direction: Cesium.Cartesian3.fromDegrees(-60, 40, 80), intensity: 2.5, });但要注意Cesium的光照精度和实时阴影距离有限。无人机在大范围地图里飞行时近地小物件的阴影很容易出现锯齿或闪动。我的做法是只让阴影覆盖无人机周围500米范围的区域也就是把阴影贴图分辨率分配在局部而不是整个场景。这需要自定义渲染逻辑官方API没有直接暴露对应的控制我是在场景回调里动态修改光源的direction保持太阳光方向的同时增加一个跟随无人机的局部光源来实现。4.2 雷达波用CallbackProperty画会呼吸的圆环雷达波是模拟器里视觉效果最强、实现成本最低的一个特效。用CallbackProperty动态生成环形微波配合透明度衰减就能做出不断扩散的雷达波。const radarEntity viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(lon, lat, alt), ellipse: { semiMajorAxis: new Cesium.CallbackProperty(() radarRadius, false), semiMinorAxis: new Cesium.CallbackProperty(() radarRadius, false), material: new Cesium.ColorMaterialProperty( Cesium.Color.RED.withAlpha(0.3) ), height: alt, outline: true, outlineColor: Cesium.Color.RED, }, });雷达波扩散的核心是radius变量随着时间增长到最大值后归零这样子看起来才像扫描周期。我在FxLayerManager里维护了一个scanPhase每次tick里取模计算。如果要做波束状雷达就改成多个扇形primitive按角度偏移绘制。更进阶的雷达波可以画“空心圆柱体”或者旋转的波束面。空心圆柱体用Cesium.PolylineVolumeGraphics或CorridorGraphics都能实现关键是动态修改顶部和底部半径让圆柱像从无人机向下发射的扫描光束。这部分我用的是CallbackProperty加Cesium.Math.lerp平滑变化效果很接近真实雷达扫描。4.3 地形压平与矢量箭头不只是视觉效果还影响航线规划地形压平在无人机模拟里非常实用。很多飞行任务需要无人机在一个相对平坦的区域起降但真实地形可能并不平。Cesium提供的地形压平API可以把指定多边形范围内的地形拉平我基于它加了一个“起降点平整”功能。const flatPolygon new Cesium.PolygonHierarchy( Cesium.Cartesian3.fromDegreesArray([ 120.1, 30.1, 120.105, 30.1, 120.105, 30.105, 120.1, 30.105, ]) ); const area await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, [120.1, 30.1]); const height area[0].height; for (const p of flatPolygon.positions) { cartographic Cesium.Cartographic.fromCartesian(p); p Cesium.Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, height); }矢量箭头则是另一个容易翻车的点。很多人想实现“高德地图那种带方向的路线箭头”但Cesium内置实体没有箭头样式。我的实现思路是用PolylineGeometry加箭头纹理贴图或者用多个三角形手动画出箭头形状。如果想快速出效果可以贴箭头纹理但纹理方向需要和线方向对齐。项目里我最终用的是基于PolylineVolumeGraphics的箭头条横截面宽度动态变化飞行中看起来像流动的指示带。4.4 哪些特效值得用哪些不值我整理了一张取舍表按“对模拟器真实感的贡献/性能开销”排序特效贡献性能开销建议动态光照高中必须开启雷达波扩散环极高极低强烈推荐航线矢量箭头高低推荐热力图中中根据场景决定视频贴图高高只在大屏展示时用高斯泼溅模型高极高谨慎使用高斯泼溅模型是最近比较火的方向Cesium有对应的扩展加载方式。但说实话在无人机模拟器里它不是必需项除非你要展示精细的城市实景扫描模型。热力图也不错适合展示飞行覆盖范围或信号强度分布但如果数据量很大建议先聚合再绘制不要几万个点直接上Cesium.Entity。河流材质、天空盒这些属于锦上添花。河流材质可以用PolylineMaterialAppearance配合自定义shader做流动纹理天空盒直接替换viewer.scene.skyBox就行。如果要做一个“看起来很像飞行模拟软件”的项目天空盒和河流材质能提升质感但优先级排在光照和雷达波之后。5. 坐标偏移、模型漂移、编译分支完整排错链路5.1 模型“飘”了Web墨卡托投影与CGCS2000的纠缠我在开发过程中遇到的最难排查的问题是无人机模型在某个区域飞行时会整体偏移几十米甚至“飘”到路对面。最初我怀疑是模型锚点设置问题检查了半天glTF没毛病。后来拿不同坐标系的底图数据做对比才确认问题出在数据源的投影坐标不一致。Cesium内部使用WGS84椭球和经纬度坐标但很多二维GIS数据用的是EPSG:3857Web墨卡托投影直接把3857坐标当成经纬度传入Cesium画出来的东西当然会“飘”。解决办法是必须在数据入口做坐标转换把3857的x、y除以投影比例尺变成经纬度范围再换算成弧度。我这里加了一个转换函数function webMercatorToCartesian3(x, y) { const lon x / 20037508.34 * 180; const lat (Math.atan(Math.exp(y / 20037508.34 * Math.PI)) * 360 / Math.PI) - 90; return Cesium.Cartesian3.fromDegrees(lon, lat); }如果是CGCS2000投影坐标系的数据需要先拿到该分带的中央经线和假北值做高斯投影反算得到经纬度后再给Cesium。这一步没有现成的通用函数我当时直接用proj4库配合自定义坐标系参数完成。另外如果数据自带的是EPSG:4326经纬度直接传给Cesium就行千万别再转投影。这个排查过程花了我差不多一个下午。现在项目里所有地物数据进来第一件事就是“验证坐标系”我会在调试面板上加载一个已知经纬度的点和底图位置做比对误差超过1米立刻报警。5.2 相机周边加载低精度为什么能看到“加载过程”模拟器飞行速度很快经常出现相机飞到哪里哪里的模型才开始加载的情况。这个现象本质是3D Tiles的Level of Detail策略导致的Cesium会优先加载相机附近的精细瓦片但无人机速度如果达到每秒几百米瓦片加载速度跟不上。我当时的解决思路是“相机周边预加载”设定一个比相机视角更广的预加载包围盒并在飞行动线方向提前请求瓦片。具体操作是监听相机移动事件动态调整viewer.scene.screenSpaceCameraController的视角范围同时给3D Tiles设置一个偏大的maximumScreenSpaceError让远处瓦片先以低精度形式加载出来。const tileset await Cesium.Cesium3DTileset.fromUrl(/data/tileset.json); tileset.maximumScreenSpaceError 16; // 适当调大低精度先顶上来 viewer.scene.primitives.add(tileset);这里有个权衡maximumScreenSpaceError调得越大远处模型越粗糙但加载也越流畅。无人机模拟器追求的是不穿帮的连续感所以我把16作为默认值进入目标区域后再动态降到2到4触发精细加载。这个“动态LOD”的思路比单纯等Cesium自动调度靠谱得多。5.3 强制GroundPrimitive更新与自定义编译分支还有一个高频问题某些地形压平、雷达波覆盖在GroundPrimitive上的贴图移动相机后颜色不刷新或者地形更新后旧的primitive还残留在画面上。这是Cesium的ground primitive缓存机制在作怪它为了性能会缓存渲染结果但动态变更时缓存不会自行失效。遇到这种情况可以在变更后强制更新相关汇合体。官方没有直接提供forceUpdate但可以通过切换primitive的show属性强迫它重新创建更稳的是viewer.scene.requestRender()配合把primitive从集合里移除再重新添加。我的经验是不要依赖内置缓存在每次地形压平改变后销毁旧的ground primitive创建新的。开销不大但能彻底避免那种“改了数据但画面不变”的诡异问题。自定义编译Cesium分支也是很多人会碰到的事。默认npm包是预编译好的功能够用但如果你想调整shader、增加自定义primitive类型或者想改掉一些内置UI行为就得自己编一个分支。编译流程不复杂拉源码、跑npm install、执行npm run combineRelease但要注意Node版本和Python版本匹配我在编译时因为Python版本过高卡了很久。如果只是为了加一个功能更推荐用扩展机制而不是改源码比如自定义MaterialAppearance或写一个CustomShader这样不用承担每次更新Cesium时合并代码的负担。5.4 加载MVT、SVG等非标准数据源无人机飞行模拟器除了地形和模型还经常要叠加实时地理数据比如MVT矢量切片、SVG标注、图片底图。Cesium原生不直接支持MVT一般思路是先解码MVT的矢量瓦片转换成GeoJSON再通过GeoJsonDataSource加载。解码方案可以找现成的库也可以用Mapbox的vector-tile-js。这个过程最大的坑是属性字段的精度丢失MVT里存的是整数坐标需要乘以extent换算成经纬度如果换算系数错了所有要素都会堆到同一个点。SVG作为底图或标注层Cesium也不直接支持。我的做法是把SVG转成Canvas画布再作为SingleTileImageryProvider贴到底图上。注意透明度处理和缩放比SVG默认的viewBox和Cesium的rectangle如果对不齐标注会偏位。视频贴图相对简单用VideoSynchronizer把video元素和Cesium时间同步然后作为图像层贴在某个实体表面。这个功能很适合模拟“无人机实时回传画面”但视频分辨率一定要控制4K视频直接上贴图会把帧率拖到个位数。6. 从模拟器到应用平台后续扩展的几种可行路径6.1 和Vue3、Unreal、Unity的联动如果项目组已经有Vue3前端框架可以直接用vue3-cesium组件库把Viewer封装成组件。但要注意组件化之后不要在Vue的响应式数据里直接存Cesium对象否则每次数据变化都会触发大量的Proxy重写性能瞬间崩掉。正确做法是维护一个非响应式的Viewer实例单例在Vue组件里只通过事件和命令通信。Cesium还有一个比较特殊的方向是Cesium for Unity和Cesium for Unreal。这两个是把Cesium的地球能力嵌入游戏引擎在一个相对传统的3D引擎里渲染全球地形。如果你有Unity或Unreal的团队可以把这套模拟器的状态机逻辑移植过去渲染和UI交给引擎做数据调度继续用Cesium。我在一个城市孪生项目里试过Cesium for Unreal地理围栏绘制很方便但动态光照和雷达波这些特效要另外实现并没有完全继承Web端的方便。6.2 从飞行模拟器到数字化平台的扩展做完基础模拟器后可以往两个方向扩展。一是往“任务规划”方向走加入地理围栏、禁飞区、走廊规划让无人机只在合法区域内飞行。绘制地理围栏和绘制普通矩形的区别在于围栏需要处理海拔高度和地形的关系目前我用的是PolygonHierarchy加动态高度数据实时写入后端。二是往“数据展示”方向走接入热力图、实时视频、传感器数据图表让模拟器变成一个态势感知平台。这两个方向需要的底层能力其实都已经在之前的源码骨架里具备状态机、坐标转换、特效图层管理。新增功能时不需要动核心模块只要在FxLayerManager里加一个“围栏渲染器”在TelemetryDispatcher里加一个数据字段扩展成本很低。顺带提一句热力图的实现如果不想自己写插值可以直接用Cesium.PointPrimitive加颜色渐变或者用heatmap.js生成Canvas再做贴图。我在项目里选了后者因为数据量到了一万以上时实体图元的渲染压力太大而Canvas贴图只需要一张图像。6.3 还会继续踩的坑结合这几周的使用体会再补充几个常见坑不要过度依赖SampledPositionProperty它在长航线采样点很多时会占大量内存优化方式是合并相邻近似点或者分段加载采样数据。雷达波和光照叠加时注意透明度混合模式雷达波扩散环在明亮地表上看不清需要把blendOption调整成Cesium.BlendOption.OPAQUE或半透明。实际项目里通常不需要加载“官方模型”公司自己的无人机模型往往带复杂的PBR材质和动画骨骼Cesium对glTF 2.0支持还可以但遇到多个动画clip时的播放控制并不方便要在模型导出时把动画烘焙掉。如果要在手机上使用不要开太多后处理特效动态光照加雷达波加3D Tiles中端手机帧率会掉到20帧以下。建议在低端设备上自动关闭阴影和热力图图层。这套源码骨架真正值钱的地方在于状态机和流程控制而不是某一条航线或者某一个视觉特效。把仿真循环、数据分发、图层管理层拆对了后面接再多的功能也是往里添砖不用推翻重来。本文还有配套的精品资源点击获取