Building Honest-to-Newton Lunar Terrain in Three.js
Deep dive into NASA's lunar elevation datasets (LOLA, SLDEM2015), procedural crater generation techniques, and practical implementation for WebGL-based space simulations. Includes code patterns, performance considerations, and why displacement mapping beats fake textures every time.
Why This Matters
If you’re building a lunar landing simulation—say, a Firefly Blue Ghost descent sequence—you hit a wall fast: most “moon textures” online are grainy JPEGs that look fine from orbit but turn into pixel mush when your lander drops below 10km altitude. For physically accurate simulations (the kind aerospace engineers actually trust), you need geometric data, not pretty pictures.
This is the research log from building realistic lunar terrain for Veenie’s simulation architecture—same tech stack powering the Venus atmospheric balloon sim. It’s equal parts NASA data archeology, shader programming, and “why is my moon white” debugging sessions.
The Data Landscape: What NASA Actually Provides
LOLA: 6.5 Billion Laser Measurements
The Lunar Reconnaissance Orbiter’s Laser Altimeter (LOLA) spent years bouncing laser pulses off the moon, collecting the most accurate planetary elevation dataset ever assembled. Here’s what you can actually download:
1. Point Cloud Data (The Raw Stuff)
- Format: COPC (Cloud Optimized Point Cloud) on AWS
- Volume: ~7 billion individual X,Y,Z measurements
- Accuracy: 10cm vertical precision, ~1m absolute accuracy
- Access: registry.opendata.aws/nasa-usgs-lunar-orbiter-laser-altimeter
This is the goldmine. Actual laser returns from orbit, organized in 15°×15° tiles. You can query specific regions via PDAL (Point Data Abstraction Library) and get lat/lon/radius triplets ready for mesh generation.
Example query (conceptual):
# Query 100km radius around Mare Crisium landing site
pdal pipeline query-moon.json
--readers.copc.filename=s3://usgs-lidar-public/LOLA/tile_045_030.copc.laz
--filters.crop.bounds="([61.8, 62.8], [18.0, 19.0])" Returns CSV with columns: longitude, latitude, radius, intensity
2. Gridded DEMs (Pre-Processed)
For those who don’t want to triangulate point clouds at runtime:
SLDEM2015 (PGDA link)
- Resolution: 60m/pixel at equator (512 pixels per degree)
- Coverage: ±60° latitude (LOLA + Kaguya Terrain Camera merged)
- Format: GeoTIFF (16-bit signed integer)
- Formula:
elevation_meters = pixel_value * 0.5 - Vertical accuracy: 3-4m RMS
Global LDEM 118m (USGS Astropedia)
- Lower res but complete global coverage
- Same GeoTIFF format
- ~500MB download
High-Res South Pole (5m/px) (PGDA landing sites)
- Resolution: 5 meters per pixel
- Includes XYZI point clouds, slope maps, uncertainty estimates
- Specific landing site regions (Artemis zones)
3. RDR Query Tool (Custom Extracts)
The LOLA RDR Query interface lets you:
- Define lat/lon bounding box
- Export up to 6M points as CSV or Shapefile
- Filter by observation time, altitude
- Get frame-accurate measurements
Perfect for extracting just your landing zone without downloading terabytes.
Data Formats Breakdown
| Format | Use Case | Pros | Cons |
|---|---|---|---|
| COPC Point Cloud | Dynamic streaming, highest fidelity | Raw measurements, no interpolation | Requires triangulation, large files |
| GeoTIFF DEM | Static heightmaps, proven workflow | Easy to parse, standard tools | Pre-interpolated, fixed resolution |
| CSV/Shapefile | Small regions, custom tools | Human-readable, flexible | Manual processing needed |
| XYZI Binary | Performance-critical apps | Compact, fast to parse | Less common tooling |
Processing Pipeline: GeoTIFF → Three.js
Here’s the practical path we took for the Firefly sim:
Step 1: Download SLDEM2015 Tile
# Get specific 1° tile (e.g., N18-N19, E61-E62 for Mare Crisium)
wget https://pgda.gsfc.nasa.gov/data/SLDEM2015/FLOAT_IMG/N18E061_SLDEM2015.IMG Step 2: Convert to Usable Format
# Using GDAL (standard geospatial library)
gdal_translate -of GTiff N18E061_SLDEM2015.IMG output.tif
# Or extract raw elevation array
gdal_translate -of ENVI -ot Float32 input.tif output.raw Step 3: Parse in JavaScript
// Load as Float32Array
const buffer = await fetch('/terrain/mare-crisium.raw').then(r => r.arrayBuffer());
const heights = new Float32Array(buffer);
// Generate Three.js geometry
const size = Math.sqrt(heights.length); // e.g., 512x512
const geometry = new PlaneGeometry(
MOON_RADIUS * 2,
MOON_RADIUS * 2,
size - 1,
size - 1
);
const positions = geometry.attributes.position.array;
for (let i = 0; i < heights.length; i++) {
const heightMeters = heights[i];
positions[i * 3 + 2] = heightMeters; // Z-up elevation
}
geometry.computeVertexNormals(); // Critical for lighting Step 4: Project to Sphere (for global moon)
// Convert flat heightmap to spherical coordinates
function projectToSphere(lat: number, lon: number, height: number) {
const phi = (90 - lat) * Math.PI / 180;
const theta = lon * Math.PI / 180;
const r = MOON_RADIUS + height;
return new Vector3(
r * Math.sin(phi) * Math.cos(theta),
r * Math.cos(phi),
r * Math.sin(phi) * Math.sin(theta)
);
} Procedural Techniques: When Data Isn’t Enough
Even 5m/px resolution fails at the final descent phase (<100m altitude). Here’s where algorithmic crater generation fills the gap.
Worley/Voronoi Noise for Craters
The secret: F2-F1 distance (distance to second-nearest point minus nearest) creates natural crater rings.
GLSL Implementation:
// Hash for pseudo-random cell points
vec2 hash2(vec2 p) {
return fract(sin(vec2(
dot(p, vec2(127.1, 311.7)),
dot(p, vec2(269.5, 183.3))
)) * 43758.5453);
}
// Worley noise returns (F1, F2) distances
vec2 worley(vec2 uv) {
vec2 i = floor(uv);
vec2 f = fract(uv);
float F1 = 1.0, F2 = 1.0;
for(int y = -1; y <= 1; y++) {
for(int x = -1; x <= 1; x++) {
vec2 neighbor = vec2(float(x), float(y));
vec2 point = hash2(i + neighbor);
float dist = length(neighbor + point - f);
if(dist < F1) {
F2 = F1;
F1 = dist;
} else if(dist < F2) {
F2 = dist;
}
}
}
return vec2(F1, F2);
}
// Generate crater from F2-F1
float craterNoise(vec3 worldPos) {
vec2 uv = sphericalUV(worldPos); // Convert 3D to 2D
vec2 F = worley(uv * craterDensity);
float crater = F.y - F.x; // The magic
crater = smoothstep(0.05, 0.2, crater); // Rim
crater -= smoothstep(0.0, 0.05, F.x) * 0.5; // Depth
return crater;
} Fractal Brownian Motion (FBM) layers multiple scales:
float fbmCraters(vec3 p) {
float value = 0.0;
float amplitude = 1.0;
float frequency = 1.0;
// Large craters
value += worleyCrater(p * frequency) * amplitude;
// Medium craters
frequency *= 2.3;
amplitude *= 0.4;
value += worleyCrater(p * frequency) * amplitude;
// Small impacts
frequency *= 3.1;
amplitude *= 0.3;
value += worleyCrater(p * frequency) * amplitude;
return value;
} Displacement Mapping vs Procedural Shaders
Displacement Mapping (what we used for Firefly):
- ✅ Real geometry deformation → collision detection works
- ✅ Accurate shadows and lighting
- ❌ Requires high vertex count (256×256+ segments)
- ❌ Memory intensive at scale
Procedural Vertex Shader:
- ✅ Zero texture memory
- ✅ Infinite detail at any scale
- ❌ Expensive noise calculations
- ⚠️ Need to compute normals via finite differences
// Approximate normal from height gradient
float eps = 0.1;
vec3 tangent1 = normalize(cross(normal, vec3(0, 1, 0)));
vec3 tangent2 = normalize(cross(normal, tangent1));
float h1 = craterNoise(pos + tangent1 * eps);
float h2 = craterNoise(pos + tangent2 * eps);
float h0 = craterNoise(pos);
vec3 grad = (h1 - h0) * tangent1 + (h2 - h0) * tangent2;
vec3 adjustedNormal = normalize(normal - grad * normalStrength); Implementation Patterns for Threlte/SvelteKit 5
Pattern 1: Standard Displacement Map
<script lang="ts">
import { T } from '@threlte/core';
import { useTexture } from '@threlte/extras';
const MOON_RADIUS = 1737.4; // km
const colorMap = useTexture('/textures/moon_color.jpg');
const displacementMap = useTexture('/textures/lola_dem.jpg');
</script>
{#await Promise.all([colorMap, displacementMap]) then [map, displacement]}
<T.Mesh>
<T.SphereGeometry args={[MOON_RADIUS, 256, 256]} />
<T.MeshStandardMaterial
{map}
displacementMap={displacement}
displacementScale={8.0}
displacementBias={-4.0}
/>
</T.Mesh>
{/await} Pattern 2: Custom Shader Material
<script lang="ts">
import { T } from '@threlte/core';
import { ShaderMaterial, Vector3 } from 'three';
const material = new ShaderMaterial({
vertexShader: `
varying vec3 vNormal;
uniform float uCraterDepth;
${worleyNoiseGLSL}
void main() {
vec3 displaced = position;
float height = craterNoise(normalize(position));
displaced += normalize(position) * height * uCraterDepth;
vNormal = computeNormal(displaced);
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}
`,
fragmentShader: `
varying vec3 vNormal;
uniform vec3 uSunDirection;
void main() {
float diff = max(dot(vNormal, uSunDirection), 0.0);
vec3 color = vec3(0.7) * (0.15 + diff * 0.85);
gl_FragColor = vec4(color, 1.0);
}
`,
uniforms: {
uCraterDepth: { value: 15.0 },
uSunDirection: { value: new Vector3(1, 0.5, 1).normalize() }
}
});
</script>
<T.Mesh>
<T.SphereGeometry args={[1737.4, 128, 128]} />
<T is={material} />
</T.Mesh> Pattern 3: LOD System (Altitude-Based)
<script lang="ts">
import { missionPhysics } from './context/physics-bridge.svelte';
let altitude = $derived($missionPhysics.telemetry?.altitude ?? 100);
let detailLevel = $derived(
altitude > 50 ? 'orbital' :
altitude > 10 ? 'approach' :
altitude > 1 ? 'descent' : 'landing'
);
</script>
{#if detailLevel === 'orbital'}
<MoonProcedural segments={128} craterDensity={2.0} />
{:else if detailLevel === 'approach'}
<MoonDisplacement segments={256} resolution="118m" />
{:else if detailLevel === 'descent'}
<MoonDisplacement segments={512} resolution="60m" />
{:else}
<MoonChunk center={$landerPosition} radius={5} resolution="5m" />
{/if} Performance Considerations
Geometry Complexity: | Segments | Vertices | Performance | Use Case | | -------- | -------- | ----------- | ------------ | | 64×64 | 4,096 | 60+ FPS | Orbital view | | 128×128 | 16,384 | 60 FPS | General use | | 256×256 | 65,536 | 30-60 FPS | High detail | | 512×512 | 262,144 | 15-30 FPS | Landing zone |
Optimization Strategies:
- Frustum Culling: Don’t render back side of moon
- LOD Swapping: Switch meshes based on camera distance
- Chunked Loading: Stream high-res tiles on demand
- Shared Textures: Reuse color map across LOD levels
Common Pitfalls (And How We Fixed Them)
White Moon Syndrome
Problem: Textures load but sphere renders pure white
Cause: Async texture loading race condition
Fix: Use {#await Promise.all([textures])} pattern
Camera-Dependent Lighting
Problem: Lighting changes when camera moves
Cause: Using positional light instead of directional
Fix: uSunDirection uniform (normalized vector) instead of position-based calculations
Pole Distortion
Problem: Craters stretched at lunar poles
Cause: Sphere UV mapping artifacts
Fix: Use 3D noise on normalized position, not UV coordinates
Displacement Pop-In
Problem: Visible mesh swap when changing LOD
Cause: Geometry discontinuity at transition
Fix: Cross-fade opacity over 1km altitude band
Tools & Libraries
Essential:
- GDAL - Geospatial data translation (
gdal_translate,gdalinfo) - PDAL - Point cloud processing
- Threlte - Svelte wrapper for Three.js
- Three.js - WebGL rendering
Helpful:
- glsl-worley - Worley noise shaders
- glsl-noise - Simplex/Perlin implementations
- QGIS - Visual GeoTIFF inspection
Next Steps: Building Production-Ready Terrain
For aerospace-grade simulations (like Veenie Kits), you need:
Phase 1: Static High-Res Base
- Download SLDEM2015 for landing zone
- Process to 512×512 displacement map
- Deploy with standard Three.js material
Phase 2: Dynamic LOD
- Implement altitude-based mesh swapping
- Pre-generate 3-4 resolution levels
- Add smooth transitions
Phase 3: Streaming Architecture
- Tile-based chunking (quadtree)
- Async loading from CDN/S3
- Garbage collection for distant tiles
Phase 4: Physics Integration
- Collision mesh from heightmap
- Raycast for surface normal at landing
- Regolith interaction model
Why This Matters for Space Simulations
When you’re pitching a lunar lander to investors (like Firefly’s Blue Ghost mission) or training AI pilots, “close enough” terrain doesn’t cut it. The difference between:
Fake: “Here’s a bumpy sphere with a crater texture”
Real: “This is Mare Crisium at 18.562°N, 61.810°E with LOLA-derived 5m resolution topography”
…is the difference between a pretty demo and a tool engineers actually trust.
The Veenie simulation architecture handles this by keeping physics headless (pure TypeScript, no rendering dependencies) and terrain swappable. Same codebase runs in browser, Node.js for AI training, or embedded in mission control dashboards.
For the Venus balloon sim, we use similar techniques with Magellan radar altimetry. Different planet, same principles: real data beats fake pretty every time.
Data Sources Quick Reference
Primary NASA Archives:
- LOLA PDS Node: http://imbrium.mit.edu/
- USGS Astropedia: https://astrogeology.usgs.gov/
- PGDA (Planetary Geodesy): https://pgda.gsfc.nasa.gov/
Cloud Access:
- AWS Open Data: https://registry.opendata.aws/nasa-usgs-lunar-orbiter-laser-altimeter/
- LOLA RDR Query: https://ode.rsl.wustl.edu/moon/
Community Resources:
- Book of Shaders (Worley): https://thebookofshaders.com/12/
- Inigo Quilez (Voronoi techniques): https://iquilezles.org/articles/voronoise/
- Three.js Displacement Example: https://threejs.org/examples/webgl_materials_displacementmap.html
Academic Papers:
- Barker et al. (2015) - SLDEM2015: https://doi.org/10.1016/j.icarus.2015.07.039
- Smith et al. (2010) - LOLA Instrument: https://doi.org/10.1007/s11214-009-9512-y
Conclusion
Building lunar terrain isn’t rocket science—it’s data science applied to rocket science. NASA gives you 6.5 billion measurements for free. GDAL converts them to meshes. Three.js renders them at 60fps. Procedural shaders fill the gaps.
The hard part isn’t the technology. It’s knowing which dataset to download, how to parse GeoTIFF headers, and why your moon keeps rendering white (always the async textures).
This research enables the next generation of space simulations—where you don’t fake the terrain, you simulate it. Same approach we use for atmospheric balloons on Venus and lunar landers.
If you’re building something similar, ping me (ivan@veenie.space). Happy to share GDAL pipelines, shader code, or just commiserate about WebGL debug hell.
Now go build something that would make Gene Kranz proud. 🚀
Related: