跪拜 Guibai
← Back to the summary

Rendering 26,000 Satellites at 60fps in a Browser

Foreword

In the first half of 2026, I led the full-stack development of a "Satellite Network Simulation Visualization Platform."

This is a professional tool for satellite network architecture design and evaluation—it can render thousands of satellites in real-time on a 3D globe, forming multi-layer constellations, inter-satellite/satellite-to-ground communication links, ground gateways, and user terminals. It supports three core business scenarios: simulation playback, mission chain deduction, and network architecture optimization.

Looking back at this development journey, from 3D rendering performance optimization to database sharding strategies, from real-time communication protocol design to the layered architecture of scene rendering—every step is worth recording.

Enterprise WeChat Screenshot_20260706142416.png

The Starting Point

The story began with a simple need: to see the operational status of a satellite network in a browser.

Traditional satellite network simulations mostly rely on offline computation and static chart output, lacking intuitive visualization methods. What we needed was an interactive, replayable, and optimizable web-based visualization platform.

In terms of technology selection, Cesium was the only open-source solution capable of rendering a real Earth and space in a browser. But Cesium only provided the "canvas," and we were still far from a complete satellite network simulation platform—we needed:

This was the starting point of the entire project—bringing a professional simulation tool to the Web.


Overall Technical Architecture

The project adopted a front-end/back-end separation architecture, communicating through dual channels: HTTP API + WebSocket.

Frontend: Built with Vue 3 + TypeScript + Vite, using Cesium as the 3D globe engine, Pinia for business state management, and ECharts/D3 for data charts.

Backend: NestJS + TypeORM + MySQL, implementing real-time simulation data push via a WebSocket gateway, supporting Docker containerized deployment.

Shared Layer: Achieved front-end/back-end type definition consistency through the snss-types npm package, avoiding type drift during interface integration.

image.png


Frontend: Architectural Evolution from Pinia to Class-Based Store

When building complex visualization applications, state management is the first problem to solve.

Initially, we used the standard Pinia defineStore, storing all satellite, node, and link data in the Pinia state. As the state fields ballooned to 50+ and data volume reached 10,000+ items, a problem gradually emerged: the overhead of deep reactivity was too high.

Vue's reactive recursively tracks every field of every element in an array. When a watch is set with deep: true, performance drops sharply. In simulation scenarios, data update frequency is extremely high—the timeline needs to refresh link congestion status and service flow paths every frame.

So we undertook a major refactoring—Class-Based Store, using ref/shallowRef instead of reactive to precisely control reactivity granularity.

// Core design: get/set auto-unwrapping, transparent to external calls
class BusinessClassStore {
  // Primitive types use ref
  private _currentCaseId = ref<string>('')
  get currentCaseId() { return this._currentCaseId.value }
  set currentCaseId(v: string) { this._currentCaseId.value = v }

  // Arrays use shallowRef to avoid deep reactivity
  private _satelliteList = shallowRef<SatelliteItem[]>([])
  get satelliteList() { return this._satelliteList.value }
  set satelliteList(v: SatelliteItem[]) { this._satelliteList.value = v }
}

export const businessStore = new BusinessClassStore()

Key design decisions:

This solution minimized the reactivity tracking cost of state changes while maintaining the developer experience.


Frontend: Performance Optimization of the Dual-Mode 3D Rendering Engine

The biggest challenge in satellite network visualization is scale—the real-time position update of thousands of satellites on a 3D globe.

Cesium provides two rendering modes, and we implemented both, allowing users to switch freely based on data volume:

Mode 1: Entity Mode (Fine Rendering)

Suitable for high-precision display of a small number of satellites:

// Each satellite is an independent Entity, supporting billboard + model + label + path
const entity = dataSource.entities.add({
  position: SampledPositionProperty, // Lagrange interpolation
  billboard: { image, scale },
  model: { uri: gltfModel }, // 3D model
  label: { text, font },
  path: { material, width }, // Orbit line
})

// Satellite attitude automatically adjusts to velocity direction
entity.orientation = new VelocityOrientationProperty(entity.position)

Mode 2: Primitive Batch Rendering

For high-performance scenarios with thousands of satellites, the key lies in batch coordinate updates and orbit calculation performance.

Primitive Batch Coordinate Update Process

Cesium's BillboardCollection and LabelCollection are key to high-performance rendering—they package thousands of points/labels into a single GPU DrawCall, rather than rendering element by element.

The entire batch coordinate update is divided into three steps:

Step 1: Batch create Primitives during initialization, using zero vectors as placeholders for positions

// BillboardCollection + LabelCollection each render all satellites in one DrawCall
const sateBillboardCollection = new Cesium.BillboardCollection({
  modelMatrix: new Cesium.Matrix4(), // Shared transformation matrix
})
const sateLabelCollection = new Cesium.LabelCollection({
  modelMatrix: new Cesium.Matrix4(),
})

for (const item of satelliteList) {
  // One Billboard per satellite, initial position set to (0,0,0)
  const billboard = sateBillboardCollection.add({
    id: item.id,
    image: 'resources/satellite/icons/icon1.png',
    position: Cesium.Cartesian3.ZERO, // Zero vector placeholder
    scaleByDistance: new Cesium.NearFarScalar(1e4, 1, 1e8, 0.5),
  })
  sateBillboardMap[item.id] = billboard
  // Create label synchronously...
}

Step 2: Start the self-developed orbit propagation library, initialize the real-time propagator

import * as KBSatellite from 'kbe3d-satellite'

// Pass TLE parameters for all satellites, return a real-time propagation controller
const liveControl = await KBSatellite.sgp4OrbitLive(satelliteList, {
  isGetDetails: false, // Don't return velocity/lat-lon, reduce computation
  isUseGPU: false, // Can switch to GPU mode
})

Step 3: 60ms timer batch updates coordinates of all Primitives

// Pre-allocate scratch objects, reuse to avoid GC
const sateTempCart3 = {} // { satelliteId: Cartesian3 }

function updateSatelliteLive() {
  // 1) Batch get ECI coordinates of all satellites at the current time from the self-developed orbit library
  liveControl.update(currentTime).then((res) => {
    // 2) Calculate ICRF → Fixed rotation matrix (only needs to be calculated once!)
    icrfToFixed = Cesium.Transforms.computeIcrfToFixedMatrix(now)

    // 3) Iterate all satellites, only update ICRF coordinates (no conversion to ECF)
    res.forEach((value, key) => {
      const pos = value.positionEci // Unit: 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) Assign modelMatrix once, completing the coordinate transformation for all!
    //    Cesium's GPU side automatically rotates ICRF coordinates to ECF
    sateBillboardCollection.modelMatrix
      = Cesium.Matrix4.fromRotationTranslation(icrfToFixed)
    sateLabelCollection.modelMatrix
      = Cesium.Matrix4.fromRotationTranslation(icrfToFixed)
  })
}

// 60ms timer (approx. 16fps update rate, visually completely smooth)
setInterval(updateSatelliteLive, 60)

Why This Design?

ICRF coordinates + shared modelMatrix is the core optimization. The natural output of SGP4 orbit propagation is ICRF (Earth-Centered Inertial) coordinates. The conventional approach is to perform ICRF→ECF matrix multiplication conversion for each satellite—26,000 satellites means 26,000 3×3 matrix multiplications per frame.

We leverage Cesium's modelMatrix mechanism: keep all satellite positions in the ICRF coordinate system, assign the ICRF→ECF rotation matrix as a whole to the Collection's modelMatrix, and Cesium's GPU shader automatically performs the coordinate transformation for all points within the Collection. Thus, 26,000 CPU matrix multiplications become 1 CPU assignment + 1 GPU parallel transformation.

Scratch object pre-allocation is another major optimization. sateTempCart3 creates all satellite Cartesian3 placeholder objects at initialization, and subsequent updates only modify their x/y/z components, resulting in zero GC throughout the process. Link rendering also heavily uses module-level pre-allocated objects like _scratchCarto1 and _scratchGeodesic.

Measured results: In a 10,000-satellite scenario, the Primitive mode rendering frame rate stabilizes above 60fps (browser refresh rate limit), and CPU usage is reduced by over 80% compared to Entity mode.


Frontend: useMapScene Architecture — Layered Composition + Dependency Injection

The rendering logic of a 3D scene is extremely complex—satellite orbit calculation, node position updates, dynamic link connections, service flow path changes, timeline frame data driving—if all stuffed into one file, it would inevitably become a 5000+ line "God function."

Our design is layered composition + provide/inject dependency injection.

Overall Architecture

useMapScene()                       ← Main entry, orchestrates sub-modules
├── useMapSceneSatellite()          ← Satellite rendering (Entity + Primitive dual mode)
├── useMapSceneNode()               ← Ground node rendering
├── useMapSceneLink()               ← Inter-satellite/satellite-to-ground link rendering
├── useMapSceneTaskFlow()           ← Mission chain/service flow rendering
└── useMapSceneTimeline()           ← Timeline frame data driving engine

Each sub-module is an independent composable, returning methods like render/clear/pick/toggle. useMapScene is responsible for orchestrating the call order and dependency injection.

Dependency Injection Design

Dependencies exist between sub-modules—service flows need the current positions of satellites and nodes to draw paths, and the timeline engine needs to simultaneously drive updates for satellites, nodes, links, and service flows.

We use Vue's provide/inject to achieve decoupling between modules:

// useMapScene.tsx — Main entry composing modules
export async function useMapScene() {
  // STEP 1: Satellite module
  const mapSceneSatellite = useMapSceneSatellite()
  const { renderSatellites, clearSatellites, pickSatelliteById, toggleShowSatellite, ... }
    = mapSceneSatellite
  provide('pickSatelliteById', pickSatelliteById)
  provide('toggleShowSatellite', toggleShowSatellite)
  // ...

  // STEP 2: Node module
  const mapSceneNode = useMapSceneNode()
  const { renderNodes, clearNodes, pickNodeById, toggleShowNode, ... }
    = mapSceneNode
  provide('pickNodeById', pickNodeById)
  provide('toggleShowNode', toggleShowNode)
  // ...

  // STEP 3: Link module
  const mapSceneLink = useMapSceneLink()
  // ...

  // STEP 4: Task flow module (needs satellite + node module references)
  const mapSceneTaskFlow = useMapSceneTaskFlow({
    satellite: mapSceneSatellite,  // Directly pass reference
    node: mapSceneNode,
  })

  // STEP 5: Timeline engine (needs all four module references)
  const mapSceneTimeline = useMapSceneTimeline({
    satellite: mapSceneSatellite,
    node: mapSceneNode,
    link: mapSceneLink,
    taskFlow: mapSceneTaskFlow,
  })
}

Note the choice between two injection methods:

Rendering Orchestration: One Entry, Five Steps

Scene rendering has only one entry point, renderMapScene(), which drives all sub-modules in order:

async function renderMapScene() {
  await clearMapScene()
  sceneControl.setPlayStep(1)
  await sceneControl.setTimeline(startTime, endTime, clockRange)

  // Render in order: Satellites → Nodes → Links → Task Flows → Timeline
  await renderSatellites(isUsePerformace) // Satellites must render first (links need their positions)
  await renderNodes(isUsePerformace) // Nodes second (links also need their positions)
  await renderLinks() // Links depend on satellite + node positions
  await renderTaskFlow() // Task flows depend on link data
  await renderTimeline() // Timeline engine starts last, driving everything
}

The rendering order is critical: link rendering needs the position information of satellites and nodes, so satellites and nodes must be completed before links; the timeline engine, as the "master scheduler," can only start after the other four modules are ready.

Click Event Distribution

Cesium's 3D scene has only one global click event, but it needs to be distributed to four different panels: satellite details, node details, link details, and service flow details. We listen uniformly in useMapScene and distribute based on the ID prefix of the picked target:

function bindMapClickEvent(e) {
  const primitive = e.data.scenePicked?.primitive
  const id = primitive?.id

  if (id?.startsWith('satellite-')) {
    pickSatellitePrimitive(primitive) // Trigger satellite pick → fly animation
    emitter.emit('Satellite Details', id) // Event bus notifies right panel
  }
  else if (id?.startsWith('node-')) {
    pickNodeEntity(entity)
    emitter.emit('Node Details', id)
  }
  else if (id?.startsWith('link-')) {
    pickLinkEntity(entity)
    emitter.emit('Link Details', id)
  }
  // ...
}

Thus, the 3D scene's interaction logic is completely encapsulated within useMapScene, and upper-level components only need to listen to the mitt event bus to respond.

The core value of this architecture is: each module is developed and tested independently. Adding a new rendering element (like "interference zone visualization") only requires creating a new useMapSceneInterference.tsx and registering it in useMapScene, without affecting existing modules.

Making a large number of satellites/nodes/links "identifiable" is an underestimated challenge.

We designed a three-layer inheritance + extend override style system:

Layer 1: Type base style (determined by data fields)
├── Satellite: Remote Sensing > GEO Layer > MEO Layer > LEO Layer > Default
├── Node: Gateway > Control Center > Others
└── Link: Inter-satellite Link > Satellite-to-Ground Link > Feeder Link

Layer 2: Single element custom style (item.style)
└── Overrides same-name properties in Layer 1

Layer 3: extend dynamic override (calculated by version comparison)
├── ADD: Green flashing + "[New]" tag
├── REMOVE: Red gradient fade-out + "[Deleted]" tag
├── PLUS: Yellow flashing + "[Capacity Increase: {mbps}]" tag
├── HIGH: Red flashing + "[Highlight]" tag
├── congestion_0/1/2/3: Green/Yellow/Orange/Red four-level congestion colors
└── down: Gray unavailable state

Style merging algorithm:

actionSetConfigStyle(items) {
  const baseStyle = config[item.satelliteType]?.[item.layer] ?? defaultStyle
  const result = cloneDeep(baseStyle)  // Deep copy to prevent global config pollution

  // Merge item's own style
  if (item.style) merge(result, item.style)

  // Merge extend overrides (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)
      // ...
    }
  }

  // Apply to various graphic types
  applyPointStyle(result.point)
  applyBillboardStyle(result.billboard)
  applyLabelStyle(result.label)
  applyPolylineStyle(result.polyline)
}

The essence of this system is: version diffs automatically drive style changes. When a user clicks "revert to previous version," the diff between old and new data automatically generates ADD/REMOVE/PLUS tags, and satellites/links in the scene change color automatically—the user can see at a glance "what was added or removed in this optimization step."


Backend: Dynamic Table Sharding and Large Data Volume Processing

Simulation data has a characteristic: individual cases are small, but the timeline is absurdly large.

The timeline.jsonl (timeline data) for a typical scenario can reach several GB, containing thousands of frames, each with thousands of link status records. The traditional "one big table for everything" approach is infeasible—queries become unacceptably slow once a single table swells to tens of millions of rows.

Our solution is dynamic table sharding (Sharding by case + scene):

MySQL my_db/
├── model_entity                    ← Fixed table (model metadata)
├── ste_{case_id}_{scene_id}        ← Task table dynamically created per case+scene
├── sfe_{case_id}_{scene_id}        ← Flow table dynamically created per case+scene
└── sle_{case_id}_{scene_id}        ← Timeline table dynamically created per case+scene

Table Lifecycle Management

// Automatically create tables when uploading a case
async uploadCaseZipFile(file) {
  const { caseId, scenes } = await parseZipFile(file)

  for (const scene of scenes) {
    const tableName = `sle_${caseId}_${scene.id}`  // Auto MD5 if exceeds 64 chars
    await createTableFromTemplate(SceneTimelineEntity, tableName)
    await bulkInsert(tableName, scene.timelineData, 10)  // JSONL streaming read
  }

  // Update cache
  await initCaseCache()
}

// Automatically drop tables when deleting a case
async deleteCaseById(caseId) {
  const tables = await getTablesByPrefix(`%${caseId}%`)
  for (const table of tables) {
    await queryRunner.dropTable(table)
  }
  await removeCaseFiles(caseId)
}

Streaming Reads to Avoid Memory Explosion

For GB-level timeline.jsonl, use Node.js readline to read line by line:

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 insert
    batch = []
  }
}

This way, memory usage remains controllable regardless of file size.


Backend: WebSocket Real-Time Situation and Room Scheduling

Besides offline simulation playback, the platform also supports a real-time situation mode—the simulation computing platform continuously pushes data, and the frontend renders in real-time.

Our WebSocket gateway borrows from signaling server design, implementing a Room + Provider pattern:

image.png

Core Protocol Design

// Client role registration
{ type: 'register', role: 'frontend' | 'provider' }

// Server dynamically assigns a scene to a provider
{ type: 'assign_scene', caseId, sceneId }

// Provider pushes real-time frame data
{ type: 'push_timeline_data', caseId, sceneId, frameNo, data }

// Server broadcasts to all frontends in the same room
{ type: 'timeline_update', frameNo, data }

Fault Tolerance Mechanisms

HTTP Upload → Controller → EventEmitter.emit() → WebSocket Gateway → Broadcast progress

Frontend-Backend Type Sharing: The Path from Independent npm Package to Monorepo

The most common pain point in front-end/back-end separation projects is: production incidents caused by inconsistent interface types.

The frontend says CaseItem.caseName, the backend returns case_name; the frontend expects number, the backend returns string—these problems emerge endlessly during integration.

Current State: Independent npm Type Package

We maintain type definitions uniformly in the backend repository src/types/, compile them into .d.ts declaration files via tsup, publish them as the snss-types npm package, and the frontend directly depends on it in package.json:

Backend src/types/
├── index.ts        ← All type exports
├── case.ts         ← CaseItem, OptimizationTrace, CaseJsonData...
└── scene.ts        ← SatelliteItem, NodeItem, LinkItem, FlowItem, TimelineFrame...
        │
        ▼ tsup compile
dist-types/
└── index.d.ts      ← Pure type declaration file
        │
        ▼ npm publish
[email protected]
        │
        └── Frontend package.json: "snss-types": "^1.0.11"

Any addition, deletion, or modification of interface fields is exposed at compile time, preventing mismatches from being discovered only at runtime.

Why This Design?

Honestly, this is a transitional solution.

When the project started, our positioning was just a frontend visualization platform, with all backend functions pushed by a third-party computing platform via WebSocket. Therefore, no backend project was planned initially, and naturally, a Monorepo was not considered.

As business requirements evolved—case management, data persistence, model CRUD—we realized we had to build our own backend. But by then, the frontend project was already established, and the snss-types npm package was already in use. Forcibly refactoring to a Monorepo would require restructuring the directory structures and build configurations of both repositories simultaneously, which was too time-costly.

So we chose to maintain the npm package solution as a transition: manually publish after type updates, and both frontend and backend run npm install to sync.

Future: Migration to Monorepo

The independent npm package solution has two pain points:

Therefore, the next step in the architecture plan is migration to a pnpm workspace Monorepo:

satellite-network-simulation/
├── pnpm-workspace.yaml
├── package.json                  ← Root workspace
├── packages/
│   └── types/                    ← Shared type package (local path reference, no publishing needed)
│       └── src/
│           ├── case.ts
│           └── scene.ts
├── apps/
│   ├── web/                      ← Existing frontend project
│   └── server/                   ← Existing backend project

After migration, the frontend and backend directly reference the local type package via the workspace:* protocol, making type changes effective instantly without any publishing process. Additionally, shared utility functions, constants, and configurations can also be managed under the packages/ directory.

This is an evolution path from "good enough" to "better"—technically, it doesn't aim for perfection in one step, but the direction must be clear.


Insights and Future Plans

Some Insights

1. Performance optimization should be "thought about early, done in batches"

In the early stages of the project, we didn't implement Primitive mode, causing the browser to drop frames with just 500 satellites. Later, we spent a lot of time refactoring—if we had designed the dual-mode architecture from the start, we would have avoided many detours. Conversely, the initial implementation of Entity mode helped us quickly validate interaction logic, so it wasn't entirely wasted.

2. Styles cannot be hardcoded in the code

Satellite types, link statuses, congestion levels—these all change with the scenario. We initially hardcoded colors and icon paths in rendering functions, then had to change code everywhere when requirements changed. Although the three-layer style inheritance system is complex to design, starting from the second version, adding a new satellite type only requires adding a JSON configuration in public/config.js.

3. Sharding design is a classic case of "trading space for time"

Querying tens of millions of rows in a single MySQL table becomes unacceptably slow, but splitting it into hundreds of small tables, each with tens of thousands of rows, brings queries down to milliseconds. The cost is management complexity—needing to maintain table creation/deletion logic, ensure consistent naming rules, and dynamically concatenate table names in queries. But this complexity is concentrated in the backend, completely transparent to the frontend.

4. Architectural evolution doesn't need to be perfect in one step

Although the snss-types independent npm package solution isn't "elegant" enough, it was a pragmatic choice under the time constraints at the time—it solved the type consistency problem without affecting the independent development pace of the frontend and backend. When migrating to a Monorepo in the future, the migration cost is very low because the type definitions themselves are independent in src/types/. Sometimes "get it running first, then optimize" is more valuable than "design perfectly before starting."

Future Plans


Final Words

Looking back on this half-year development journey, my biggest feeling is: making a "usable" satellite simulation platform isn't hard; making a "good to use" one is very hard.

"Usable" means data can load, satellites can display, and the timeline can play. But "good to use" means:

Behind these "good to use" aspects are countless performance analyses, architectural refactorings, and boundary condition handlings. If you are also working on a similar visualization project, I hope this article can give you some inspiration.

May our code help people see the world beyond Earth.


Comments Welcome

If you have any thoughts or questions about satellite network simulation visualization, feel free to discuss in the comments section. You can also follow my public account—【诗传千古地负海涵】 🌟