前言
2026年上半年,我主导完成了一个"卫星网络仿真可视化平台"的前后端全栈开发。
这是一个面向卫星网络架构设计与评估的专业工具------它能在三维地球上实时渲染成千上万颗卫星组成的多层星座、星间/星地通信链路、地面信关站与用户终端,支持仿真回放、任务链推演和网络架构优化三大核心业务场景。
回头看这段开发历程,从 3D 渲染的性能优化到数据库的分表策略,从实时通信的协议设计到场景渲染的分层架构------每一步都值得记录。

一切的起点
故事的开始,源于一个朴素的需求:在浏览器里看到卫星网络的运行状态。
传统的卫星网络仿真大多依赖离线计算与静态图表输出,缺乏直观的可视化手段。我们需要的是一个可交互、可回放、可优化的 Web 端可视化平台。
技术选型上,Cesium 是唯一能在浏览器中渲染真实地球和太空的开源方案。但 Cesium 只提供了"画布",离一个完整的卫星网络仿真平台还有很远------我们需要的是:
- 成千上万颗卫星的轨道计算与实时渲染
- 星间/星地链路的动态连线
- 业务流的多跳路径可视化
- GB 级仿真数据的存储、查询与推送
- 网络架构优化的版本对比与交互
这就是整个项目的起点------把一个专业仿真工具搬上 Web。
整体技术架构
项目采用了前后端分离架构,中间通过 HTTP API + WebSocket 双通道通信。
前端:Vue 3 + TypeScript + Vite 构建,以 Cesium 为 3D 地球引擎,Pinia 管理业务状态,ECharts/D3 支撑数据图表。
后端:NestJS + TypeORM + MySQL,通过 WebSocket 网关实现仿真数据实时推送,支持 Docker 容器化部署。
共享层 :通过 npm 包 snss-types 实现前后端类型定义一致,避免接口对接中的类型漂移。

前端:从 Pinia 到 Class-Based Store 的架构演进
做复杂可视化应用,状态管理是第一个要解决的问题。
最初我们使用标准的 Pinia defineStore,将卫星、节点、链路等数据全部存入 Pinia state。随着状态字段膨胀到 50+ 个、数据量达到 10000+ 条时,一个问题逐渐凸显:深度响应式开销过大。
Vue 的 reactive 会递归追踪数组中每个元素的每个字段,当 watch 开启 deep: true 时,性能急剧下降。而仿真场景中数据更新频率极高------时间轴每帧都要刷新链路拥塞状态、业务流路径。
于是我们做了一次重大重构------Class-Based Store ,用 ref/shallowRef 替代 reactive,精确控制响应式粒度。
typescript
// 核心设计:get/set 自动解包,外部调用无感知
class BusinessClassStore {
// 基本类型用 ref
private _currentCaseId = ref<string>('')
get currentCaseId() { return this._currentCaseId.value }
set currentCaseId(v: string) { this._currentCaseId.value = v }
// 数组用 shallowRef 避免深层响应式
private _satelliteList = shallowRef<SatelliteItem[]>([])
get satelliteList() { return this._satelliteList.value }
set satelliteList(v: SatelliteItem[]) { this._satelliteList.value = v }
}
export const businessStore = new BusinessClassStore()
关键设计决策:
- 双层响应式 :基础类型用
ref,大数组用shallowRef,只有整体替换才触发更新------action 中始终用this.linkList = [...]整体赋值 - get/set 拦截器 :外部调用
businessStore.satelliteList直接拿值,无需.value,API 完全兼容原 Pinia store - 浅监听体系 :9 个关键组件共 30+ 个
watch全部使用浅监听(如watch(() => store.linkList, ...)),配合 action 中整体重新赋值,既保证了渲染响应,又避免了深层追踪的性能损耗 - 模板不受影响 :Pinia state 本身是
reactive的,模板v-for仍能正确响应push/splice,但 watch 浅监听要求 action 整体替换
这套方案在保持开发者体验不变的前提下,将状态变更的响应式追踪成本降到了最低。
前端:双模式 3D 渲染引擎的性能优化
卫星网络可视化最大的挑战在于量级------成千上万颗卫星在三维地球上的实时位置更新。
Cesium 提供了两种渲染模式,我们同时做了实现,让用户在不同数据量下自由切换:
模式一:Entity 模式(精细渲染)
适合少量卫星的高精度展示:
javascript
// 每个卫星是独立的 Entity,支持 billboard + model + label + path
const entity = dataSource.entities.add({
position: SampledPositionProperty, // 拉格朗日插值
billboard: { image, scale },
model: { uri: gltfModel }, // 3D 模型
label: { text, font },
path: { material, width }, // 轨道线
})
// 卫星姿态随速度方向自动调整
entity.orientation = new VelocityOrientationProperty(entity.position)
- 使用
SampledPositionProperty+LagrangePolynomialApproximation实现平滑插值 VelocityOrientationProperty让卫星始终"头朝前飞",视觉效果专业- 支持卫星传感器视椎体(
SatelliteSensor),可展示卫星对地覆盖范围
模式二:Primitive 批量渲染
针对成千上万颗卫星的高性能场景,关键在于批量坐标更新 和轨道计算性能。
Primitive 批量坐标更新流程
Cesium 的 BillboardCollection 和 LabelCollection 是高性能渲染的关键------它们将成千上万个点/标签打包为一次 GPU DrawCall,而非逐元素渲染。
整个批量坐标更新分为三步:
Step 1:初始化时批量创建 Primitive,位置先用零向量占位
javascript
// BillboardCollection + LabelCollection 各一次 DrawCall 渲染全部卫星
const sateBillboardCollection = new Cesium.BillboardCollection({
modelMatrix: new Cesium.Matrix4(), // 共享变换矩阵
})
const sateLabelCollection = new Cesium.LabelCollection({
modelMatrix: new Cesium.Matrix4(),
})
for (const item of satelliteList) {
// 每个卫星一个 Billboard,初始位置填 (0,0,0)
const billboard = sateBillboardCollection.add({
id: item.id,
image: 'resources/satellite/icons/icon1.png',
position: Cesium.Cartesian3.ZERO, // 零向量占位
scaleByDistance: new Cesium.NearFarScalar(1e4, 1, 1e8, 0.5),
})
sateBillboardMap[item.id] = billboard
// 同步创建 label...
}
Step 2:启动自研轨道外推库,初始化实时传播器
javascript
import * as KBSatellite from 'kbe3d-satellite'
// 传入全体卫星的 TLE 参数,返回实时传播控制器
const liveControl = await KBSatellite.sgp4OrbitLive(satelliteList, {
isGetDetails: false, // 不返回速度/经纬度,减少计算量
isUseGPU: false, // 可切换 GPU 模式
})
Step 3:60ms 定时器批量更新所有 Primitive 的坐标
javascript
// 预分配 scratch 对象,复用避免 GC
const sateTempCart3 = {} // { satelliteId: Cartesian3 }
function updateSatelliteLive() {
// 1) 从自研轨道库批量获取当前时刻所有卫星的 ECI 坐标
liveControl.update(currentTime).then((res) => {
// 2) 计算 ICRF → Fixed 旋转矩阵(只需算一次!)
icrfToFixed = Cesium.Transforms.computeIcrfToFixedMatrix(now)
// 3) 遍历所有卫星,仅更新 ICRF 坐标(不转换到 ECF)
res.forEach((value, key) => {
const pos = value.positionEci // 单位:km
sateTempCart3[key].x = pos.x * 1000
sateTempCart3[key].y = pos.y * 1000
sateTempCart3[key].z = pos.z * 1000
sateBillboardMap[key].position = sateTempCart3[key]
sateLabelMap[key].position = sateTempCart3[key]
})
// 4) 一次赋值 modelMatrix,完成全体坐标变换!
// Cesium GPU 侧自动将 ICRF 坐标旋转到 ECF
sateBillboardCollection.modelMatrix
= Cesium.Matrix4.fromRotationTranslation(icrfToFixed)
sateLabelCollection.modelMatrix
= Cesium.Matrix4.fromRotationTranslation(icrfToFixed)
})
}
// 60ms 定时器(约 16fps 更新频率,视觉上完全平滑)
setInterval(updateSatelliteLive, 60)
为什么这样设计?
ICRF 坐标 + 共享 modelMatrix 是核心优化。SGP4 轨道外推输出的天然是 ICRF(地心惯性系)坐标。常规做法是逐颗卫星做 ICRF→ECF 矩阵乘法转换------26000 颗卫星意味着每帧 26000 次 3×3 矩阵乘法。
我们利用 Cesium 的 modelMatrix 机制:将所有卫星位置保持在 ICRF 坐标系,把 ICRF→ECF 旋转矩阵整体赋值给 Collection 的 modelMatrix,Cesium 的 GPU 着色器会自动对 Collection 内所有点做坐标变换。这样原本 26000 次 CPU 矩阵乘法变成了 1 次 CPU 赋值 + 1 次 GPU 并行变换。
scratch 对象预分配 是另一大优化。sateTempCart3 在初始化时一次性创建所有卫星的 Cartesian3 占位对象,后续更新只修改其 x/y/z 分量,全程零 GC。链路渲染中也大量使用模块级 _scratchCarto1、_scratchGeodesic 等预分配对象。
实测效果:在万星场景下,Primitive 模式渲染帧率稳定在 60fps 以上(浏览器刷新率上限),CPU 占用相比 Entity 模式降低 80% 以上。
前端:useMapScene 架构 ------ 分层组合 + 依赖注入
3D 场景的渲染逻辑极其复杂------卫星轨道计算、节点位置更新、链路动态连线、业务流路径变化、时间轴帧数据驱动------如果把这些全部塞进一个文件,不出意外会变成一个 5000+ 行的"上帝函数"。
我们的设计是分层组合 + provide/inject 依赖注入。
整体架构
scss
useMapScene() ← 主入口,编排子模块
├── useMapSceneSatellite() ← 卫星渲染(Entity + Primitive 双模式)
├── useMapSceneNode() ← 地面节点渲染
├── useMapSceneLink() ← 星间/星地链路渲染
├── useMapSceneTaskFlow() ← 任务链/业务流渲染
└── useMapSceneTimeline() ← 时间轴帧数据驱动引擎
每个子模块是独立的 composable,返回 render/clear/pick/toggle 等方法。useMapScene 负责编排调用顺序和依赖注入。
依赖注入设计
子模块之间存在依赖关系------业务流需要获取卫星和节点的当前位置来绘制路径,时间轴引擎需要同时驱动卫星、节点、链路、业务流四个模块的更新。
我们通过 Vue 的 provide/inject 实现模块间解耦:
javascript
// useMapScene.tsx --- 主入口组合各模块
export async function useMapScene() {
// STEP 1: 卫星模块
const mapSceneSatellite = useMapSceneSatellite()
const { renderSatellites, clearSatellites, pickSatelliteById, toggleShowSatellite, ... }
= mapSceneSatellite
provide('pickSatelliteById', pickSatelliteById)
provide('toggleShowSatellite', toggleShowSatellite)
// ...
// STEP 2: 节点模块
const mapSceneNode = useMapSceneNode()
const { renderNodes, clearNodes, pickNodeById, toggleShowNode, ... }
= mapSceneNode
provide('pickNodeById', pickNodeById)
provide('toggleShowNode', toggleShowNode)
// ...
// STEP 3: 链路模块
const mapSceneLink = useMapSceneLink()
// ...
// STEP 4: 任务流模块(需要卫星+节点模块引用)
const mapSceneTaskFlow = useMapSceneTaskFlow({
satellite: mapSceneSatellite, // 直接传入引用
node: mapSceneNode,
})
// STEP 5: 时间轴引擎(需要全部四个模块引用)
const mapSceneTimeline = useMapSceneTimeline({
satellite: mapSceneSatellite,
node: mapSceneNode,
link: mapSceneLink,
taskFlow: mapSceneTaskFlow,
})
}
注意两种注入方式的选择:
- 面板控制方法 (
toggleShowSatellite等)用provide/inject------由左侧面板组件通过inject获取后绑定到开关按钮 - 模块间引用 (
mapSceneSatellite等)用直接传参 ------useMapSceneTaskFlow和useMapSceneTimeline通过参数获取兄弟模块的引用,避免用inject构造循环依赖
渲染编排:一个起点,五个步骤
场景渲染的入口只有一个 renderMapScene(),按顺序驱动所有子模块:
javascript
async function renderMapScene() {
await clearMapScene()
sceneControl.setPlayStep(1)
await sceneControl.setTimeline(startTime, endTime, clockRange)
// 按序渲染:卫星 → 节点 → 链路 → 任务流 → 时间轴
await renderSatellites(isUsePerformace) // 卫星必须先渲染(链路需要其位置)
await renderNodes(isUsePerformace) // 节点其次(链路也需要其位置)
await renderLinks() // 链路依赖卫星+节点位置
await renderTaskFlow() // 任务流依赖链路数据
await renderTimeline() // 时间轴引擎最后启动,驱动全部
}
渲染顺序很关键:链路渲染需要卫星和节点的位置信息,所以卫星和节点必须在链路之前完成;时间轴引擎作为"总调度",必须在其余四个模块就绪后才能启动。
点击事件分流
Cesium 的 3D 场景只有一个全局点击事件,但需要分流到卫星详情、节点详情、链路详情、业务流详情四个不同的面板。我们在 useMapScene 中统一监听,根据拾取目标的 ID 前缀分流:
javascript
function bindMapClickEvent(e) {
const primitive = e.data.scenePicked?.primitive
const id = primitive?.id
if (id?.startsWith('satellite-')) {
pickSatellitePrimitive(primitive) // 触发卫星拾取 → 飞行动画
emitter.emit('卫星详情', id) // 事件总线通知右侧面板
}
else if (id?.startsWith('node-')) {
pickNodeEntity(entity)
emitter.emit('节点详情', id)
}
else if (id?.startsWith('link-')) {
pickLinkEntity(entity)
emitter.emit('链路详情', id)
}
// ...
}
这样,3D 场景的交互逻辑完全封装在 useMapScene 内部,上层组件只需监听 mitt 事件总线即可响应。
这套架构的核心价值在于:每个模块独立开发、独立测试 。新增一种渲染元素(比如"干扰区域可视化"),只需要新建一个 useMapSceneInterference.tsx,在 useMapScene 中注册即可,不会影响已有模块。
让大量卫星/节点/链路变得"有辨识度",是一个被低估的难题。
我们设计了一套三层继承 + extend 覆盖的样式系统:
arduino
第 1 层:类型基础样式(由数据字段决定)
├── 卫星:遥感卫星 > GEO 层 > MEO 层 > LEO 层 > 默认
├── 节点:信关站 > 测控中心 > 其他
└── 链路:星间链路 > 星地链路 > 馈电链路
第 2 层:单元素个性样式(item.style)
└── 覆盖第 1 层同名属性
第 3 层:extend 动态覆盖(由版本对比计算)
├── ADD:绿色闪烁 + "【新增】"标记
├── REMOVE:红色渐变淡出 + "【删除】"标记
├── PLUS:黄色闪烁 + "【扩容增量:{mbps}】"标记
├── HIGH:红色闪烁 + "【高亮】"标记
├── congestion_0/1/2/3:绿/黄/橙/红四级拥塞色
└── down:灰色不可用状态
样式合并算法:
javascript
actionSetConfigStyle(items) {
const baseStyle = config[item.satelliteType]?.[item.layer] ?? defaultStyle
const result = cloneDeep(baseStyle) // 深拷贝防止污染全局配置
// 合并 item 自身样式
if (item.style) merge(result, item.style)
// 合并 extend 覆盖(ADD/REMOVE/PLUS/HIGH)
if (item.extend) {
for (const extend of item.extend) {
if (extend.type === 'ADD') merge(result, addOverlayStyle)
if (extend.type === 'REMOVE') merge(result, removeOverlayStyle)
// ...
}
}
// 应用到各图形类型
applyPointStyle(result.point)
applyBillboardStyle(result.billboard)
applyLabelStyle(result.label)
applyPolylineStyle(result.polyline)
}
这套系统的精髓在于:版本 diff 自动驱动样式变化 。当用户点击"回退至上一版本"时,新旧数据 diff 自动生成 ADD/REMOVE/PLUS 标记,场景中的卫星/链路自动变色------用户一眼就能看到"这一步优化增删了什么"。
后端:动态分表与大数据量处理
仿真数据有个特点:单个案例小,但时间轴大得离谱。
一个典型场景的 timeline.jsonl(时间轴数据)可能达到数 GB,包含数千帧、每帧数千条链路状态记录。传统的"一张大表存所有"方案不可行------单表膨胀到几千万行后查询会慢到不可接受。
我们的方案是动态分表(Sharding by case + scene):
arduino
MySQL my_db/
├── model_entity ← 固定表(模型元数据)
├── ste_{case_id}_{scene_id} ← 按 case+scene 动态创建的 task 表
├── sfe_{case_id}_{scene_id} ← 按 case+scene 动态创建的 flow 表
└── sle_{case_id}_{scene_id} ← 按 case+scene 动态创建的 timeline 表
表的生命周期管理
javascript
// 上传案例时自动建表
async uploadCaseZipFile(file) {
const { caseId, scenes } = await parseZipFile(file)
for (const scene of scenes) {
const tableName = `sle_${caseId}_${scene.id}` // 超过64字符自动 MD5
await createTableFromTemplate(SceneTimelineEntity, tableName)
await bulkInsert(tableName, scene.timelineData, 10) // JSONL 流式读取
}
// 更新缓存
await initCaseCache()
}
// 删除案例时自动删表
async deleteCaseById(caseId) {
const tables = await getTablesByPrefix(`%${caseId}%`)
for (const table of tables) {
await queryRunner.dropTable(table)
}
await removeCaseFiles(caseId)
}
流式读取避免内存爆炸
对于 GB 级的 timeline.jsonl,使用 Node.js readline 逐行读取:
javascript
const rl = readline.createInterface({
input: fs.createReadStream(timelinePath),
crlfDelay: Infinity,
})
let batch = []
for await (const line of rl) {
batch.push(JSON.parse(line))
if (batch.length >= 10) {
await repository.insert(batch) // 批量插入
batch = []
}
}
这样,无论文件多大,内存占用始终可控。
后端:WebSocket 实时态势与房间调度
除了离线仿真回放,平台还支持实时态势模式------仿真计算平台持续推送数据,前端实时渲染。
我们的 WebSocket 网关借鉴了信令服务器的设计,实现了房间 + Provider 模式:

核心协议设计
javascript
// 客户端角色注册
{ type: 'register', role: 'frontend' | 'provider' }
// 服务端动态分配场景给 provider
{ type: 'assign_scene', caseId, sceneId }
// provider 推送实时帧数据
{ type: 'push_timeline_data', caseId, sceneId, frameNo, data }
// 服务端广播给同房间的所有 frontend
{ type: 'timeline_update', frameNo, data }
容错机制
- provider 断线自动恢复:provider 重连后,服务端自动重新分配所有活跃场景
- 房间生命周期管理:所有 frontend 离开后自动通知 provider 停止计算
- EventEmitter 桥接:文件上传进度通过 NestJS EventEmitter 从 HTTP Controller 传递到 WebSocket Gateway,实现实时进度推送
scss
HTTP Upload → Controller → EventEmitter.emit() → WebSocket Gateway → 广播进度
前后端类型共享:从独立 npm 包到 Monorepo 的路径
前后端分离项目最常见的痛点是:接口类型不一致导致的生产事故。
前端说 CaseItem.caseName,后端返回 case_name;前端期望 number,后端返回 string------这类问题在对接阶段层出不穷。
现状:独立 npm 类型包
我们在后端仓库 src/types/ 中统一维护类型定义,通过 tsup 编译为 .d.ts 声明文件,发布为 snss-types npm 包,前端 package.json 中直接依赖:
css
后端 src/types/
├── index.ts ← 全部类型导出
├── case.ts ← CaseItem, OptimizationTrace, CaseJsonData...
└── scene.ts ← SatelliteItem, NodeItem, LinkItem, FlowItem, TimelineFrame...
│
▼ tsup 编译
dist-types/
└── index.d.ts ← 纯类型声明文件
│
▼ npm publish
snss-types@1.0.11
│
└── 前端 package.json: "snss-types": "^1.0.11"
任何接口字段的增删改都会在编译期暴露,不会等到运行时才发现不匹配。
为什么要这样设计?
说实话,这是一个过渡方案。
项目启动时,我们的定位只是一个前端可视化平台,后端功能全部由第三方计算平台通过 WebSocket 推送。因此初期并没有规划后端工程,自然也不会考虑 Monorepo。
随着业务需求演进------案例管理、数据持久化、模型 CRUD------我们意识到必须自建后端。但此时前端工程已经成型,snss-types npm 包已经在使用。如果强行改造为 Monorepo,需要同时重构两个仓库的目录结构和构建配置,时间成本太高。
于是我们选择维持 npm 包方案作为过渡:类型更新后手动发布,前后端各自 npm install 同步。
未来:迁移到 Monorepo
独立 npm 包的方案有两个痛点:
- 发布流程繁琐 :每次类型变更需要
npm version patch→npm publish→ 前后端各npm install,链路太长 - 版本对齐困难 :前端依赖的
snss-types版本可能与后端源码不一致,尤其在多人协作时
因此,架构的下一步规划是迁移到 pnpm workspace Monorepo:
go
satellite-network-simulation/
├── pnpm-workspace.yaml
├── package.json ← 根 workspace
├── packages/
│ └── types/ ← 共享类型包(引用本地路径,无需发布)
│ └── src/
│ ├── case.ts
│ └── scene.ts
├── apps/
│ ├── web/ ← 现有前端工程
│ └── server/ ← 现有后端工程
迁移后,前后端直接通过 workspace:* 协议引用本地类型包,类型变更即时生效,无需任何发布流程。同时,共享的工具函数、常量、配置也可以纳入 packages/ 目录统一管理。
这是一条从"够用"到"更好"的演进路径------技术上不求一步到位,但方向要清晰。
启发与未来规划
几点启发
1. 性能优化要"提前想,分批做"
项目初期我们没有做 Primitive 模式,导致 500 颗卫星就让浏览器掉帧。后来花了大量时间重构------如果一开始就设计双模式架构,会少走很多弯路。但反过来,Entity 模式的先期实现帮助我们快速验证了交互逻辑,也不算白费。
2. 样式不能在代码里写死
卫星类型、链路状态、拥塞等级------这些都会随场景而变化。我们起初把颜色、图标路径硬编码在渲染函数里,后来需求一变就到处改代码。三层样式继承系统虽然设计复杂,但从第二版开始,新增一种卫星类型只需要在 public/config.js 里加一段 JSON 配置。
3. 分表设计是"用空间换时间"的典型案例
MySQL 单表几千万行查询会慢到不可接受,但拆成几百张小表后,每张表几万行,查询都在毫秒级。代价是管理复杂度------需要维护表的创建/销毁逻辑、需要确保命名规则一致、需要在查询时动态拼接表名。但这份复杂度集中在后端,对前端完全透明。
4. 架构演进不求一步到位
snss-types 独立 npm 包方案虽然不够"优雅",但在当时的时间约束下是务实的选择------它解决了类型一致性问题,且不影响前后端的独立开发节奏。未来迁移 Monorepo 时,因为类型定义本身就独立在 src/types/ 中,迁移成本很低。有时候"先跑起来再优化"比"设计完美再启动"更有价值。
未来规划
- Monorepo 架构迁移:将前后端整合为 pnpm workspace Monorepo,共享类型包改为本地引用,消除发布流程,实现类型变更即时生效
写在最后
回顾这半年的开发历程,我最大的感受是:做一个"能用"的卫星仿真平台不难,做一个"好用"的很难。
"能用"是数据能加载、卫星能显示、时间轴能播放。但"好用"意味着:
- 26000 颗卫星不掉帧
- GB 级数据秒级加载
- 版本切换在 500ms 内完成
- 样式系统让新人 10 分钟就能新增一种卫星类型
- 前后端类型一致,对接不出错
这些"好用"的背后,是无数次的性能分析、架构重构和边界条件处理。如果你也在做类似的可视化项目,希望这篇文章能给你一些启发。
愿我们的代码,能带人们看见地球之外的世界。
欢迎评论
如果大家对卫星网络仿真可视化有什么看法或问题,欢迎在评论区交流讨论。也可以关注我的公众号------【诗传千古地负海涵】 🌟