ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cesium雷达追踪卫星:从轨道解算到波束建模的完整实现

Cesium雷达追踪卫星:从轨道解算到波束建模的完整实现 简介Cesium雷达追踪卫星是一份结合Vue.js与Cesium的3D空间可视化实战资源面向有一定Web前端基础、希望学习卫星轨道模拟与CZML动态数据解析的开发者。项目以雷达追踪卫星为核心场景通过Cesium在浏览器中渲染三维地球并动态绘制卫星轨迹配合Vue组件实现界面交互与数据联动可用于航天科普展示、卫星状态监控或三维可视化教学。资源包共5个文件包含Vue页面组件、CZML轨道数据文件、JavaScript逻辑脚本、效果预览图与说明文档整体大小约1.16MB结构紧凑、便于快速上手。说明文档对项目功能与文件用途进行了梳理CZML文件可作为学习卫星轨道参数定义和Cesium时间轴动画的参考样本。该资源已有4098人浏览学习适合需要从零理解Cesium与Vue集成流程、掌握三维轨迹追踪与雷达扫描效果的开发者参考也可作为课程设计或技术预研的入门素材。1. Cesium雷达追踪卫星不是加个模型就能跑很多第一次接到“Cesium雷达追踪卫星”需求的人会以为这是画一个圆锥体让它跟着一个卫星图标转。实际做起来你会发现最难的不是三维图形而是让雷达站、卫星轨道、当前时间和波束形状在同一个坐标系与时间基准下工作。Cesium 的三维地球默认使用 ECEF 地固系而 TLE 轨道根数经过 SGP4 传播后先给出 ECI 惯性系位置两边如果没有完成坐标转换卫星会出现在几百公里外的错误位置。我会把轨道解算、波束建模、时间驱动和可见性判断串成一条可落地的实现路径覆盖从验证原型到高频实时更新的常见做法。适合正在做数字孪生、卫星监督、地面站仿真和雷达态势大屏的工程师。2. 轨道解算与坐标统一从TLE到Cesium的世界坐标雷达追踪卫星的第一步不是画图而是回答“卫星此刻在哪个位置”。工程上最常见的轨道数据源是 TLE 两行根数配合 SGP4/SDP4 传播模型外推出任意时刻的位置。Cesium 本身没有轨道传播模块常见的做法是用 satellite.js 在前端完成也可以把 sgp4 的 C 实现编译成 wasm 塞给 Worker但项目迭代阶段没有必要。我一般先选 satellite.js把轨道传播放进 Worker主线程只接收最终的 ECEF 坐标这样后面加多颗卫星也不会撑爆渲染线程。2.1 为什么直接用 TLE SGP4而不是实时坐标接口很多人会问卫星平台不给实时坐标接口吗实时坐标接口通常有网络延迟和接口频控而且当雷达站需要对未来时间点做预报时接口没法返回 5 分钟后的卫星位置。TLE 一次下载本地就可以外推未来几天的轨道更新频率低适合前端持续计算。TLE 里包含轨道历元SGP4 会根据这个历元计算当前时刻的相对位置所以时间基准非常重要TLE 的时间是 UTC不是本地时间也不是 Cesium 默认的日期。satellite.js 的twoline2satrec返回一个 satrec 对象propagate(satrec, date)输出 ECI 直坐标系位置之后还要用格林尼治恒星时把 ECI 旋转到 ECEF才能和 Cesium 的地球坐标对齐。很多波束指向偏了几十度的案例根源都是漏了这一步。2.2 雷达站大地坐标到 ECEF以及站心 ENU 的关系雷达站的部署参数通常是经度、纬度、海拔高度单位是度和米。Cesium 使用地心地固系所以可以直接把这个经纬高转成 Cartesian3。关键点是单位Cesium 使用米satellite.js 的传播结果使用千米。ECEF 的 X 轴指向赤道与本初子午线交点Z 轴指向地球极点与 Cesium 场景坐标一致。以雷达站为原点再看卫星需要一个东北天矩阵用于计算方位角、俯仰角和斜距Cesium.Transforms.eastNorthUpToFixedFrame(stationPos)就能生成这个矩阵后面可见性判断和波束旋转都会依赖它。先看最小可运行的轨道解算代码import * as satellite from satellite.js; import * as Cesium from cesium; // 雷达站经纬高度、度、米 const stationGeo Cesium.Cartographic.fromDegrees(116.39, 39.9, 45.0); const stationPos Cesium.Cartographic.toCartesian(stationGeo); // TLE 两行根数示例采用国际空间站的格式实际项目从数据库读取 const tleLine1 1 25544U 98067A 23001.50000000 .00016717 00000-0 30700-3 0 9993; const tleLine2 2 25544 51.6400 150.0000 0006000 60.0000 60.0000 15.50000000 1000; const satrec satellite.twoline2satrec(tleLine1, tleLine2); function getSatEcef(date) { const pv satellite.propagate(satrec, date); if (!pv.position) { throw new Error(TLE 传播失败根数过期或超出外推范围); } const gmst satellite.gstime(date); const ecf satellite.eciToEcf(pv.position, gmst); // 千米转米得到 Cesium 场景坐标 return new Cesium.Cartesian3(ecf.x * 1000.0, ecf.y * 1000.0, ecf.z * 1000.0); }这段代码里satrec只需要初始化一次propagate每帧调用时传入一个 UTC 的 Date。eciToEcf的作用是完成惯性系到地固系的旋转旋转量就是用gstime(date)算出的格林尼治恒星时。如果跳过这一步卫星在 Cesium 里的位置会随着地球自转产生偏差波束自然对不上。有了 ECEF 坐标再用 satellite.js 计算雷达站看向卫星的方位角、俯仰角、斜距const enuMatrix Cesium.Transforms.eastNorthUpToFixedFrame(stationPos); const observerGd { longitude: stationGeo.longitude, // 弧度 latitude: stationGeo.latitude, height: stationGeo.height / 1000.0 // 千米 }; const ecf { x: satPos.x / 1000.0, y: satPos.y / 1000.0, z: satPos.z / 1000.0 }; const look satellite.ecfToLookAngles(observerGd, ecf); const az Cesium.Math.toDegrees(look.azimuth); // 方位角度 const el Cesium.Math.toDegrees(look.elevation); // 俯仰角度 const range look.range * 1000.0; // 斜距米这里的observerGd是 satellite.js 约定的输入结构longitude 和 latitude 必须是弧度height 是千米和 Cesium 的 Cartesian3 单位不一样。az/el 后面会给波束旋转使用。为了方便理解下面这张表列出了这条链路里常见的坐标系关系。坐标系特征单位在追踪链路中的角色ECI/TEME惯性系不随地球自转千米SGP4 传播后的原始输出ECEF/ECF随地球自转固定在地球上千米Cesium 场景的底层坐标基准ENU以雷达站为原点的东北天米计算方位、俯仰、遮挡判断Cesium Cartesian3本质是 ECEF米上传给 Primitive 和 Entity2.3 高频更新时把传播塞进 Worker追踪场景通常一帧要更新几十甚至几百个位置点。我不建议在requestAnimationFrame或者clock.onTick里直接大量调用propagate因为 satellite.js 内部有较多浮点运算和临时对象。常见做法是建立一个 Worker初始化时载入 TLE主线程把 UTC 时间 post 进去Worker 返回米单位的 ECEF// worker.js importScripts(satellite.min.js); let satrec null; self.onmessage (e) { const msg e.data; if (msg.type init) { satrec satellite.twoline2satrec(msg.line1, msg.line2); } else if (msg.type propagate satrec) { const date new Date(msg.time); const pv satellite.propagate(satrec, date); const gmst satellite.gstime(date); const ecf satellite.eciToEcf(pv.position, gmst); self.postMessage({ x: ecf.x * 1000, y: ecf.y * 1000, z: ecf.z * 1000 }); } };主线程初始化后可以每帧发送时间戳。使用 Worker 还能避免propagate失败时在主线程抛异常导致整个场景卡死。需要注意多个 TLE 源的历元可能不一致千万不要把两个不同预报机构、不同历元时间的 TLE 混在同一个 satrec 流程里否则卫星会随机偏移。注意TLE 根数更新不是越频繁越好统一数据来源比追求“最新”更重要。混用根数会让轨道在切换时出现肉眼可见的跳变。3. 雷达波束建模在 Cesium 里画一个会追踪的圆锥坐标统一后可以画波束了。雷达波束在三维上的简化是一个圆锥体顶点在雷达站天线中轴指向卫星圆锥顶角为波束宽度。Cesium 里表示这个锥体有两条路线先用 Entity 快速验证再用 Primitive 自建几何做性能版本。我一般会把这两步分开因为第一版用 Entity 能很快看出坐标系和单位的问题等算法稳定后再优化渲染路径。3.1 先用 Entity 的 cylinder 验证波束指向Cesium 的Entity.cylinder支持 topRadius、bottomRadius、length 和 slices。把 topRadius 设为 0bottomRadius 设为斜距乘以半角正切length 设为斜距就能得到一个锥体。它的本地轴是 cylinder 的轴需要用 orientation 把轴转到卫星方向。下面这段代码可以贴在初始化流程里const beamEntity viewer.entities.add({ position: stationPos, orientation: Cesium.Transforms.headingPitchRollQuaternion( stationPos, new Cesium.HeadingPitchRoll( Cesium.Math.toRadians(az), Cesium.Math.toRadians(-el), // 正负取决于模型方向跑一次看偏哪边再翻 0 ) ), cylinder: { length: range, topRadius: 0, bottomRadius: range * Math.tan(Cesium.Math.toRadians(2.0)), slices: 32, material: Cesium.Color.fromCssColorString(#00ff88).withAlpha(0.3) } });length 和 bottomRadius 的单位都是米若斜距是 500 公里length 就是 500000。slices控制圆周细分32 足够过大只会增加三角面数。运行后如果锥体不在卫星方向优先检查 az 和 el 的取值基准其次是 HPR 的符号约定这两个问题占了“波束对不上”的绝大多数情况。3.2 用 Primitive 自建锥体更新时只换 modelMatrix稳定之后把 Entity 换成 Primitive。Cesium 的CylinderGeometry可以在本地坐标里创建一个沿 Y 轴伸展的锥体。我们把几何体只创建一次之后通过 modelMatrix 平移到雷达站并旋转到卫星方向这样渲染线程不会反复重建几何体和材质。const range 150000; // 初始斜距米 const halfAngle Cesium.Math.toRadians(2.0); const geometry new Cesium.CylinderGeometry({ length: range, topRadius: 0, bottomRadius: range * Math.tan(halfAngle), slices: 64 }); const instance new Cesium.GeometryInstance({ geometry: geometry, attributes: { color: Cesium.ColorGeometryInstanceAttribute.fromColor( Cesium.Color.fromCssColorString(#00a8ff).withAlpha(0.35) ) } }); const beamPrimitive new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.PerInstanceColorAppearance({ transparent: true, flat: false }), releaseGeometry: false }); viewer.scene.primitives.add(beamPrimitive);PerInstanceColorAppearance要求每个实例必须有 color attribute所以我在GeometryInstance里通过ColorGeometryInstanceAttribute传入了颜色和透明度。flat: false会计算法线光照让锥体有立体感对性能敏感的大屏可以设成flat: true关闭光照。releaseGeometry: false表示几何体继续由实例持有不要被 Primitive 自动释放。更新函数不需要猜 HPR 的符号直接构造一个旋转矩阵把本地 Y 轴对齐到卫星方向即可function rotateYToDirection(direction) { const yAxis new Cesium.Cartesian3(0, 1, 0); const dirNorm Cesium.Cartesian3.normalize(direction, new Cesium.Cartesian3()); const dot Cesium.Cartesian3.dot(yAxis, dirNorm); if (Math.abs(dot) 0.9999) { return dot 0 ? Cesium.Matrix3.IDENTITY.clone() : Cesium.Matrix3.fromRotationY(Cesium.Math.PI); } const axis Cesium.Cartesian3.cross(yAxis, dirNorm, new Cesium.Cartesian3()); const angle Math.acos(Cesium.Math.clamp(dot, -1.0, 1.0)); return Cesium.Matrix3.fromQuaternion( Cesium.Quaternion.fromAxisAngle(axis, angle) ); } function updateBeam(satPos) { const dir Cesium.Cartesian3.subtract(satPos, stationPos, new Cesium.Cartesian3()); const rotation rotateYToDirection(dir); beamPrimitive.modelMatrix Cesium.Matrix4.fromRotationTranslation( rotation, stationPos ); }这里的关键是把锥体的本地 Y 轴当作波束中轴。CylinderGeometry的轴就是 Y 轴所以旋转矩阵能将锥体精准地压到雷达站与卫星的连线上。direction长度没有影响反正会被归一化真正的距离由几何体的 length 决定。建议这个updateBeam不要每帧调用控制在每 100 到 200 毫秒一次即可锥体旋转在视觉上依然平滑CPU 压力却小很多。提示如果波束锥体在你屏幕上“横着飞”先检查CylinderGeometry的轴向与rotateYToDirection的对应关系再检查几何体的 length 是否过大导致相机裁切。大多数时候不是数学错而是单位或者轴向搞混了。3.3 站点标点和距离环比波束更值得优先实现雷达追踪的可视化里波束并不是最耗性能的部分。很多团队先画波束忘了画雷达站点和距离环导致用户无法判断波束的起点和威力范围。我一般用PointPrimitiveCollection画站点用PolylineCollection画距离环。PointPrimitive 是 GPU 批量绘制的点适合同时显示多个站点。距离环可以用PolylineGeometry画一个 64 段的圆环放在PolylineCollection里以雷达站为中心绘制 50km、100km 等刻度。如果雷达站本身是 3D Tiles 里的模型节点波束的起点不能直接取站点经纬高最好取模型绑定点或者真实天线面板的世界坐标否则波束会从模型底座穿出来。另一点是开启 Cesium 动态光照后Primitive 的材质会受场景光照影响想让波束保持清晰可以给 appearance 单独指定 uniform 颜色别去蹭场景灯光。4. 时间同步与性能排错别让卫星抖成直线前面代码写得通但接上实时数据后追踪画面最常见的问题是卫星位置一跳一跳或者波束跟了一段后突然停在某个地方。多数不是算法错了而是时间基准没有统一。Cesium 场景有一个 Clock默认从当前日期开始走但如果我们用clock.currentTime驱动传播又在别处用Date.now()两边就会出现微小偏差看起来就是抖动。我一般先把 Cesium 时钟固定成 UTC 同步源。4.1 让 Cesium 时钟和 UTC 对齐用 clock.onTick 驱动追踪初始化场景时把 clock 设为系统当前时间并挂一个 onTick 更新函数viewer.clock.shouldAnimate true; viewer.clock.currentTime Cesium.JulianDate.fromDate(new Date()); viewer.clock.multiplier 1; viewer.clock.clockRange Cesium.ClockRange.CLAMPED; viewer.clock.canAnimate true; let enuMatrixInv null; if (!enuMatrixInv) { enuMatrixInv Cesium.Matrix4.inverse(enuMatrix, new Cesium.Matrix4()); } viewer.clock.onTick.addEventListener((clock) { const date Cesium.JulianDate.toDate(clock.currentTime); const satPos getSatEcef(date); const local Cesium.Matrix4.multiplyByPoint( enuMatrixInv, satPos, new Cesium.Cartesian3() ); const az Math.atan2(local.x, local.y); const el Math.atan2(local.z, Math.hypot(local.x, local.y)); updateBeam(satPos); });在这里我不再重复用 satellite.js 的ecfToLookAngles而是直接用 ENU 矩阵把卫星坐标转到雷达站东北天坐标。atan2(local.x, local.y)得到方位角atan2(local.z, hypot(local.x, local.y))得到俯仰角。两种方式都可以但用 ENU 矩阵的好处是后续做地形遮挡判断可以直接复用同一个矩阵。clock.multiplier设为 1 表示按真实时间前进如果做“未来预报”可以让它大于 1但 TLE 外推时间过长会导致 SGP4 精度快速下降建议以不超过 24 小时为界。4.2 更新频率拆成两档位置每帧波束每 200ms继续优化性能。卫星位置变化很快但波束锥体这种大尺度几何对角度不太敏感。我一般把更新拆成两档卫星实体位置在 onTick 里每帧更新波束 modelMatrix 用一个lastBeamTime控制只在时间差大于 200ms 时才重新计算。let lastBeamUpdate 0; function onFrame(date) { const satPos getSatEcef(date); updateSatelliteEntity(satPos); const now Cesium.JulianDate.toDate(date).getTime(); if (now - lastBeamUpdate 200) { updateBeam(satPos); lastBeamUpdate now; } }这样做的原因是updateBeam里Matrix3.fromQuaternion和Matrix4.fromRotationTranslation都会分配临时对象如果每帧调用JavaScript GC 的频率会明显上升最终表现为页面越来越卡直到崩溃。另一个经验是不要在clock.onTick里不断创建新的Cartesian3尽量复用已有的对象或者在计算完成后再赋值给 Entity 的 position。4.3 常见崩溃与坐标漂移排查我在几个项目里遇到过 Cesium 3D 地球滚动一段时间后崩溃或者雷达波束飘到海里的问题。原因各不相同但可以列成一张排查表现象典型原因处理方式卫星每隔几秒跳一次TLE 更新时间与系统时间不同步或使用了本地时区统一使用 UTC检查new Date().toISOString()波束指向完全相反ECI/ECEF 未转换或只做了单位换算确认eciToEcf与gstime已执行波束偏离目标 1~3 度HPR 的 pitch 符号不对或几何体预旋转缺失用向量旋转方式替代 HPR修正轴向拖动地球后页面崩溃相机无限缩放触发瓦片加载过多设置最小/最大相机距离长时间运行内存持续上涨onTick 里不断 new 对象Geometry 被释放复用 Primitive控制更新频率再配合一组相机约束能有效避免“Cesium 3d地球滚动遇到崩溃”这类问题const controller viewer.scene.screenSpaceCameraController; controller.minimumZoomDistance 5; controller.maximumZoomDistance 10_000_000; controller.enableCollisionDetection true;这里没有打开depthTestAgainstTerrain。如果你打开它波束会被地形遮挡这不是 Bug而是正确的深度效果但如果你只想快速看波束是否穿过山体就不要打开该选项。用地形射线做遮挡判断是更可控的方案下一章就用得到。5. 从“看见”到“判别”雷达距离方程与可见窗口事件追踪不是目的判别才是。雷达能否看到这颗卫星除了几何可见外还取决于回波信噪比。一个简化的雷达距离方程可以写成Rmax ((Pt * Gt * Gr * λ^2 * σ) / ((4π)^3 * k * Te * Bn * L * S/Nmin))^(1/4)前端通常不需要自己算这套参数但从后端拿到 Rmax 后就能过滤波束长度同时把“可见”的阈值从几何可见提升到物理可见。Cesium 侧的常见做法是只有当仰角大于最低仰角、斜距小于 Rmax并且地球不遮挡时才把追踪状态从“搜索”切换为“跟踪”。下面的函数用 ENU 矩阵计算仰角再用射线判断是否存在地形遮挡function computeVisibility(satPos, minElDeg, maxRange) { const local Cesium.Matrix4.multiplyByPoint( enuMatrixInv, satPos, new Cesium.Cartesian3() ); const el Cesium.Math.toDegrees( Math.atan2(local.z, Math.hypot(local.x, local.y)) ); const slantRange Cesium.Cartesian3.distance(stationPos, satPos); return el minElDeg slantRange maxRange !isOccluded(satPos); } function isOccluded(satPos) { const dir Cesium.Cartesian3.subtract(satPos, stationPos, new Cesium.Cartesian3()); const ray new Cesium.Ray(stationPos, dir); const hit viewer.scene.globe.pick(ray, viewer.scene); if (!hit) return false; return Cesium.Cartesian3.distance(stationPos, hit) Cesium.Cartesian3.distance(stationPos, satPos); }enuMatrixInv在初始化时缓存避免每帧矩阵求逆。globe.pick会沿射线找最远的地形交点如果交点比卫星近说明卫星被地形遮挡。若你的雷达要观测低轨卫星这个判断比简单判断仰角大于 0 更重要。需要注意globe.pick默认拾取的是地形或椭球对 3D Tiles 模型没有反应如果站址附近有高大的建筑模型要改用viewer.scene.pickFromRay并传入Scene的地物列表。当可见性状态发生变化时可以触发事件。例如进入跟踪窗口时记录时间离开时计算弧段时长let prevVisible false; function updateVisibility(date) { const visible computeVisibility( satEntity.position.getValue(date), minElevation, Rmax ); if (visible !prevVisible) { console.log(track-start ${date.toISOString()}); // 调用告警 API记录进入跟踪时间 } prevVisible visible; }这样就把“Cesium雷达追踪卫星”从纯三维可视化推进到了业务决策层。更进一步可以把未来 24 小时的轨道采样成一组可见窗口再用 CZML 加载到 Cesium 3D 地球里做回放配合历史卫星影像或高程数据判断雷达站选址是否被遮挡。如果还需要输出波束覆盖范围内的卫星轨迹热力图可以把卫星经过的时间和仰角写入Cesium.CustomDataSource用PointPrimitiveCollection按信噪比或 RCS 着色效果比拉伸圆柱更直观。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进