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):

  1. Compute _blendOffset = oldData._clientPos − newData.position (if old data exists)
  2. Set _clientPos and _clientVel from the new server position/velocity
  3. Set _blendStartTime to 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) using u_sunDirection uniform (transformed to body-local space by TerrainManager), scaled by u_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:

  1. 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
  2. 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 single BufferGeometry. 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 customDepthMaterial that 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_color from shared config (e.g. #FFF0E0 for Alpha Centauri, #FFA060 for Barnard’s Star)
  • Label: system display name (e.g. “Alpha Centauri”)
  • Distance: formatted in light-years using formatDistanceLy()
  • Visibility: toggled with the systems category in markerVisibility, 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.


Back to top

Galaxy — Kubernetes-based multiplayer space game

This site uses Just the Docs, a documentation theme for Jekyll.