Terrain Data Pipeline — Real Topography for Luna

Issue: #743 Parent Epic: #429 (Planetary Terrain System) Depends on: #742 (Quadtree Sphere LOD) — CLOSED

Summary

Replace Luna’s purely procedural heightmaps with real LOLA (Lunar Orbiter Laser Altimeter) elevation data. The LOLA data provides the large-scale terrain shape (basins, craters, maria), while procedural noise adds sub-resolution fine detail (surface roughness, small rocks). Bodies without real data continue using procedural-only generation.

Data Source

LOLA LDEM_16 — 16 pixels per degree global lunar DEM:

Property Value
Resolution 16 pixels/degree
Dimensions 5,760 x 2,880 pixels
Meters/pixel ~1,895 m at equator
Format 16-bit signed integer (little-endian)
Raw size 33.2 MB
Scale factor 0.5 (DN × 0.5 = elevation in meters)
Reference radius 1,737,400 m
Projection Simple cylindrical (equirectangular)
Latitude 90°N (row 0) to 90°S (row 2879)
Longitude 0°E (col 0) to 360°E (col 5759)
Source URL https://imbrium.mit.edu/DATA/LOLA_GDR/CYLINDRICAL/IMG/LDEM_16.IMG

Feature Visibility at 16 ppd

  • Tycho crater (85 km): ~45 pixels across
  • Copernicus crater (93 km): ~49 pixels across
  • Mare Imbrium (1,100 km): ~580 pixels across
  • South Pole-Aitken basin (2,500 km): ~1,300 pixels across
  • Smallest resolved feature: ~4 km (2 pixels)

Build-Time Data Pipeline

Script: scripts/terrain-data/build_luna_heightmap.py

Downloads LOLA data and converts to a web-optimized binary format.

Input: LDEM_16.IMG (33.2 MB, downloaded from NASA PDS)

Output: services/web-client/public/terrain/luna_heightmap.bin

Output format: Raw binary, 5,760 × 2,880 pixels, Int16 little-endian (same encoding as source). The web client applies the scale factor (×0.5) at load time.

Output size: 33.2 MB (same as input — just the raw pixel data without PDS headers/labels)

Script behavior:

  1. Check if output file already exists (skip download if so, unless --force)
  2. Download LDEM_16.IMG from NASA PDS mirror
  3. Parse PDS label to verify dimensions and encoding
  4. Strip PDS headers, write raw pixel data to output
  5. Verify output by spot-checking known elevations (e.g., Mons Huygens ~10,786m)

Dependencies: Python 3, numpy. No Pillow needed (pure binary I/O).

.gitignore

Add to services/web-client/public/terrain/:

# Generated terrain data — built by scripts/terrain-data/build_luna_heightmap.py
*.bin

The binary data is NOT committed to git. It is generated at build time.

Dockerfile Integration

In services/web-client/Dockerfile, add a build stage that:

  1. Installs Python + numpy
  2. Runs scripts/terrain-data/build_luna_heightmap.py
  3. Copies luna_heightmap.bin to the nginx static assets directory
# Terrain data build stage
FROM python:3.11-slim AS terrain-data
RUN pip install numpy
COPY scripts/terrain-data/ /scripts/
RUN python /scripts/build_luna_heightmap.py --output /terrain/luna_heightmap.bin

# Final stage (existing nginx)
COPY --from=terrain-data /terrain/ /usr/share/nginx/html/terrain/

Nginx Serving

The heightmap is served as a static file at /terrain/luna_heightmap.bin. Nginx compresses with gzip in transit (~33 MB raw compresses to ~15-20 MB gzipped).

No special nginx configuration needed — the existing static file serving handles it.

Web Client Integration

New Module: services/web-client/src/terrain/TerrainHeightmap.js

Loads and samples the real heightmap data.

export class TerrainHeightmap {
  constructor(width, height, data) {
    this.width = width;    // 5760
    this.height = height;  // 2880
    this.data = data;      // Int16Array
    this.scaleFactor = 0.5; // DN to meters
  }

  /**
   * Sample elevation at a unit sphere direction.
   * @param {number} nx, ny, nz - Unit sphere normal (body-local)
   * @returns {number} Elevation in meters relative to reference radius
   */
  sample(nx, ny, nz) {
    // Convert sphere normal to lat/lon
    const lat = Math.asin(clamp(ny, -1, 1));  // radians, Y = polar axis
    const lon = Math.atan2(nx, nz);            // radians, 0 at +Z, increases toward +X
    // ... convert to pixel coords, bilinear interpolation
  }
}

Sphere Normal to Lat/Lon Mapping

The body-local coordinate system uses Y as the polar axis (matching cube face layout where ±Y are top/bottom faces):

  • Latitude: lat = asin(ny) — ranges from -π/2 (south pole) to +π/2 (north pole)
  • Longitude: lon = atan2(nx, nz) — ranges from -π to +π

Convert to LOLA pixel coordinates:

row = (0.5 - lat/π) × height    // 90°N = row 0, 90°S = row 2879
col = (lon_deg / 360) × width   // 0°E = col 0, 360°E = col 5759

Where lon_deg = (lon_rad × 180/π + 360) % 360 to convert from [-180, 180] to [0, 360).

Bilinear Interpolation

Sample the 4 nearest pixels and interpolate:

(col_floor, row_floor), (col_ceil, row_floor)
(col_floor, row_ceil),  (col_ceil, row_ceil)

Longitude wraps at 0°/360° (column index wraps modulo width). Latitude clamps at poles.

Alignment Verification

The prime meridian (0° longitude) must align with Luna’s actual prime meridian (sub-Earth point). This depends on the body-local coordinate system matching the IAU convention. The spin axis orientation and prime meridian offset in cockpitExtrapolation.js (via bodyConfig.js) define this alignment. If the body rotates with the correct prime meridian angle, the LOLA data will automatically align — both use the IAU reference frame.

Loading Strategy

  1. TerrainManager initiates an async fetch of the heightmap URL when terrain is first activated
  2. Until the data loads, the existing procedural terrain renders (seamless fallback)
  3. Once loaded, TerrainHeightmap instance is stored on the BodyTerrain object; all cached mesh patches are cleared and regenerated with real heightmap data
  4. Subsequent terrainHeight() calls use real data + fine detail overlay

Gzip Decompression (#775)

For .gz heightmap URLs, the loader uses the browser DecompressionStream API for streaming gzip decompression. On browsers that lack DecompressionStream (Safari < 16.4, older Firefox), it falls back to response.arrayBuffer() — the server’s gzip transit encoding (Content-Encoding: gzip) transparently decompresses the response.

if (typeof DecompressionStream !== 'undefined') {
  // Modern browsers: streaming decompress
  const ds = new DecompressionStream('gzip');
  buffer = await new Response(response.body.pipeThrough(ds)).arrayBuffer();
} else {
  // Fallback: server transit decompression handles it
  buffer = await response.arrayBuffer();
}

Retry with Exponential Backoff (#768)

If the heightmap fetch fails, the loader retries with exponential backoff to prevent flooding the server at 60 requests/second:

  • Delays: 1s, 2s, 4s, 8s, 16s (capped at 30s)
  • Maximum 5 attempts before giving up permanently
  • Procedural terrain continues rendering if all retries fail
  • State tracked per body: _heightmapRetryAfter (timestamp), _heightmapRetryCount

Modified terrainHeight() in TerrainNoise.js

When a TerrainHeightmap is available for the body:

export function terrainHeight(noise, nx, ny, nz, profile, heightmap = null) {
  if (heightmap) {
    // Real data: sample LOLA heightmap for large-scale shape
    let h = heightmap.sample(nx, ny, nz);

    // Add procedural fine detail (high-frequency noise only)
    // Skip fBm large-scale (octaves 1-8), skip large basins, skip medium craters
    // Keep: fine-detail noise for sub-2km surface roughness
    if (profile.noiseOctaves > 8) {
      const fineOctaves = profile.noiseOctaves - 8;
      const amplitude = (profile.maxElevation - profile.minElevation) * 0.5;
      h += fbm(noise, nx * 64, ny * 64, nz * 64, fineOctaves, 2.0, 0.5) * amplitude * 0.05;
    }

    return h;
  }

  // Procedural fallback (existing code, unchanged)
  // ...
}

What’s replaced: fBm large-scale terrain, medium craters, small craters, large named basins — all of these are captured in the real LOLA data at 16 ppd resolution.

What’s kept: Fine-detail noise (octaves 9+, at 64× frequency) adding ~5% amplitude surface roughness below the 1.9 km pixel resolution.

Terrain Profile Changes

Add a heightmapUrl field to the Luna profile:

export const LUNA = {
  // ... existing fields ...
  heightmapUrl: '/terrain/luna_heightmap.shuf.gz',
  heightmapWidth: 5760,
  heightmapHeight: 2880,
  heightmapScaleFactor: 0.5,  // DN × 0.5 = meters
  heightmapShuffled: true,    // byte-shuffled Int16 for better gzip compression
};

The heightmapShuffled flag indicates the binary data uses byte shuffling (all low bytes concatenated, then all high bytes) before gzip compression. This improves compression ratio from ~2× to ~5.7× for Int16 elevation data. The loader unshuffles after decompression.

Bodies without heightmapUrl continue using procedural-only generation.

Coordinate System Alignment

Body-Local Frame

The terrain system’s body-local coordinate system (cube-sphere axes):

  • Y-axis = polar axis (spin axis)
  • X-axis = equatorial, 90° east of prime meridian
  • Z-axis = equatorial, prime meridian (0° longitude)

LOLA Frame

  • North pole = +Y in body-local
  • 0° longitude = +Z in body-local (sub-Earth point)
  • 90°E longitude = +X in body-local

This mapping is natural for the cube-sphere layout and matches the IAU body-fixed frame that cockpitExtrapolation.js already uses for body rotation.

Verification

After integration, verify alignment by checking:

  1. South Pole-Aitken basin appears at ~53°S, 169°E (visible from far side)
  2. Mare Imbrium appears at ~33°N, 344°E (near side)
  3. Tycho crater appears at ~43°S, 349°E (near side, southern highlands)

File Structure

scripts/terrain-data/
  build_luna_heightmap.py     — Download + convert LOLA data
  README.md                   — Usage instructions

services/web-client/
  public/terrain/
    luna_heightmap.bin         — Generated binary (gitignored)
    .gitignore                 — Ignore generated binaries
  src/terrain/
    TerrainHeightmap.js        — Heightmap loader + sampler (NEW)
    TerrainNoise.js            — Modified: accepts optional heightmap
    TerrainProfiles.js         — Modified: adds heightmapUrl to Luna
    TerrainManager.js          — Modified: loads heightmap, passes to terrainHeight
    TerrainPatch.js            — Modified: passes heightmap to terrainHeight

Performance Considerations

  • Memory: 33 MB for the Int16Array (one-time allocation)
  • Load time: ~1-3 seconds over localhost (gzipped ~15-20 MB), async with procedural fallback
  • Sampling cost: Bilinear interpolation is 4 lookups + 3 lerps per vertex — negligible compared to simplex noise (which does 3D gradient hashing per octave)
  • No tiling needed: 33 MB fits comfortably in browser memory as a single buffer

Acceptance Criteria

  • Luna renders with recognizable real features (Tycho, Copernicus, Mare Imbrium, SPA basin)
  • Procedural fine detail visible at close range (sub-2km surface roughness)
  • Seamless fallback to procedural terrain while heightmap loads
  • Build pipeline downloads and converts LOLA data without manual steps
  • Docker image includes terrain data as static asset
  • No visual seams or artifacts at data boundaries (pole regions, 0°/360° wrap)
  • Terrain rotation aligns real features with correct lunar coordinates
  • Bodies without real data (future) still render with procedural terrain
  • Total terrain asset size < 50 MB in Docker image

Back to top

Galaxy — Kubernetes-based multiplayer space game

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