Simulation

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.

moonterrainlolathreejsthreltedata-sourcesgeotiffpoint-cloudprocedural

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)

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

FormatUse CaseProsCons
COPC Point CloudDynamic streaming, highest fidelityRaw measurements, no interpolationRequires triangulation, large files
GeoTIFF DEMStatic heightmaps, proven workflowEasy to parse, standard toolsPre-interpolated, fixed resolution
CSV/ShapefileSmall regions, custom toolsHuman-readable, flexibleManual processing needed
XYZI BinaryPerformance-critical appsCompact, fast to parseLess 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:

  1. Frustum Culling: Don’t render back side of moon
  2. LOD Swapping: Switch meshes based on camera distance
  3. Chunked Loading: Stream high-res tiles on demand
  4. 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:

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:

Cloud Access:

Community Resources:

Academic Papers:

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: