Engine systems

Building a real-time Saturn ephemeris in React Three Fiber

How I rebuilt a Wallpaper Engine's Saturn simulation as the hero element of a portfolio site: Keplerian propagator for the moons, procedural ring shader, and ~5000 ring particles.

Arthur Dutra··5 min readShare ↗RSS

A few weeks ago I came across a Wallpaper Engine submission by sykm that ran a real-time Saturn ephemeris in the background of a Windows desktop. It used VSOP87 for Saturn's heliocentric position, a Kepler equation solver with three branches (Taylor, Householder, Mikkola, selected by eccentricity), J2–J8 oblateness perturbations, mean-motion resonances (Mimas–Tethys, Enceladus–Dione), even GR corrections. The author's stated accuracy was ±1,000 km at one year, ±50,000 km at one hundred. Within a hair of JPL Horizons.

That kind of physics doesn't fit on a portfolio page. But the shape of it does. So I rebuilt a stripped-down version as the hero element of this site.

What survived the trim

The portfolio version drops everything that doesn't matter visually over a single page visit:

  • No VSOP87. Saturn sits at scene origin in our composition. We never need its heliocentric position.
  • No mean-motion resonances. Over the few minutes a visitor will spend on the page, Mimas and Tethys haven't drifted enough for the lock-step to read.
  • No J2–J8. Same reason. The drift only becomes visible after weeks.
  • No GR corrections, tidal evolution, or radiation pressure.

What survived: 6-element Keplerian orbits per moon, with a Newton-Raphson solver for Kepler's equation. About 80 lines.

export function solveKepler(M: number, e: number, tol = 1e-7): number {
  let m = M % (Math.PI * 2);
  if (m > Math.PI) m -= Math.PI * 2;
  else if (m < -Math.PI) m += Math.PI * 2;
 
  let E = m + e * Math.sin(m);
  for (let k = 0; k < 8; k++) {
    const f = E - e * Math.sin(E) - m;
    const fp = 1 - e * Math.cos(E);
    const dE = f / fp;
    E -= dE;
    if (Math.abs(dE) < tol) break;
  }
  return E;
}

Newton-Raphson converges in 4–6 iterations for the eccentricities Saturn's moons actually have (max 0.123 at Hyperion). The 8-iteration cap is paranoia.

Eight moons, real periods, scaled distances

I propagate eight major moons every frame: Mimas, Enceladus, Tethys, Dione, Rhea, Titan, Hyperion, and Iapetus. Periods, eccentricities, and inclinations are taken from the NASA fact sheet. Semi-major axes are not. Iapetus orbits at 59 Saturn radii, which would put it ~40 scene units off-screen at our default camera. I compress the radial scale (real ratio 19×, scene ratio ~2.3×) so all eight fit in frame.

What I kept faithful is Iapetus's 15° inclination relative to Saturn's tilted ring plane. That's the visual hook. The inner moons sweep along the equator; Iapetus arcs through a different plane. Without it the system reads as a flat carousel. With it, the depth lands.

The orbits run on a SATURN_TIME_SCALE of ~18,000× wall clock, so Mimas finishes a lap in about 5 seconds and Titan in 76 seconds. Real-time would be technically more correct and visually invisible.

The disc: shader + particles, not one or the other

The first version of the disc was a flat textured ring with the original Interstellar RingA and RingB texture maps. It looked like exactly that: a flat textured ring. The user feedback was unambiguous: "it's flat, the sprites are only warped, I want a volumetric accretion disk."

The fix that worked was layering two systems:

  1. A custom ring shader on a RingGeometry that encodes Saturn's real ring structure as a piecewise alpha function over uv.y (which RingGeometry maps from inner to outer radius). C ring sparse and dusty, B ring densest with sine-modulated banding, Cassini Division as a gap, A ring with explicit Encke and Keeler gaps, F ring as a narrow bright sliver at the outer edge. Saturn's shadow on the rings comes from a per-fragment ray-sphere intersection toward the sun direction.

  2. A volumetric particle disc of ~4,200 points distributed in a torus volume around the rings. Density-biased to mirror the shader (42% in the B ring, 26% in A, 17% in C, the rest scattered). Each particle has a persistent (radius, theta, y, speed) tuple; only the angle advances each frame, so the buffer write is small.

useFrame((_, dt) => {
  const arr = posAttr.array as Float32Array;
  for (let i = 0; i < RING_PARTICLE_COUNT; i++) {
    angles[i] += dt * speeds[i];
    const a = angles[i];
    const r = radii[i];
    arr[i * 3]     = Math.cos(a) * r;
    arr[i * 3 + 1] = ys[i];
    arr[i * 3 + 2] = Math.sin(a) * r;
  }
  posAttr.needsUpdate = true;
});

Speeds follow Keplerian (1 / r^1.4), so inner particles complete their laps faster, and the disc visibly shears. About 8% of the particles are flagged as "ice" with a near-white color, giving the swarm sparkle highlights against the more uniform tan body.

Three lessons

The most expensive thing was almost the cheapest. I spent two days fighting a Suspense boundary issue with the texture loader before realising the actual visual win came from the particles, not the shader, and the shader didn't need to do anything fancy at all. If you're building this kind of thing, prototype the particles first and only add the shader once the particles aren't enough on their own.

Compress what doesn't matter, preserve what does. Iapetus's 15° inclination was non-negotiable for the visual. The semi-major axes were entirely negotiable. Knowing which is which up front saves hours.

LLMs are stupidly useful for translating equations into code. sykm credits DeepSeek and Claude for math/physics explanation and debugging, and notes the project would have taken "tens of times more effort" solo. I had the same experience. The hard part wasn't writing the code; it was getting the J2 signs right and composing perturbation terms in the body frame versus ICRF. That's exactly the kind of "I have the paper, I just need to translate equation 14" work that current models are great at.

The full source is on this site. The hero you're seeing on the home page is rendered live every frame by the propagator described above.