Skip to content

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 in SurfaceScene.svelte (ADR-072) plus three callers (/earth, /fly cislunar, /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, /fly cislunar, /iss, /tiangong whenever the body fills enough of the viewport.
  • Per-region high-detail texture insets at SurfaceSite lat/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:

  1. #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.
  2. #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 /mars and /moon.
  3. #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 /moon and /mars, with the threshold for "approaching" tunable per body via a single inset_zoom_threshold_ratio value.
  • 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.map reassignment + 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:

jsonc
{
  "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 target

Provenance 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 r128material.map reassignment + needsUpdate = true is r128-native. No MeshStandardMaterial.uniforms shader-side LOD, no quadtree, no CubeTextureLoader tricks.
  • Provenance fail-closed — every new texture path requires an entry in image-provenance.json per ADR-047. validate-data enforces.
  • No render-path regression — per-frame swap check stays cheap (single comparison + bounded threshold-cross writes). Test gate: dispose-leak.test.ts extended to verify both 2K and 4K textures dispose cleanly on route teardown.

Scope by body

BodyLayer B base swapLayer A insetsNotes
/mars (and SurfaceScene consumer)Yes — 2K → 4KYes for rover sites + Apollo/Artemis regionsPrimary case; integrates with #283 region work directly
/moon (and SurfaceScene consumer)Yes — 2K → 4KYes for Apollo + Luna + Chang'e + future lunar-base regionsSame as Mars; the threshold ratios differ because tidal lock means the user usually approaches the near-side hemisphere
/earthYes — 2K → 4KGated on #285Launch 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 → 4KInherits the Moon SurfaceSite insets via Layer A automatically; Earth inherits via #285The 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 EarthEarth via #285The station scenes default to ISS-local coords; Earth is far context. Probably won't trip the swap threshold often, but the mechanism is shared
/exploreNoNoPlanet 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 ≤ 12 during normal usage on /mars or /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-data continues 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.textures snapshot 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-061region_bounds on SurfaceSite (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

Orrery — architecture documentation · MIT · No tracking