Quadtree Sphere LOD Terrain System
Issue: #742 Parent Epic: #429 (Planetary Terrain System)
Summary
Replace the fixed SphereGeometry body rendering with a CDLOD-style quadtree sphere that provides continuous level-of-detail from orbital distances down to near-surface (meter-scale) resolution. Luna is the first target body. Applies to both cockpit and map views.
Design Overview
Each body eligible for terrain rendering uses a cube-sphere geometry: 6 root quadtree faces projected onto a sphere. Each quadtree node represents a terrain patch — a small vertex grid (e.g., 33×33) with height displacement. The quadtree subdivides based on camera distance, giving high detail near the camera and coarse detail far away.
Cube-Sphere Projection
Six root patches correspond to the faces of a cube. Each face is recursively subdivided into 4 children. Vertex positions on the flat grid are normalized to unit sphere coordinates, then scaled by radius + height(lat, lon).
+Y
|
+----+----+
/| top /|
/ | face / |
+----+----+ |
| +--|---+--+ → +X
| / | front|
|/ bottom |/
+----+----+
/
+Z
LOD Selection
A node subdivides when:
screen_space_error = (patch_geometric_error * screen_height) / (2 * distance * tan(fov/2))
If screen_space_error > threshold (e.g., 2.0 pixels), subdivide. Otherwise, render this node.
patch_geometric_error= maximum height difference within the patch (bounded by noise amplitude at that scale)distance= camera distance to patch center- Maximum depth: 18 (~6.6m patches on Luna, finer with per-face subdivision)
Hybrid Height Generation
CPU layer (coarse):
- Generates heightmap tiles as
Float32Arrayat moderate resolution (e.g., 256×256 per face at each LOD level) - Cached in an LRU tile cache (keyed by face + quadtree path)
- Used for: coarse LOD rendering, future physics queries, normal map generation
- Noise: multi-octave simplex noise + crater generation
- Generated on demand, async (Web Worker)
GPU layer (fine detail):
- Vertex shader adds high-frequency detail on top of CPU heightmap
- Fractal noise in shader for sub-tile detail (octaves beyond CPU resolution)
- Only active at high LOD levels (depth > 12)
- Deterministic: same inputs → same output (no random state)
Terrain Profile: Luna
const LUNA_TERRAIN = {
baseRadius: 1_737_400, // meters
maxElevation: 10_786, // Mons Huygens (relative to mean)
minElevation: -9_100, // South Pole-Aitken basin
noiseOctaves: 12, // CPU-side octaves
gpuDetailOctaves: 6, // additional GPU octaves
craterDensity: 0.8, // relative density
craterSizeRange: [100, 250_000], // meters (min, max diameter)
largeBasins: [ // named features (procedural approximation)
{ lat: -53, lon: 169, diameter: 2500_000, depth: 8200, name: 'South Pole-Aitken' },
{ lat: 13, lon: -3, diameter: 1120_000, depth: 4800, name: 'Imbrium' },
{ lat: -20, lon: -65, diameter: 930_000, depth: 3000, name: 'Orientale' },
],
seed: 42, // deterministic seed
};
Rendering Pipeline
Per-Frame Update
- Traverse quadtree for each terrain-enabled body:
- Compute camera distance to each node center (in body-local space)
- Apply LOD selection criteria
- Collect visible leaf nodes into render list
- Request missing tiles from CPU heightmap cache (async)
- Build/update patch geometry for visible nodes:
- Reuse geometry buffers from pool (avoid GC pressure)
- Apply CPU heightmap displacement to vertex positions
- Generate skirt vertices for seam hiding
- Render patches with terrain material:
- Vertex shader: apply fine-detail GPU displacement
- Fragment shader: compute surface normal from height gradient, apply lighting
Patch Geometry
Each patch is a 33×33 vertex grid (32×32 quads = 2048 triangles per patch):
- Positions computed from cube-sphere projection + height displacement
- UVs mapped for texture lookup
- Vertex normals are body-local sphere normals (
cubeToSphereunit direction). These are passed to the fragment shader asvBodyLocalNormalfor body-texture UV sampling. World-space normals for lighting are derived in the fragment shader fromnormalize(vWorldPos - u_bodyCenter)(#998), which is robust against model matrix precision issues. - Skirts: 4 edge strips extending radially inward by
skirtDepthto hide T-junction cracks between LOD levels. Skirt vertices copy normals from their corresponding edge vertices.
Skirt depth = max_height_difference * 2 at current LOD level
Geometry Buffer Pool
Pre-allocate a pool of BufferGeometry objects (e.g., 512) to avoid allocation/deallocation churn during camera movement. Each pool entry has:
- Position buffer:
Float32Array(33 * 33 * 3 + skirt_vertices * 3) - Normal buffer: same size
- UV buffer:
Float32Array(33 * 33 * 2 + skirt_vertices * 2) - Index buffer: shared (same topology for all patches at same grid size)
Material
Single ShaderMaterial per body with toneMapped: false (shader handles ACES + gamma manually):
u_sunDirection: sun direction in world space (extracted from scene directional light position)u_bodyCenter: body center position in world space (terrain group position), used for world-space normal derivationu_rockTexture: procedurally generated rock/regolith detail texture (shared across all terrain bodies)u_texScale: texture tiling scale derived from body radius
Lighting normal computation (#998): World-space normals for lighting are derived in the fragment shader from normalize(vWorldPos - u_bodyCenter) rather than transforming attribute normals via mat3(modelMatrix). This is robust against edge cases where the model matrix normal transform produces degenerate values (e.g., NaN from GPU float precision at cockpit scale). The vertex attribute normal (body-local sphere direction) is passed through as vBodyLocalNormal for body-texture UV sampling.
Fragment shader computes:
- Surface color: distance-blended between body photo texture (orbit) and procedural highland/maria (surface). Photo textures use GPU-native sRGB→linear conversion (
texture.colorSpace = SRGBColorSpace→GL_SRGB8internal format); no manualpow(2.2)in the shader (#998). - Height-based brightness modulation: valleys darker (0.85×), peaks brighter (1.15×)
- Lighting from world-space normals (derived from world position, not vertex attribute transform)
- Rock texture detail modulation (distance-faded: full detail near surface, plain albedo at distance)
- Distance fog / atmosphere blend (for atmospheric bodies, future)
Debug Diagnostic (#998)
The terrain shader supports 7 debug modes via u_debugMode uniform, controlled from the browser console:
window.__TERRAIN_DEBUG = N // 0=normal, 1=red, 2=normals, 3=NdotL,
// 4=diffuse*light, 5=albedo, 6=sun dir, 7=body center dist
- Mode 1 (red): Verifies shader execution — if terrain isn’t red, the shader isn’t running
- Mode 2 (normals): Shows
N*0.5+0.5as RGB — should be a rainbow sphere - Mode 3 (NdotL): Grayscale diffuse — white on sun-facing side, black on dark side
- Mode 5 (albedo): Raw surface color — diagnoses texture/color issues
The TerrainManager reads window.__TERRAIN_DEBUG each frame and updates the uniform.
GPU Resource Lifecycle (#767)
The rock texture is shared across all terrain bodies via reference counting:
getRockTexture(): Creates the 512×512 procedural texture on first call, increments refcount on subsequent callsreleaseRockTexture(): Decrements refcount; disposes the GPU texture when count reaches zero- Called from
BodyTerrain.dispose()when terrain is cleaned up
This prevents GPU memory leaks when terrain bodies are activated/deactivated repeatedly (e.g., flying between bodies).
Integration Points
Cockpit View (cockpitMeshes.js)
In updateBody():
- When camera distance to body <
LOD_ACTIVATION_DISTANCE(e.g.,200 * radius), switch fromSphereGeometryto terrain quadtree - The terrain object replaces the body mesh in the scene (or is added as a sibling)
- Atmosphere mesh remains as child of body group
- Wireframe overlay disabled when terrain is active
- Body rotation (spin) still applied to the terrain group
Floating origin: Already handled — ship at origin, body position offset. Terrain patches are computed in body-local coordinates, then transformed by body world matrix. At COCKPIT_SCALE = 1e-7, Luna radius = 0.1737 units — Float32 precision is sufficient for orbital distances but may need care at surface level.
Surface-level precision: When camera altitude < 100 km, use a body-local floating origin: subtract body center from all positions before rendering terrain patches. This gives full Float32 precision relative to the body surface.
Map View (mapBodies.js)
In ensureBody():
- When map zoom brings a body to sufficient screen size (> 100px diameter), activate terrain LOD
- Same quadtree system, lower max depth (e.g., depth 8 for map — terrain visible but not meter-scale)
- Map view uses
MAP_SCALEinstead ofCOCKPIT_SCALE
Texture System
- Terrain bodies use a distance-blended approach: the body’s photo texture (e.g.
mars.jpg,moon.jpg) is sampled in the terrain fragment shader at orbital distances, blending to procedural highland/maria coloring at close range- Full photo texture: view distance >
bodyRadius × 0.03(~100 km for Mars) - Full procedural: view distance <
bodyRadius × 0.003(~10 km for Mars) - Smooth blend between these thresholds
- Full photo texture: view distance >
- The photo texture is loaded via
TextureManager.request()when terrain first activates for a body - Bodies without a photo texture (exoplanets, small moons) use procedural coloring at all distances
- Non-terrain bodies (stars, gas giants) continue using texture maps on
SphereGeometryunchanged - Atmosphere meshes unaffected (atmosphere.js already handles shell rendering)
Body Rotation
Body spin is applied via quaternion in cockpitExtrapolation.js. The terrain group copies the body mesh’s quaternion (tilt + spin), so terrain patches rotate with the body. The terrain heightmap is in body-fixed coordinates.
Vertex normals are body-local sphere directions, passed through as vBodyLocalNormal for texture UV sampling. For lighting, world-space normals are derived in the fragment shader from normalize(vWorldPos - u_bodyCenter), which is robust against model matrix precision issues (#998). The sun direction is passed in world space.
Camera body-local transform for LOD (#1054): The camera position MUST be transformed from world space to body-local space before LOD selection. TerrainManager computes the inverse body quaternion (_invQuat) from the body mesh quaternion each frame and applies it to the camera-relative position. Without this transform, the LOD system selects high-resolution patches at the wrong body-local direction, causing landed ships to sit on low-resolution terrain with completely wrong elevation values (up to ~1km error on bodies with rough procedural terrain like Callisto).
Horizon Culling
During LOD subdivision (Phase 2, depth > MIN_DEPTH), patches beyond the geometric horizon are skipped. This prevents far-away invisible patches from consuming the patch budget, freeing it for nearby patches that need deep subdivision.
Geometry: For a camera at distance d from body center with body radius R, a point on the sphere surface is visible if the dot product of the camera position vector and the point’s unit normal exceeds R:
dot(cameraPos, nodeCenter) > R
A depth-scaled margin ensures partial visibility at patch boundaries:
dot(cameraPos, nodeCenter) > R * (1 - nodeSize)
At low depths (coarse patches), nodeSize is large → generous margin. At high depths (fine patches), nodeSize ≈ 0 → tight culling near the true horizon.
Impact: At 81km altitude above Luna (horizon angle 17.1° from nadir), horizon culling reduces the number of patches needing subdivision by ~90%.
Depth Budget Reservation
Without budget control, hundreds of medium-depth patches (depth 5-7) consume the entire patch budget before any patch reaches high depth. At 93km altitude, the old algorithm produced maxDepth=7 with 1564 patches stuck at that level.
Each depth level is limited to at most half the remaining budget:
maxSplitsAtDepth = max(4, floor(remainingBudget * 0.5 / 3))
Within each depth, patches are split highest-error first (closest to camera). This ensures the camera’s nadir column always reaches the deepest LOD levels while maintaining broad coverage at coarser levels. Verified: at 93km altitude, maxDepth improved from 7 to 14.
Performance Budget
| Metric | Target |
|---|---|
| Max visible patches | 2000 |
| Triangles per patch | 2,048 (32×32 grid) |
| Max terrain triangles | ~2.4M |
| Geometry pool size | 512 buffers |
| CPU heightmap cache | 64 MB (LRU) |
| Heightmap tile generation | < 5ms per tile (Web Worker) |
| Frame budget for LOD traversal | < 2ms |
| Target framerate | 60 FPS |
| New patches per frame | 200 max (throttled to prevent frame drops) |
Patch Build Throttling
To prevent frame drops during heavy LOD subdivision (e.g., rapid altitude change), at most 200 new patches are built per frame (MAX_NEW_PER_FRAME = 200). Remaining patches are deferred to subsequent frames and built progressively. Deferred patches only build if they are still in the current visible set (stale deferred nodes are discarded).
LOD Rebuild Hysteresis
LOD selection only rebuilds when the camera has moved more than 2% of its distance from the body center (camMovedSq > (distMeters * 0.02)²). This prevents per-tick LOD jitter caused by body rotation shifting the camera’s body-local position slightly each frame. If no rebuild is needed, only morph factors are updated on existing patches.
File Structure
services/web-client/src/
terrain/
TerrainQuadtree.js — Quadtree data structure, LOD selection
TerrainPatch.js — Patch geometry generation, buffer pool
TerrainMaterial.js — Shader material (vertex + fragment)
TerrainNoise.js — Simplex noise, crater generation
TerrainProfiles.js — Per-body terrain parameters (Luna, etc.)
TerrainManager.js — Integration: manages terrain per body, LOD activation
terrainWorker.js — Web Worker for async heightmap generation
Transition Behavior
| Camera Distance | Rendering |
|---|---|
| > 200× radius | Simple SphereGeometry (existing) |
| 10-200× radius | Terrain quadtree, low LOD (depth 4-8) |
| 1-10× radius | Medium LOD (depth 8-14) |
| < 1× radius (surface) | High LOD (depth 14-18), body-local floating origin |
The transition from sphere to terrain should be seamless — at the activation distance, the lowest-LOD terrain (6 patches, one per face) should closely match the existing sphere appearance.
Seam Handling: Patch Overlap + Skirts
Edge Vertex Morphing (Primary Gap Fix)
At LOD boundaries, a coarse patch’s GPU-interpolated surface can sit above a fine patch’s edge vertices. Skirts only extend downward and cannot fill upward gaps, producing visible concentric ring artifacts centered below the camera.
Solution: Force morphFactor = 1 on all edge vertices (first/last row and column) in updatePatchMorphFactor. The geomorph system already computes morph deltas that snap odd-indexed vertices to the parent-grid interpolation. By always morphing edges to parent level, T-junction vertices match the coarse neighbor’s interpolated surface exactly.
- Even-indexed edge vertices have
morphDelta = 0(shared with parent grid), sofactor = 1is a no-op - Odd-indexed edge vertices snap to parent interpolation, closing T-junction gaps
- Interior vertices use the normal distance-based morph factor (smooth transitions)
- No extra vertices, no index buffer changes, no neighbor tracking
- Root nodes (depth 0) skip edge morphing (no parent morph targets)
Skirts (Secondary Safety Net)
Each patch edge has a skirt — a strip of triangles extending radially inward from the edge vertices. Skirts fill sub-pixel gaps at mesh boundaries between separate draw calls.
patch surface
──────────────
| |
| terrain |
| |
──────────────
|||skirt|||||| ← extends inward (toward body center)
Skirt depth factor: max(2.0 × height_variation, 5% of patch arc length, 200m).
With patch overlap as the primary gap fixer, skirts are a secondary safety net and can use a smaller depth factor.
Skirt z-bias: gl_Position.z += a_skirt * 0.00005 * gl_Position.w pushes skirts behind main surface in the depth buffer.
Normal Strategy (Critical for Ring-Free Rendering)
LOD ring artifacts are caused by skirt pixels receiving wrong normals. Skirt triangles face perpendicular to the sphere surface, so any normal computation based on skirt mesh geometry (including dFdx/dFdy screen-space derivatives) gives wildly different lighting than the adjacent main surface.
Height-gradient vertex normals: For each main surface vertex, finite differences on the already-computed heights[] grid produce displaced sphere positions at neighboring vertices. Surface tangent vectors (U and V directions) are computed from these displaced positions; their cross product gives a smooth normal that reflects terrain features (craters, ridges). An outward-pointing check (dot(normal, sphereNormal) > 0) ensures consistent orientation. These normals are view-independent and resolution-independent — they depend only on the height field, not on mesh geometry or camera position.
Skirt vertices: Copy the normal from their corresponding edge vertex. Since GPU interpolation within skirt triangles uses these copied normals, the skirt surface receives the same lighting as the adjacent main surface, preventing visible seams at LOD boundaries.
Why not dFdx/dFdy? Screen-space derivative normals are flat per-triangle and change with camera angle and zoom level (LOD switches produce different triangle configurations). They also give incorrect results on skirt triangles. Height-gradient vertex normals are computed once on the CPU and are stable across views.
Consistent Winding Order
All six cube faces must have tangentU × tangentV pointing outward (matching the face normal). The index buffer must use CCW winding when viewed from outside — specifically (i, i+1, i+gs) not (i, i+gs, i+1), because the latter produces edge vectors in tangentV×tangentU order (inward normal), which causes FrontSide to cull the near hemisphere and render only the far side.
FrontSide Rendering
Material uses THREE.FrontSide (not DoubleSide). With consistent winding, all outward-facing triangles are front-facing. DoubleSide causes skirt back faces to z-fight with adjacent main surfaces, creating flickering ring artifacts.
Determinism
All procedural generation is seeded:
seed+ body name → unique per-body seed- Same seed → identical terrain at any LOD level, any viewing angle
- CPU and GPU noise use the same seed/parameters for consistency
Acceptance Criteria
- Luna renders with visible terrain features (craters, highlands) when approached
- Smooth LOD transitions — no visible popping between subdivision levels
- No T-junction cracks between adjacent patches at different LOD levels
- Performance: maintains 60 FPS with terrain sphere in view
- Far bodies still render correctly (simple sphere at distance)
- Both cockpit and map views support terrain rendering
- Floating-origin prevents jitter near surface
- Procedural terrain is deterministic (same seed → same terrain)
- Terrain does not affect physics (visual only in this phase)
- Atmosphere meshes render correctly over terrain
- Body rotation applies correctly to terrain
Dependencies
- #741 (atmospheric rendering) — CLOSED
- #502 (atmospheric drag) — CLOSED
- Three.js
ShaderMaterial,BufferGeometry,DataTexture - Web Workers API for async heightmap generation
Future Extensions (out of scope)
- Real heightmap data (Phase 2, #743)
- Server-side terrain height queries for physics (Phase 3, #744)
- Landing mechanics using terrain height (Phase 4, #745)
- Other bodies beyond Luna
- Texture splatting (regolith, rock, ice)
- Normal mapping from heightmap