Astro
This file covers the astro domain: the ephemeris that places and moves celestial bodies, the gravitational field and reference frames built on it, and the frame-local authoring boundary that lets a solar system be placed through one Transform.
1. The solar system: ephemeris, gravity, and frames
The engine/domain/astro/include/SushiEngine/astro/ headers place and move bodies. They are pure
double-precision host code that fills the neutral Environment — the engine ships no device code
here. engine/domain/astro/include/SushiEngine/astro/julian_date.hpp and
engine/domain/astro/include/SushiEngine/astro/orbital_elements.hpp propagate the Standish
Keplerian rows; engine/domain/astro/include/SushiEngine/astro/celestial_bodies.hpp catalogues each
body’s radius, colour, pole, SurfacePreset, and ring_extent (Saturn’s ring span, zero for every
other body); engine/domain/astro/include/SushiEngine/astro/ephemeris.hpp’s fill_environment_sky
assembles them into the local sky each frame and selects the dominant body (the one whose
surface is the analytic ground) by the surface hand-off altitude.
Saturn’s rings are drawn the same analytic, mesh-free way as the bodies themselves.
fill_environment_sky threads ring_extent(BodyId::Saturn) onto the body’s
CelestialBody::ring_inner_metres/ring_outer_metres (oriented by the body’s real pole, i.e.
its J2000 equatorial plane) and, once Saturn is the dominant near-field planet, onto
Environment::planet_ring_*. engine/presentation/render/source/scene/scene_uniforms.cpp packs
the far-field radii into the body record’s previously-unused lanes and appends one planet_ring
vec4 for the near-field case (after the block’s arrays, so the shaders that share the scene block
and read only its earlier fields keep their offsets).
engine/presentation/render/shaders/sky.frag then ray-tests the equatorial annulus in both
regimes — the far-field body loop (the ring resolves as the camera nears Saturn past the far-disk
LOD) and the near-field planet — shading it with banded opacity (C/B rings, the empty Cassini
division, the A ring with its Encke gap), fine ringlets and self-gravity-wake clumping, a
back-scatter opposition surge, translucency over the disk, and the planet’s shadow cast across
the ring. docs/design/SATURN_RING_REALISM.md (2026-09, shipped) added the ring’s own shadow on
the ground, back-lit forward scattering, optical-depth-derived banding, and sheared
self-gravity-wake noise on top of this — all driven by a per-body Astro::RingProfile so a
future ringed body is a new profile row, not a new code path.
docs/design/PLANETARY_RINGS.md (2026-09, shipped) cashed that promise in: Jupiter, Uranus, and
Neptune each got a real RingProfile, and the far-field body loop’s shared ring-profile uniform
slot — correct only while Saturn was the sole ringed body — became a per-body slot so two ringed
bodies visible in the same frame no longer race for it.
Because every body is placed with its true direction, distance, and angular radius in one frame,
eclipses fall out of the geometry rather than being scripted. fill_environment_sky computes
the solar-eclipse coverage once — the circle-circle overlap (disk_overlap_fraction) of the
Sun’s disk by any nearer body — and hands it to the renderer as a single scalar
(Environment::solar_eclipse, packed into sky_counts.w); the sky, PBR, and cloud passes all dim
the direct sun by it, so the whole scene dusks toward totality together. The lunar eclipse is the
mirror case: the Moon’s disk against Earth’s umbra at the anti-solar point, folded into the Moon
body’s colour and brightness on the CPU (a coppery, dimmed disk) with no shader involved. Both
are Earth-consistent and ephemeris-driven, so they occur only at the real alignments.
Every body in that sky is also a light. The ephemeris turns the body list into an ordered
CelestialLight array (Environment::lights), and the Sun is not a special case in it: an
emitter’s irradiance is authored, a reflector’s follows from the definition of geometric albedo —
incident irradiance times albedo * (radius/distance)^2 times phase_brightness, which is a
Lambert sphere for smooth bodies and Allen’s lunar fit (opposition surge included) for regolith
ones. Nothing names a body, so the Moon over Earth, Jupiter over Europa, and earthshine on the
Moon are one code path, and the numbers are real — a full Moon lands near 2.8e-6 of sunlight.
The list is ordered by what each light delivers at the camera (irradiance weighted by
elevation), which is what lets the single shadow-cascade atlas belong to the Sun by day and to
the dominant reflector after sunset; the PBR pass and the analytic ground both loop the array,
shading the rest with the same BRDF minus the cascades. Because the lights are physical, so is
the exposure path: the auto-exposure histogram’s floor reaches 2^-20 to meter a moonlit surface
rather than crush it, and PlanetParameters::ocean_roughness gives the ocean mask a tight GGX
lobe, so a body low over water draws a glitter path instead of a round highlight.
The pipeline is organised around three coordinate spaces: solar (heliocentric ecliptic J2000, double metres — where every body lives), planet (body-fixed per body, origin at its centre, turning with its pole and spin — where surface entities live), and local (the camera-relative scene frame the renderer draws in, origin at the observer’s surface point, +Y along the geodetic normal).
The transforms between them are fully body-parametric, so the sky is built the same way on any
planet rather than only Earth: SkyObserver::observer_body names the body the observer stands
on, fill_environment_sky anchors the scene origin to that body’s surface and places every
other body relative to it, and engine/domain/astro/include/SushiEngine/astro/body_orientation.hpp
supplies the per-body spin and the rotation of a direction into the observer body’s equatorial
frame (ecliptic_to_body_equatorial / equatorial_to_body_equatorial). Earth is routed through
the exact fixed-obliquity conversion and sidereal time so the home sky is unchanged; every other
body gets its true pole and day.
The one subtlety in that, and it is load-bearing: prime_meridian_angle is the spin, not
body_rotation_angle. The latter is the IAU angle W, which is measured from the ascending node
of the body’s equator on the J2000 equator, while the frame above puts its +X on the node with
the ecliptic. Both are legitimate frames with the pole as +Z; they are not the same frame, and
the two nodes are 52.7° apart for the Moon and 117.6° for Venus. Anything asking “where is the
prime meridian right now” — the topocentric sky, the scene-frame bijection, the body-fixed pose
conversions, and the terrain, which reads elevations indexed by real coordinates — calls
prime_meridian_angle. Earth resolves to Greenwich sidereal time by definition, which is why it
is exempt from all of this.
The planet space is what Environment::planet_body_axes publishes to the renderer: three
scene-frame columns whose third is planet_pole, filled by the ephemeris because nothing
downstream can derive a body’s pole and meridian, and carried as plain vectors so the render seam
stays free of the astro layer. Frame::drive_environment re-anchors observer_body to whichever
body the camera is on and rebases the whole world on a change, so time animation and precision hold
on every planet, not just Earth.
The observer’s latitude and longitude are derived, never authored.
Frame::derive_observer_coordinate
(engine/world/frame/include/SushiEngine/frame/observer_anchor.hpp) reads them off whatever the
world is anchored on — the driven entity while it plays, the camera while it does not — once that
anchor has drifted 10 km horizontally, and snaps the result to a 1 km body-fixed cube so the same
anchor always gives the same origin. The move is a rebase_scene_frame transaction, so nothing
shifts in the universe when the scene frame does.
Three modules give that model gravity and a planet-relative transform, all bodies handled by the same parametric code (Mercury–Pluto and the Moon, no per-body branches):
-
engine/domain/astro/include/SushiEngine/astro/gravity.hpp— the gravitational field. Bodies stay on their analytic Keplerian rails (the sources); a free entity is aStateVectorintegrated through the summed Newtonian fieldgravity_field()by a symplectic velocity-Verletintegrate_step(). Keeping the planets on rails makes the field a deterministic function of position and time — the property SushiLoop’s lockstep needs — and stops long orbits drifting the way a fully dynamic N-body system would.standard_gravitational_parameter()tabulates GM per body;sphere_of_influence_radius()sizes the Laplace SOI. The inverse-square field is evaluated only in double at the seam:|r|³over ~1e11 m collapses in the physics solve’s optional single precision. -
engine/domain/astro/include/SushiEngine/astro/reference_frame.hpp— the active reference frame. AReferenceFrameis body-centred but keeps inertial (ecliptic) axes, so a state expressed in it is the heliocentric state minus the body’s own — a Galilean shift with no fictitious terms.active_frame_body()picks the most local dominant attractor at a point;rebase()is the single double-precision coordinate change a sphere-of-influence crossing triggers, the orbital analogue of the floating-origin sector rebase (see the value-type seam). -
engine/domain/astro/include/SushiEngine/astro/surface_frame.hpp— the body-fixed surface frame that makes a planet-relative pose work. An entity near a body stores its position as body-fixed Cartesian metres (ECEF,geodetic_to_body_fixed/body_fixed_to_geodetic, with lat/lon only as a boundary conversion for authoring and the map) and its orientation relative to the local East-North-Uplocal_tangent_basis()at that position. Because the tangent basis is derived from position, “upright, facing north” is identity orientation everywhere on the body — the reason a southern-hemisphere entity stands straight rather than tilted.surface_gravity_vector()is the near-field pull: the inward ellipsoid normal timessurface_gravity(), correctly oriented over the whole body. -
engine/domain/astro/include/SushiEngine/astro/gravity_field.hpp—IGravityField, the field behind a dependency-inversion seam, andSummedRailsGravityField, the default on-rails summation. The orbital integrator names the interface, so a patched-conic or full N-body field can replace it without the integrator changing. -
engine/domain/astro/include/SushiEngine/astro/astro_dynamics.hpp—advance_astro_state()joins one field-parameterisedintegrate_stepand the SOIrebaseinto the single per-step authority update: lift the body-centred state to heliocentric, step it through the injected field, re-select the active frame from where it lands, express it there. -
engine/domain/astro/include/SushiEngine/astro/scene_frame.hpp—SceneFrame, the exact rigid bijection between a heliocentric-ecliptic position and the scene’s local frame, reproducing the ephemeris’s scene construction so a free body and the planet it orbits line up.engine/domain/astro/include/SushiEngine/astro/topocentric.hppholds the observer’s East-Up-South basis (shared with the ephemeris, DRY);engine/domain/astro/include/SushiEngine/astro/body_orientation.hpp’sbody_equatorial_to_eclipticis its inverse rotation. -
engine/domain/astro/include/SushiEngine/astro/scene_geodetic.hpp—scene_to_geodeticandgeodetic_to_scene, the two frames above composed with the ellipsoid: a scene position, the body’s centre within that scene and an epoch give a latitude, a longitude and an altitude, and back. The body-fixed step in the middle unwindsprime_meridian_anglerather than the raw IAU W. It is the boundary conversion the Inspector reads a surface pose back through.
RuntimeSimulation consumes this two ways. Per-body gravity: each physics step builds a
Simulation::GravitySampler (make_gravity_sampler) and hands it to IPhysicsStepper::step,
which samples it at every body’s own position each sub-step
(PhysicsWorld::predict_substep_field). The sampler maps a body’s scene position to
heliocentric, samples the injected Astro::IGravityField — the same SummedRailsGravityField
the orbital integrator uses, so gravity has one source — and rotates the acceleration back into
scene axes, so each body feels the true field (1/r² falloff, curvature toward the attractor,
third-body terms) rather than one vector shared by the whole scene. Sampling at the current
position keeps the semi-implicit predict symplectic. With no dominant body it falls back to a
uniform demo-gravity sampler.
There is no separate “astro body” mode: a body’s orbital motion emerges from this same per-body
gravity plus its velocity, through the one physics path (the exclusive astro toggle and its
parallel derive_astro_transforms pose-derivation were removed; the Astro:: dynamics modules
they used remain and now back the gravity sampler, and — ahead — the unified dynamic body’s Free
authority). The simulation still owns the master epoch: julian_date() advances by the fixed
step (scaled by set_time_scale_days_per_second), Frame::drive_environment drives the sky from
it through set_sky_observer on every host’s behalf, and the extracted snapshot carries it back —
one clock for orbits, planets, and the rendered sky. No host may make that call itself:
set_sky_observer is an OWNED_CALLS row in
tools/layering/check_host_frame_ownership.py, so a host
that drives the sky directly fails the check rather than quietly forking the frame.
Two planet-relative constraints run in RuntimeSimulation::apply_surface_constraints (from
extract, so they hold both while playing and after an edit). The gate is per entity, not per
scene: an entity that resolves to no reference body at all is skipped, which leaves a plain
non-astronomical scene untouched while still composing an entity bound to a body the scene does
not consider dominant:
-
Surface anchoring — an entity toggled through
set_surface_anchoredstores its orientation ground-local (relative to the East-North-Up tangent frame at its position); the pass composes the tangent frame onto it, so “upright” is identity everywhere on the body and a southern-hemisphere entity stands straight rather than tilted. “Up” is the geodetic normal (reference_surface_normal_scene, the ellipsoid gradient of the entity’s own reference body), not the geocentric radial, so a flattened body’s local vertical is exact — it delegates toAstro::geodetic_normalinengine/domain/astro/include/SushiEngine/astro/surface_frame.hpp. This is host-sideRecordbookkeeping (like colliders), not an ECS component — no Schedule system reads it, and it needs theEnvironmentthe systems do not see. -
The planet collider — every entity is kept outside the reference ellipsoid (its true flattened radius along the outward direction); a penetrating rigid body is re-posed through the physics seam so the surface is a hard floor. The editor’s Scene fly-camera is not clamped: it flies freely and may enter a body (Unity/Blender behaviour), the infinite-far reverse-Z projection carrying the depth range.
Frame-local authoring. An entity carries a reference frame (Simulation::EntityFrame = a
celestial body index + FrameMode Auto/Free/Surface) — the Unity-parent analogue with a body as
the parent. It is an authoring-boundary projection, not a second source of truth: the
scene-frame Transform stays what physics and render read, and frame_local_transform /
set_frame_local_transform convert between the frame-local pose and the scene Transform
through the scene-frame bijection at the master epoch.
In Surface mode the frame-local position is a geodetic coordinate (latitude, longitude,
altitude — scene_to_body_fixed / body_fixed_to_scene unwind the body’s spin W(t),
engine/domain/astro/include/SushiEngine/astro/surface_frame.hpp does the ellipsoid conversion), so
a spawn is placed the way a map reads; in Free it is a Cartesian offset from the body’s scene
centre (reference_center_scene). This is what lets the whole solar system be placed through one
Transform without typing heliocentric numbers, and it is the reference descriptor —
frame-independent — that a future networking layer syncs for zero-conflict (never the per-client
scene Transform). A reference body of -1 is the scene root, so an entity that never picks a body
is unchanged. Surface mode drives the surface anchoring above.

