PRD-021 · Surface texture LOD strategy — sphere texture sharpness at mid-zoom on /mars, /moon, /earth
Status · Draft Date · 2026-05-30 Owner · Marko Closes into · ADR-073 (technical decision), #284 (issue), eventual implementation slices Slice gate · Blocked by #283 Slice 6 — surface hotspots v2 / SurfaceScene refactor / flat ground patch must ship first
Why this is a PRD. This isn't a UI-only tweak. We're committing to a new shape on the asset pipeline (per-region high-detail texture insets), a new descriptor on
SurfaceSite(inset_texture_path?: string,inset_zoom_threshold_ratio?: number), a new precache tier for mobile (W6 / #276 coupling), and a runtime LOD-swap path inSurfaceScene.svelte(ADR-072) plus three callers (/earth,/flycislunar,/iss,/tiangong). Those are spread across three subsystems and need a single owner doc.
What
Today every surface route renders its base body — Mars, Moon, Earth — with a single 2048×1024 equirectangular texture. At wide zoom that's fine. As the camera approaches the surface — before the flat ground-patch threshold trips (#283 Slice 6, Mars + Moon only) — the texels become larger than screen pixels and the sphere reads as visibly soft. NASA's interactive surface maps swap in higher-detail tiles continuously; we don't, and the softness is the first thing reviewers notice when comparing the two.
This PRD locks the strategy to fix that, scoped to:
- The 2K → 4K base swap on
/mars,/moon,/earth,/flycislunar,/iss,/tiangongwhenever the body fills enough of the viewport. - Per-region high-detail texture insets at
SurfaceSitelat/lon extents — Mars + Moon for v0.7, Earth gated on #285 (launchpads as surface sites).
/explore is out of scope (planet thumbnails never zoom large enough to soften).
Why now
Three signals converged:
- #284 was filed as a forward-looking note when #283 was scoped — Marko flagged the sphere-softness symptom as a separate concern. The flat ground patch (#283 Slice 6) addresses deep zoom; this PRD addresses the mid zoom band that the flat patch doesn't cover.
- #283's SurfaceScene refactor (ADR-072) is the right insertion point — the sibling-agent slices currently in flight (1-2) standardise the sphere material assembly and give us one place to add the LOD-swap path that automatically benefits both
/marsand/moon. - #285 (launch sites on
/earth) is on the roadmap for v0.8 — when it lands, Earth picks up the SurfaceSite descriptor model. That's the right moment to add per-region Earth insets at no extra infrastructure cost. The base 2K → 4K swap on Earth ships first, decoupled.
Users
Same audience as /moon and /mars today: educators, science writers, curious adults zooming in to inspect a specific landing site or surface feature. The visual contract is what the experience is selling — "I can see Apollo 11 Tranquility Base in usable detail" is meaningfully different from "I can see a blurry equirectangular patch."
Goals + non-goals
Goals:
- Sphere texture stays subjectively sharp across the camera-distance range users actually traverse on
/moonand/mars, with the threshold for "approaching" tunable per body via a singleinset_zoom_threshold_ratiovalue. - Mobile precache footprint stays within budget — the high tier carries 4K base textures (~12 MB extra across three bodies) + Layer A insets for the top-traffic regions; the mobile tier stays at 2K base + on-demand insets. Aligns with W6 / #276 deferred to v0.8.
- Asset provenance discipline survives unchanged — every new texture (4K base + per-region inset) carries a provenance entry per ADR-046 / ADR-047, fail-closed validated by
validate-data. - Three.js r128 compatibility maintained — the LOD-swap mechanism uses
material.mapreassignment +needsUpdate = true, which is r128-native. No r130+ APIs are taken on (ADR-001 pin). - No regression in render-loop cost — the per-frame swap check is a single number comparison; the actual swap only fires on threshold cross.
Non-goals:
- Tile streaming / quadtree (option 2 from the issue review). Right eventual answer if Orrery becomes a full interactive map, but its win is largest in exactly the zoom band the flat ground patch (#283 Slice 6) is taking over. Defer until there's signal it's needed; doesn't block any current user-facing problem.
- 8K base textures. Heavy GPU + bandwidth cost everywhere even when users orbit empty terrain.
- Camera autopilot to regions. Threshold-based LOD swap is responsive to the user's zoom intent; it doesn't pull them toward regions automatically.
- New surface-source pipelines beyond what existing fetches already provide. The fetch script will need to be re-pointed at a working high-res endpoint (Solar System Scope's
download/URL started returning HTML some time after fetch-assets.ts was last run), but sourcing tiles, mosaicking, or polar-region special handling is out of scope.
Approach
Locked by ADR-073. Summary:
Layer A — per-region high-detail texture insets
For each SurfaceSite / region with user-attention, generate a higher-resolution sphere-projected texture inset at the region's lat/lon extent. The inset overlays the base sphere material in a bounded UV rectangle around the region when:
camR / body_radius <= inset_zoom_threshold_ratio(default 1.5, tunable per region)- The camera line-of-sight intersects the region's
region_bounds(already on the SurfaceSite schema per ADR-061)
When either gate releases, the inset is unloaded; the base sphere material takes back over.
Descriptor extension on SurfaceSite:
{
"id": "apollo-11",
"region_bounds": {...}, // ADR-061
"inset_texture_path": "textures/insets/moon/apollo-11/2k.jpg", // NEW (PRD-021)
"inset_zoom_threshold_ratio": 1.5 // NEW, optional
}Layer B — base 2K → 4K swap
For bodies-of-no-interest zoom (no SurfaceSite in line-of-sight), the base sphere material swaps to a 4K texture when camR / body_radius is below the swap threshold (default 2.5 for /earth, 3.0 for /mars and /moon — tuned per body's typical camera range).
Asset shape:
static/textures/2k_<body>_daymap.jpg # existing, default base
static/textures/4k_<body>_daymap.jpg # NEW, swap-in targetProvenance entry per file. License continues to be CC-BY-4.0 attributed to Solar System Scope (or equivalent — the fetch script picks the source).
Both layers compose
A user zooming toward Apollo 11 sees: 2K base at wide → 4K base on closing approach (Layer B threshold) → Layer A inset on top of the 4K base once region line-of-sight + threshold both fire. When they pull back, the sequence reverses cleanly.
Constraints
- Mobile precache budget — current workbox precache is at 754 MiB (per #95 review). Layer B 4K bases add ~12 MB; Layer A insets are bounded by region count × inset size (target: ~500 KB per inset, ~10-20 regions per body). All ships within budget.
- PWA install size on mobile — high tier carries everything; mobile tier carries 2K base + nearest-region inset only. SW precache tier strategy ships as part of W6 (#276).
- Three.js r128 —
material.mapreassignment +needsUpdate = trueis r128-native. NoMeshStandardMaterial.uniformsshader-side LOD, no quadtree, noCubeTextureLoadertricks. - Provenance fail-closed — every new texture path requires an entry in
image-provenance.jsonper ADR-047.validate-dataenforces. - No render-path regression — per-frame swap check stays cheap (single comparison + bounded threshold-cross writes). Test gate:
dispose-leak.test.tsextended to verify both 2K and 4K textures dispose cleanly on route teardown.
Scope by body
| Body | Layer B base swap | Layer A insets | Notes |
|---|---|---|---|
/mars (and SurfaceScene consumer) | Yes — 2K → 4K | Yes for rover sites + Apollo/Artemis regions | Primary case; integrates with #283 region work directly |
/moon (and SurfaceScene consumer) | Yes — 2K → 4K | Yes for Apollo + Luna + Chang'e + future lunar-base regions | Same as Mars; the threshold ratios differ because tidal lock means the user usually approaches the near-side hemisphere |
/earth | Yes — 2K → 4K | Gated on #285 | Launch sites land first; insets follow. Base swap ships now because the existing /earth scene already invites close inspection of LEO satellite altitudes |
/fly cislunar (Earth + Moon meshes) | Yes — 2K → 4K | Inherits the Moon SurfaceSite insets via Layer A automatically; Earth inherits via #285 | The cislunar scene's Earth and Moon meshes use the same texture URLs the surface routes do; pointing them at the LOD-swap path is one parameter change in buildCislunarScene's options |
/iss, /tiangong (Earth backdrop) | Yes — 2K → 4K, only if camera approaches Earth | Earth via #285 | The station scenes default to ISS-local coords; Earth is far context. Probably won't trip the swap threshold often, but the mechanism is shared |
/explore | No | No | Planet thumbnails stay at 2K; never zoom close enough |
Success criteria
- Visual: side-by-side screenshots at the threshold-crossing camera distance for /mars, /moon, /earth show a clearly sharper sphere texture without artefacts (no seam at the swap boundary, no flicker, no flash to lower res mid-swap).
- Per-region insets: Apollo 11 / Curiosity Bradbury Landing / launchpad LC-39A (when #285 lands) read at meaningfully higher detail than the rest of the sphere around them, with the inset edge invisible (alpha-feathered or seam-aligned).
- Performance: animation framerate ≥ 55 fps on a 2020-era mid-tier laptop GPU during a sweep that crosses both Layer B and Layer A thresholds. Memory ceiling:
renderer.info.memory.textures ≤ 12during normal usage on/marsor/moon. - Mobile: PWA install size on the mobile tier stays within current budget (no W6 / #276 acceleration needed for this work). Layer A inset on-demand load on mobile completes in ≤ 2 s on a 4G connection.
- Provenance gates:
validate-datacontinues green; every new texture has a complete provenance entry; the per-region inset cross-reference (SurfaceSite.inset_texture_path → on-disk file) is validated. - e2e: a smoke spec asserts the LOD swap fires + unswaps + cleans up (
renderer.info.memory.texturessnapshot before / after) for at least one body per workflow.
Out of scope
- Real-time satellite imagery overlay (e.g. ISS view from current orbital position). Different surface, different latency budget. Future work.
- Adjustments to the SurfaceSite line-of-sight gate computation beyond what ADR-061 already specified for
region_bounds. - Generative super-resolution (e.g. an upscaling model in the asset pipeline). Compromises provenance; we'd be attributing pixels we generated to the source agency.
References
- #283 — surface hotspots v2 / SurfaceScene refactor / flat ground patch (hard prerequisite, Slice 6)
- #284 — issue this PRD closes into; chosen approach is option 3 (per-region insets) + option 1 sub-case (2K → 4K base swap)
- #285 — launch sites on
/earth(gates Layer A on Earth) - #276 — W6 responsive image variants + mobile-tier SW precache (mobile-tier coupling)
- #124 — 3D marker fidelity (becomes more relevant as camera gets closer to surface)
- ADR-001 — Three.js r128 pin (constrains the swap mechanism to r128-native APIs)
- ADR-046 / ADR-047 — provenance manifest (covers new inset + 4K base entries)
- ADR-061 —
region_boundsonSurfaceSite(Layer A line-of-sight check) - ADR-072 — shared
SurfaceScene.svelte(Layer A + Layer B integration point) - ADR-073 — technical decision lock for this PRD