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:
- Check if output file already exists (skip download if so, unless
--force) - Download
LDEM_16.IMGfrom NASA PDS mirror - Parse PDS label to verify dimensions and encoding
- Strip PDS headers, write raw pixel data to output
- 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:
- Installs Python + numpy
- Runs
scripts/terrain-data/build_luna_heightmap.py - Copies
luna_heightmap.binto 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
TerrainManagerinitiates an async fetch of the heightmap URL when terrain is first activated- Until the data loads, the existing procedural terrain renders (seamless fallback)
- Once loaded,
TerrainHeightmapinstance is stored on theBodyTerrainobject; all cached mesh patches are cleared and regenerated with real heightmap data - 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:
- South Pole-Aitken basin appears at ~53°S, 169°E (visible from far side)
- Mare Imbrium appears at ~33°N, 344°E (near side)
- 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