简介: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 和 Entity |
2.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 设为 0,bottomRadius 设为斜距乘以半角正切,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 着色,效果比拉伸圆柱更直观。
本文还有配套的精品资源,点击获取