Rendering — Inter-Tick Extrapolation
Overview
The game server broadcasts state updates at ~1 Hz (one per tick). Between ticks, the client must extrapolate positions to produce smooth 60 FPS rendering. At high time scales (100×+), extrapolation errors become visually significant.
Problem
Quadratic extrapolation diverges at high time scales
The original approach computed a single parabolic step from the last tick data each frame:
pos = tickPos + tickVel × dt + 0.5 × gravity(tickPos) × dt²
At 100× time scale with 1 Hz ticks, dt reaches ~100 game-seconds per tick interval.
Gravity direction changes as bodies orbit, but the quadratic uses constant acceleration
from tick-time. For a LEO ship (period ~5400 s), 100 game-seconds = 6.7° of orbit — the
parabola diverges ~2.5 km from the true circular path. The ship is the camera’s floating
origin, so this error makes all bodies (including Earth) jump on each tick correction.
Map view has no blend smoothing
mapView.onStateUpdate() overwrites state.bodyData with raw server data, destroying
blend offsets already set by the cockpit view. Bodies snap to new positions each tick.
Solution: Frame-by-Frame Velocity Verlet
Replace per-tick quadratic extrapolation with incremental velocity Verlet integration.
Each animation frame advances _clientPos / _clientVel by the frame’s game-time delta,
recomputing gravity at the current extrapolated position.
Accuracy
LEO ship at 100× time scale:
- Quadratic: ~2.5 km error per tick
- Verlet: ~0.21 m error per tick (12,000× improvement)
Performance
~15 bodies × 15 gravity calculations = 225 floating-point ops per frame at 60 FPS. Negligible impact.
Data Model
Each body, ship, and station in state.bodyData / state.myShipData /
state.shipStates / state.stationStates carries:
| Field | Type | Description |
|---|---|---|
_clientPos |
{x, y, z} |
Verlet-propagated position (meters, ICRF) |
_clientVel |
{x, y, z} |
Verlet-propagated velocity (m/s, ICRF) |
_blendOffset |
{x, y, z} or null |
Residual offset at tick boundary |
_blendStartTime |
number |
performance.now()/1000 when blend started |
Tick boundary reset
When new server data arrives (_updateBody / _updateMyShip / _updateShip / _updateStation):
- Compute
_blendOffset = oldData._clientPos − newData.position(if old data exists) - Set
_clientPosand_clientVelfrom the new server position/velocity - Set
_blendStartTimeto current wall-clock time
The blend offset decays linearly to zero over BODY_BLEND_DURATION (0.2 s).
Extrapolation Loop (per animation frame)
Frame timing
const realFrameDt = now - this._lastFrameTime; // wall-clock seconds
this._lastFrameTime = now;
const gameFrameDt = Math.min(realFrameDt, 0.1) * (state.timeScale || 1.0);
The 0.1 s cap on realFrameDt prevents position jumps when the tab is backgrounded.
Velocity Verlet step (each body / ship)
acc = gravAccel(_clientPos, bodyData, excludeSelf)
_clientPos += _clientVel × dt + 0.5 × acc × dt²
_clientVel += acc × dt
Gravity is computed using _clientPos of all other bodies (not tick positions),
so multi-body interactions remain self-consistent within each frame.
Own-ship thrust extrapolation
For the player’s own ship, the Verlet step includes thrust acceleration in addition to gravity. Without this, the client predicts a gravity-only trajectory between ticks, creating correction jumps whenever the ship is thrusting — especially noticeable during liftoff where the camera is near the surface and thrust dominates the acceleration budget.
thrustAccel = (SHIP_SPECS[ship_class].max_thrust * thrust_level) / mass
thrustDir = attitude.rotate([0, 0, 1]) // ship +Z = forward
acc = gravAccel(...) + thrustDir × thrustAccel
This only applies to the own ship (other ships’ thrust states update too infrequently for reliable extrapolation). The thrust direction uses the ship’s current attitude quaternion, which is interpolated separately.
Verlet-propagated objects
All objects that affect or are near the camera use Verlet:
- Celestial bodies (via
state.bodyData) - Own ship (via
state.myShipData) - Other ships (via
state.shipStates) - Stations (via
state.stationStates)
What still uses tick-origin cappedDt
- Body axial rotation (continuous, not cumulative)
- Lagrange point computation (position-independent)
- Ship/station attitude interpolation
Map View
The map view reads state.bodyData (shared with cockpit view). It does not overwrite
state.bodyData in onStateUpdate() — it only ensures meshes/indicators exist. Body
Verlet state is maintained by the cockpit view’s _updateBody() calls.
Ship and station indicators in map view also read from Verlet-propagated
_clientPos for consistency with the cockpit view.
Object Allocation Strategy (#780, #765)
The extrapolatePositions() function runs every frame at 60 FPS. To avoid GC pressure from per-frame allocations, all temporary Three.js objects (Vector3, Quaternion) are pre-allocated at module scope and reused each frame.
Pre-allocated temp objects in cockpitExtrapolation.js:
| Variable | Type | Used By |
|---|---|---|
_tmpVec3 |
THREE.Vector3 |
Body spin axis, landed ship offset, general temp |
_tmpVec3b |
THREE.Vector3 |
Second temp vector (ecliptic up for tilt quat) |
_tmpQuat |
THREE.Quaternion |
Body tilt quat, landed ship inverse quat |
_tmpQuatB |
THREE.Quaternion |
Body spin quat, attitude interpolation |
_prevQuat |
THREE.Quaternion |
Ship attitude prev-frame quat |
_currQuat |
THREE.Quaternion |
Ship attitude curr-frame quat |
These replace new THREE.Vector3() / new THREE.Quaternion() calls that previously ran inside per-body and per-ship loops every frame.
Terrain Shader Lighting
The terrain shader (TerrainMaterial.js) is a custom ShaderMaterial with its own lighting pipeline, separate from Three.js’s built-in MeshStandardMaterial lighting.
Lighting model
- Diffuse: Lambertian
max(dot(N, sunDir), 0.0)usingu_sunDirectionuniform (transformed to body-local space byTerrainManager), scaled byu_lightIntensity(passed from the scene DirectionalLight/PointLight intensity) to match MeshStandardMaterial brightness - Ambient: 0.03 (flat, no hemisphere) — low enough to keep the dark side visibly dark while still showing surface features
- Slope effect:
1.0 - 0.3 * (1.0 - abs(NdotL))— darkening on steep slopes - Skirt darken:
1.0 - vSkirt * 0.7— hides patch skirt edges
Tone mapping and output encoding
ShaderMaterial does not participate in the renderer’s automatic ACES tone mapping or sRGB output encoding. The terrain shader manually applies both to match the visual appearance of MeshStandardMaterial objects:
- ACES filmic tone mapping (Narkowicz 2015 approximation): compresses HDR lighting values into displayable range, crushes dark values more aggressively than simple gamma, and rolls off highlights
- sRGB gamma encoding:
pow(color, 1/2.2)converts linear to sRGB for display
// ACES filmic tone mapping (matches Three.js ACESFilmicToneMapping)
vec3 color = ACESFilm(linearColor);
// Linear → sRGB
gl_FragColor.rgb = pow(color, vec3(1.0 / 2.2));
The combination of low ambient (0.03) + light intensity scaling + ACES tone mapping produces a clear dark/lit terminator. Without ACES, simple gamma encoding lifts dark values excessively (a linear 0.045 maps to 0.24 perceived brightness — 24% instead of the intended ~5%).
Shadows
Terrain patches receive shadows from ships via a custom shadow map pipeline. The terrain shader samples u_shadowMap using u_shadowMatrix to transform terrain world-space positions into shadow UV+depth coordinates. The shadow factor multiplies only the diffuse component — ambient light is unaffected, so shadowed terrain is dim but not black.
Scale mismatch and shadow proxy
Ship models are built at meter scale (e.g., a cargo hauler is ~60m / 60 scene units). The shadow camera frustum operates at cockpit scale (COCKPIT_SCALE = 1e-7). This ~10^7× scale mismatch means the ship model is millions of times larger than the shadow frustum — it cannot render into the shadow map.
Solution: A shadow proxy — a simplified silhouette mesh at cockpit scale — casts the ship’s shadow. The proxy:
- Uses a low-poly silhouette geometry that approximates the ship’s outline (not a bounding box). Each ship class has a dedicated
createShadowProxy*()function that merges a few simple shapes (cylinders, boxes) into a singleBufferGeometry. This produces a recognizable shadow shape (e.g., the planetary lander shows its octagonal body and landing legs, not a square). - All geometry is pre-scaled to cockpit scale (
COCKPIT_SCALE = 1e-7) at creation time - Is invisible in the main render pass (
colorWrite: false, depthWrite: false) - Uses
customDepthMaterialthat writes constant depth 0 to the shadow map RGBA color attachment - Follows the ship’s position and attitude quaternion each frame
Constant depth 0 is correct because the proxy is always above the terrain surface — everything below it should be shadowed. This also avoids RGBA pack/unpack precision issues that caused splotchy artifacts with computed linear depth values.
All other scene meshes have castShadow = false to prevent garbage depth writes from Three.js’s default MeshDepthMaterial (which applies logarithmic depth — producing constant gl_FragDepth with the orthographic shadow camera).
Shadow map format
Three.js r160 uses RGBA color attachments for shadow maps (format 1023 = RGBAFormat, type 1009 = UnsignedByteType; shadow.map.depthTexture is undefined). The terrain shader reads via unpackRGBAToDepth(texture2D(u_shadowMap, uv)). The updateShadow() function passes shadow.map.texture (the color attachment).
Shadow matrix
The shadow matrix (u_shadowMatrix) is precomputed in updateShadowLight() from the shadow camera setup: biasMatrix × projectionMatrix × viewMatrixInverse. This avoids a one-frame lag that occurs when reading Three.js’s shadow.matrix (which is only updated during renderer.render()).
Shadow frustum
The ship-centric shadow frustum scales with AGL: ±200m at ground, up to ±20km at 50km altitude. Above 50km AGL, shadows switch to body-centric mode.
Texel snapping (shadow stability)
The shadow frustum center is snapped to the shadow map’s texel grid (texelSize = 2 × SHADOW_HALF / 2048). Without this, sub-texel shifts in the frustum center — caused by the ship rotating with the landed body or by floating-point drift — produce visible shadow jitter/swimming on terrain. Both the shadow camera and the proxy position snap to the same grid, keeping the shadow map stable between frames.
Shadow filtering
3×3 PCF (percentage-closer filtering) with near-zero bias (1e-6). Since only the shadow proxy writes to the shadow map, there is no terrain self-shadowing — bias can be minimal.
Distance-based texture blending
Rock texture detail and body photo texture blending use distance thresholds that scale with body radius, with minimum floors for small bodies:
- Rock texture fade:
nearDist = max(bodyRadius * 0.0003, 5e-6),farDist = max(bodyRadius * 0.003, 5e-5)— ensures texture visible from ~500m on small bodies - Photo texture blend:
photoNear = max(bodyRadius * 0.003, 1e-4),photoFar = max(bodyRadius * 0.03, 1e-3)— procedural terrain visible during approach (~1km/10km floors)
Star System Markers
The cockpit view displays labeled markers for other playable star systems, positioned at their real sky direction as seen from the player’s current system.
Data source
System positions are defined in shared_data.json SYSTEMS entries. Each system has a position in ICRF meters relative to the Solar System Barycenter. The direction vector from the current system’s position to each other system’s position gives the marker’s sky direction.
Positioning
System markers are placed at a fixed cockpit-space distance along the direction vector to the target system. The distance is large enough to appear “at infinity” behind all local objects but within the camera’s far plane:
direction = normalize(targetSystem.position - currentSystem.position)
markerPos = shipPos_cockpit + direction × SYSTEM_MARKER_DISTANCE
SYSTEM_MARKER_DISTANCE = 1e5 in cockpit-scale units (1e12 meters / 10 billion km). This is well beyond any local body but within the log depth buffer range.
Marker appearance
- Dot color: system’s
star_colorfrom shared config (e.g.#FFF0E0for Alpha Centauri,#FFA060for Barnard’s Star) - Label: system display name (e.g. “Alpha Centauri”)
- Distance: formatted in light-years using
formatDistanceLy() - Visibility: toggled with the
systemscategory inmarkerVisibility, included in the B-key toggle cycle - Targeting: click to target (type
system), included in target cycling
Distance computation
Inter-system distances are static (systems don’t move on human timescales), computed once from the ICRF position vectors. Distance in light-years: dist_meters / 9.461e15.
AGL (Above Ground Level) Display
Server-authoritative AGL
The physics server computes AGL in _compute_agl() using terrain_height_at_position() and broadcasts it in the ShipState protobuf (agl field, double, meters; -1 if unavailable).
The client displays server AGL when available (agl >= 0), falling back to client-side terrain computation only when the server value is unavailable (agl == -1). This prevents AGL jumps caused by client/server procedural noise disagreement — the Simplex noise implementations in JavaScript and Python produce different elevation values for the same coordinates, which caused ~1.8 km AGL discontinuities on bodies without heightmaps (e.g., Io).
Client-side AGL computation is still used as fallback and for inter-tick smoothing when approaching terrain-capable bodies before the server starts reporting AGL. Three locations compute client AGL: cockpitShipSystems.js, cockpitOrbitDiagram.js, mapView.js.
Landed ship terrain snap
When landed, the client snaps the ship’s visual position to the client-rendered terrain surface (cockpitExtrapolation.js). This ensures the ship sits on the terrain the player sees, not floating above or below it due to sample point differences.
Heightmap bodies (Luna, Mars): Client and server terrain agree precisely, so the snap produces sub-meter corrections.
Procedural-only bodies: Client and server now produce identical terrain via matched Simplex noise implementations. The terrain snap still applies a _posCorrection blend for minor position adjustments.
Simplex noise determinism: The seed-based permutation table shuffle in TerrainNoise.js must use Math.imul() for the LCG multiply (s * 1103515245). Plain * overflows IEEE 754 double precision (>2^53) after the first iteration, producing a completely different permutation table than Python’s arbitrary-precision integers. This caused ~1.5 km terrain disagreement on Io (254/256 permutation entries wrong).
Landed blend uses full correction: Because the landed pinning code resets _clientPos from the body-local offset every frame, the _posCorrection must be applied in full each frame (not multiplied by blendRate). The exponential decay on the correction itself provides the smooth transition. This differs from the Verlet blend pattern where _clientPos accumulates and correction * blendRate is added incrementally.
On liftoff, the landed→flying transition uses _posCorrection blending with the standard Verlet pattern: _clientPos starts at the old visual position (client terrain level) and the correction (server − prev) blends it toward the server position over ~200ms. This matches the normal tick blend direction (clientPos = prev, correction = server − prev).
_gravAccel() — propagated positions
Both views’ _gravAccel() functions use bd._clientPos || bd.position for each
attractor, so gravity is computed from Verlet-propagated positions rather than stale
tick-origin positions.