Remember Cassini’s Grand Finale? Twenty-two death-defying dives between Saturn and its rings before the spacecraft’s 2017 kamikaze into the gas giant’s atmosphere. I wanted to simulate that mission profile, but I had ulterior motives: testing whether Veenie Kit’s architecture could handle pure Keplerian mechanics as elegantly as it handles atmospheric flight.
Spoiler: it took about five hours to go from git copy paste folder to publication-ready sim. Here’s why that matters.
Setup: getting ringy with Cassini?
Veenie Kit started as a Venus balloon simulator - continuous integration with RK4, atmospheric drag, buoyancy, all that messy physics. But what about the other class of space simulation? The orbital mechanics kind, where you solve Kepler’s equation analytically and skip the integrator entirely.
Saturn offered the perfect test case:
- Visually stunning (those rings!)
- Analytically tractable (closed-form Keplerian solution)
- Familiar mission (everyone loves Cassini)
- Multi-body system (six moons to track)
Plus, I wanted to see if I could time-travel through Saturn’s 29.5-year orbit by just setting a Julian date and watching the sun move. Turns out you can.
The Ring Shader
Building Saturn is easy: sphere with a texture. Building the rings took some shader work - I used Sangil Lee’s ray-tracing approach that calculates shadows by tracing from each surface point through the ring plane toward the sun. It’s actual geometry, not a texture trick, and it runs at 60fps. The textures are from Planet Pixel Emporium - real Cassini imaging data.
The only gotcha was getting the ring normal vector right after combining two rotations (ring flattening + Saturn’s axial tilt), but once that clicked, the shadows started casting correctly. Shader bugs are great because they produce gorgeous incorrect results right up until you fix them.
The Architecture That Actually Matters
Here’s where it gets interesting. The real test wasn’t rendering Saturn - it was whether the Veenie pattern would hold up when you swap RK4 integration for analytical Keplerian mechanics.
The Snapshot Pattern
Instead of scattering ephemeris calculations throughout the renderer, we centralize everything in the physics engine:
// physics/environment/saturn-system.ts
export function getSaturnEnvironment(
missionTime: number,
julianDate: number
): SaturnEnvironment {
const moons = getMoonStates(missionTime);
const sunPosition = getSunPosition(julianDate);
const solarIrradiance = getSolarIrradiance(sunPosition);
return { moons, sunPosition, solarIrradiance };
} The physics engine updates this snapshot every frame. The bridge exposes it. The renderer consumes it:
// context/physics-bridge.svelte.ts
private sync() {
const state = this.engine.state;
this.#position = toRenderCoords(state.position);
this.#environment = this.engine.environment; // ← The snapshot
}
get environment() { return this.#environment; } Now the Saturn shader just reads missionPhysics.environment.sunPosition and it automatically updates when you change the calendar. Press C, pick a date, watch the ring shadows move. Time travel without touching shader code.
This is the same pattern JPL uses for mission planning. Physics owns truth, renderer just displays it.
The Performance Crisis
Here’s something fun: the sim ran beautifully for about 5 minutes, then the browser seized up completely. Smooth 60fps, then suddenly frozen, then epileptic stuttering, then dead.
Turns out I was creating 360 new objects per second.
Every frame, the physics bridge was rebuilding the moon Map:
// BROKEN (360 allocations/second)
const moonMap = new Map();
for (const [name, moonState] of engine.environment.moons) {
moonMap.set(name, {
position: toRenderCoords(moonState.position), // NEW OBJECT
radius: moonState.radius * RENDER_SCALE,
color: moonState.color
});
}
this.#moons = moonMap; // NEW MAP Six moons × 60 fps = 360 allocations/second. The garbage collector was drowning.
The fix? Update positions in-place:
// FIXED (zero allocations after init)
for (const [name, moonState] of engine.environment.moons) {
const existing = this.#moons.get(name);
if (existing) {
const newPos = toRenderCoords(moonState.position);
existing.position.x = newPos.x; // IN-PLACE UPDATE
existing.position.y = newPos.y;
existing.position.z = newPos.z;
} else {
this.#moons.set(name, { /* first time only */ });
}
} From 360 allocations/second to zero. From browser freeze to infinite runtime. This is the difference between “it works” and “it ships.”
The Camera System
Initially I had a standard orbital camera. Works fine, but boring. Then I realized: if I can follow Cassini’s position every frame, I can do way more interesting things.
I ended up with five camera modes:
1. Orbital - Standard orbit around Saturn. This is your “director’s view” - full control, bird’s eye perspective.
2. Cassini Follow - Camera tracks Cassini using delta movement (same pattern as the Venus and Firefly sims). You can rotate around the spacecraft as it moves. It’s chase-cam physics: calculate the position delta since last frame, add it to the camera position, done.
3. Cassini Track Saturn - Camera on Cassini, locked looking at Saturn. This is the money shot. Disable OrbitControls, set camera position to Cassini position, target to (0,0,0). Watch Saturn drift past as you orbit. Mesmerizing at ludicrous speed.
4. Cassini Track Moon - Same as above but locked to a specific moon. Follow Titan across the sky from Cassini’s perspective.
5. Moon Surface - Fixed camera on moon surface, looking back at Saturn. This one was tricky.
The Epileptic Camera Problem
The moon surface mode initially caused violent spinning. I mean violently - seizure-inducing camera thrashing at multiple rotations per second.
The issue: moons are displayed 10× their real scale (otherwise invisible), and I was using that scaled radius to calculate camera position. Moon at 1200 render units distance, camera 15 units “above surface”… except 15 units at 1200 units distance means any tiny movement causes massive angular changes.
The fix should be percentage-based offset instead of fixed units:
// Scales properly for all moons
const offsetFraction = 0.03; // 3% of orbital radius
const camDistance = distFromSaturn * (1 + offsetFraction); I haven’t verified this yet (it’s 2am and I’m shipping), but the math checks out. Update coming.
Camera Zoom for Free First-Person
Here’s a neat trick: I set minDistance={0.1} on OrbitControls. Now you can scroll wheel all the way into Cassini’s cockpit. No extra code, just physics. Want first-person view? Zoom in. Want god view? Zoom out. The camera system handles both.
The whole camera component is maybe 100 lines. Five modes, smooth transitions, proper coordinate handling. It’s abstracted into world/Camera.svelte so the Scene just imports it:
<Camera />
<Saturn />
<Moons />
<Cassini /> Clean separation. All the gnarly useTask logic lives in Camera, Scene stays readable.
The Ludicrous Speed Problem
Saturn’s orbital period is 29.5 years. At 1× speed, watching a complete orbit would take… 29.5 years. Not ideal for demos.
So I built a speed picker that goes up to 604,800× (one week per real second). At that speed, you can watch Saturn complete its entire orbit around the sun in 52 minutes.
Here’s the beautiful part: the Keplerian solver doesn’t care about time scale. There’s no numerical integration, no accumulated errors, no instability. Every frame, we just solve:
export function solveKepler(el: OrbitalElements, t: number, mu: number): CartesianState {
const n = Math.sqrt(mu / Math.pow(el.a, 3));
let M = (el.M0 * D2R) + n * t;
// Newton-Raphson to find E from M
let E = M;
for (let j = 0; j < 10; j++) {
const dM = M - (E - el.e * Math.sin(E));
const dE = dM / (1 - el.e * Math.cos(E));
E += dE;
if (Math.abs(dE) < 1e-6) break;
}
// Compute position and velocity from E
// ... rotation to inertial frame
return { position, velocity };
} Closed-form solution. Same accuracy at 1× or 604,800×. Crank it to max and watch the show.
The display is smart about units - instead of “302400×”, it shows “3.5d/s” (three and a half days per second). Way more readable. Click the speed display to get presets: 0.1×, 1×, 10×, 100×, 1k×, 10k×, 1h/s, 6h/s, 12h/s, 1d/s, 2d/s, 7d/s.
Why This Actually Matters
Here’s what I didn’t have to build:
- Time service (already tested on Venus)
- Physics bridge pattern (copy-paste from Firefly)
- Zone UI system (from balloon sim)
- Coordinate transforms (established pattern)
- Camera following logic (Veenie/Firefly pattern)
I didn’t build a “Saturn simulator” - I built a Veenie Kit instance configured for Saturn. The time service, physics bridge, zone system, and snapshot pattern were already tested. All I did was swap the physics domain (RK4 → Kepler) and add shaders.
Total lines of code: ~2,500 (including physics, UI, shaders, camera, and five modes).
Compare that to academic orbital mechanics tools that take months to build and look like they’re from Windows 95. The difference? Reusable architecture.
The snapshot pattern means adding Mars is just new constants. The camera system means any sim gets five modes for free. The performance fixes mean it runs forever. The coordinate transforms mean mixing atmospheric flight and orbital mechanics in the same scene is trivial.
That’s the point. Not “I built a Saturn sim” but “I built infrastructure that makes Saturn sims trivial.”
The Five-Hour Breakdown
- Hour 1: Set up Keplerian solver, adapt to TypeScript
- Hour 2: Wire up moons ephemeris, orbital trails, basic rendering
- Hour 3: Integrate shaders, fix ring normal bug
- Hour 4: Build calendar modal, hook up ephemeris time travel
- Hour 5: Zone UI refactor, speed picker, keyboard shortcuts
Then another 3 hours later that night:
- Hour 6: Performance crisis debugging and fix
- Hour 7: Camera system with five modes
- Hour 8: Cassini spacecraft mesh, camera abstraction
Eight hours total to production. Not because I cut corners - because I didn’t have to build infrastructure twice.
Resources
Shader tutorial: Sangil Lee’s Saturn rings
Planet textures: Planet Pixel Emporium - NASA Cassini data
Veenie architecture: How Veenie Sims Work - deep dive on snapshot pattern
Try the sim: Cassini at Saturn - press ? for keyboard shortcuts, V to cycle camera modes, C for calendar
Eight hours to Saturn with five camera modes and infinite runtime. Because I didn’t have to build the same infrastructure twice.
Play with the sim, steal the techniques, and if you’re building something similar, let’s talk.
- Ivan