Rendering 8,000 Satellites in a Browser Without Melting Your GPU
There are roughly 10,000 active satellites in orbit, plus thousands of debris objects tracked by CelesTrak and the 18th Space Defense Squadron. When we set out to render the full catalog in Deep Seer, the naive approach — create an Entity for each, update positions every frame — brought our frame rate to about 3fps. Here's how we got to 60fps with 8,000+ objects.
The Data: TLE Sets and SGP4 Propagation
Every tracked object in Earth orbit has a Two-Line Element set (TLE) published by CelesTrak. A TLE encodes six Keplerian orbital elements plus drag terms in a compact two-line text format standardized by NORAD in the 1960s. The TLE for the International Space Station looks like this:
ISS (ZARYA) 1 25544U 98067A 26086.51782528 .00020000 00000-0 36000-3 0 9993 2 25544 51.6400 200.1200 0006000 90.0000 270.0000 15.49000000400000
To get a satellite's position at any given time, you feed the TLE into the SGP4 (Simplified General Perturbations 4) propagation algorithm, which accounts for Earth's oblateness (J2 perturbation), atmospheric drag, solar/lunar gravitational effects, and deep-space resonance for high-altitude orbits. The output is a position and velocity vector in the TEME (True Equator Mean Equinox) reference frame, which you then convert to ECEF or geodetic coordinates for display on the globe.
We use the satellite.js library, a JavaScript port of the original FORTRAN SGP4 implementation. It's well-tested and handles all the coordinate conversions. The computation itself is fast — propagating a single TLE takes microseconds. The problem is doing it 8,000 times, 60 times per second.
The Frame Budget Problem
At 60fps, you have 16.67 milliseconds per frame. Of that, the browser needs time for layout, paint, compositing, and garbage collection. The rendering pipeline — clearing the buffer, drawing terrain, drawing imagery, drawing your data — takes its share. Realistically, you have 4-6 milliseconds of JavaScript execution time per frame before you start dropping frames.
Propagating 8,000 TLEs with SGP4 takes approximately 8-12ms on a mid-range desktop CPU. That alone blows the frame budget. You cannot propagate every satellite every frame on the main thread.
Solution 1: Web Worker Offloading
The first optimization is moving SGP4 propagation off the main thread entirely. We spawn a dedicated Web Worker that owns the satellite catalog and propagation logic. The main thread sends the current simulation time to the worker. The worker propagates all TLEs, packs the resulting positions into a Float64Array (three doubles per satellite: x, y, z in ECEF meters), and transfers the buffer back to the main thread using a transferable object (zero-copy).
This completely eliminates the propagation cost from the main thread's frame budget. The worker can take 10-15ms to compute positions without affecting rendering. The main thread simply uses the most recent position buffer it has received. If the worker occasionally takes longer than one frame, the satellites display at their last known positions — the visual difference is imperceptible because orbital positions change slowly relative to frame rate.
Solution 2: PointPrimitiveCollection Instead of Entities
CesiumJS provides two APIs for drawing things on the globe: the Entity API and the Primitive API. The Entity API is high-level and convenient — you describe what you want (a point, a label, a model) and CesiumJS handles the rendering details. But it has significant overhead per entity: property tracking, event handling, and per-frame evaluation of potentially time-dynamic properties.
With 8,000 entities, this overhead is catastrophic. Each entity creates multiple JavaScript objects, and CesiumJS evaluates each one every frame even if nothing changed. Our profiler showed that simply iterating the entity collection — before any rendering — consumed 6ms per frame.
The Primitive API is lower-level. PointPrimitiveCollection is a single GPU draw call that renders up to hundreds of thousands of points. You add points to the collection, set their positions, and the entire collection renders in a single WebGL drawArrays call. Updating a point's position modifies a typed array that gets uploaded to the GPU as a vertex buffer.
Switching from 8,000 Entities to a single PointPrimitiveCollection reduced our per-frame rendering cost from ~18ms to ~0.3ms. This was the single largest performance improvement.
Solution 3: Level-of-Detail Switching
When the camera is zoomed out to see the full globe, 8,000 individual points create visual clutter without providing useful information. You can't distinguish individual satellites at that scale — they merge into a noisy cloud. Conversely, when zoomed in to a specific orbital shell or region, individual satellites and their labels become meaningful.
We implement a three-tier level-of-detail system based on camera altitude:
- Far (above 20,000 km): Show orbital shells as translucent bands. LEO, MEO, and GEO are rendered as colored rings at their characteristic altitudes. Individual satellites are hidden. This communicates the structure of the orbital environment without rendering thousands of points.
- Medium (2,000 - 20,000 km): Show individual satellites as points from the PointPrimitiveCollection. No labels. Color-coded by orbit type or country of origin. This is where the full 8,000-point visualization runs.
- Near (below 2,000 km): Show satellites as points with labels (name, NORAD ID). Only satellites within the view frustum and within a distance threshold get labels, using a BillboardCollection for text. At this zoom level, typically 50-200 satellites are visible, so the label overhead is manageable.
Solution 4: Occlusion Culling
At any given time, roughly half of all satellites are behind the Earth relative to the camera. Rendering points that are occluded by the planet wastes GPU cycles and, more importantly, creates confusing visual artifacts where satellites appear to float in front of terrain they should be hidden behind.
CesiumJS provides built-in horizon culling for entities, but the PointPrimitiveCollection doesn't automatically cull individual points against the globe. We implement a manual occlusion test: for each satellite position, we compute the angle between the camera-to-satellite vector and the camera-to-Earth-center vector. If the satellite is behind the Earth's limb (accounting for the satellite's altitude), we set its pixel size to zero, effectively hiding it without removing it from the collection.
This test is simple vector math — a dot product and an angle comparison — and we run it on the Web Worker alongside the SGP4 propagation. The worker outputs a visibility flag alongside each position, and the main thread only shows points flagged as visible. This typically eliminates 40-50% of points from rendering.
Solution 5: requestAnimationFrame Batching
Even with a Web Worker producing position updates, applying 8,000 position changes to the PointPrimitiveCollection in a single frame creates a spike. Each point.position = newPosition call triggers internal bookkeeping in CesiumJS.
We batch the updates across multiple frames. When a new position buffer arrives from the worker, we don't apply all 8,000 updates immediately. Instead, we apply a chunk of 2,000 per frame over four frames. The visual effect is imperceptible — positions change by fractions of a pixel between frames — but the CPU cost is spread evenly, preventing frame drops.
For time-critical satellites (those the user has selected or that are near the camera), we apply updates immediately. Only the background catalog uses batched updates.
Our Actual Performance Numbers
Measured on a mid-range laptop (Intel i7-1260P, Intel Iris Xe, 16GB RAM, Chrome 124):
- Satellite count: 8,247 (full CelesTrak GP catalog)
- Frame rate (zoomed out, orbital shells): 60fps, 2.1ms frame time
- Frame rate (medium zoom, all points): 58-60fps, 4.8ms frame time
- Frame rate (close zoom, labels visible): 60fps, 3.2ms frame time (fewer visible objects)
- Worker propagation time: 9.3ms for full catalog (runs independently of frame rate)
- Memory usage: ~180MB total (CesiumJS baseline ~120MB, satellite data ~60MB)
- Position update latency: ~50ms from simulation time change to visual update (worker round-trip + batching)
On a discrete GPU (NVIDIA RTX 3060), the frame times drop to 2-3ms across all zoom levels, and we could comfortably render 20,000+ points without issues.
What We'd Do Differently
If starting over, we'd consider computing satellite positions entirely on the GPU using a compute shader or vertex shader that takes TLE parameters as uniforms/attributes and outputs positions. This would eliminate the worker-to-main-thread transfer entirely. CesiumJS doesn't natively support custom compute shaders, but you can achieve this with custom primitive types and GLSL vertex shaders that encode the SGP4 algorithm. The math is complex but entirely expressible in GLSL, and it would scale to 100,000+ objects trivially.
We'd also explore using SharedArrayBuffer instead of transferable objects for the worker communication, allowing true zero-copy shared memory between the worker and main thread. This requires the correct COOP/COEP headers but eliminates the transfer overhead entirely.
The difference between 3fps and 60fps wasn't a single optimization — it was five. Move computation off the main thread. Use the right rendering primitive. Show appropriate detail for the zoom level. Don't render what's behind the planet. Spread updates across frames. Each one mattered.