ADR-073 — Surface texture LOD via per-region insets (Layer A) + 2K → 4K base swap (Layer B); no tile streaming
Status · Accepted Date · 2026-05-30 Closes into · PRD-021 (product framing), #284 (issue) Related ADRs · ADR-001 (Three.js r128 pin — constrains LOD mechanism), ADR-046 / ADR-047 (provenance for new textures), ADR-061 (
region_boundsonSurfaceSite— Layer A line-of-sight gate), ADR-072 (sharedSurfaceScene.svelte— Layer A + Layer B integration point) Related issues · #276 (W6 mobile-tier precache — couples), #283 (surface hotspots v2 — hard prereq), #285 (launch sites on/earth— gates Layer A on Earth)
Context
Surface routes /mars, /moon, /earth, plus the cislunar / station scenes that share the same body textures, currently render every base sphere with a single 2048×1024 equirectangular texture. At wide zoom this is fine. As the camera approaches the surface — before the flat ground-patch threshold trips (#283 Slice 6, Mars + Moon only) — texels become larger than screen pixels and the sphere reads as visibly soft. NASA's interactive surface maps swap higher-detail tiles continuously; we don't, and the softness is the first thing reviewers notice when comparing the two.
PRD-021 frames the user-facing problem. This ADR locks the technical shape of the fix.
The decision space:
- (A) Per-resolution base variants — 2K / 4K / 8K of each body, swap based on camera distance.
- (B) Tiled cubemap or quadtree streaming — NASA-like continuous tile load + LOD selection.
- (C) Per-region high-detail insets — generate higher-res sphere-projected textures only for the SurfaceSite regions users zoom toward.
The trade-offs:
| Option | Asset-pipeline cost | Runtime cost in r128 | Mobile impact | Where it shines |
|---|---|---|---|---|
| A | Low — one fetch per resolution per body, manifest entry per file | Trivial — material.map = newTexture on threshold | Heavy at 8K — ~250 MB GPU + ~80 MB on-disk per body; even 4K is ~4 MB on-disk per body × 3 bodies + a precache hit | Desktop, when the body fills the viewport everywhere. Cheap to ship. |
| B | High — tile generation pipeline (gdal_translate or equivalent), per-zoom manifests, per-tile provenance, per-tile license if mixing sources | High — Three.js r128 has no built-in quadtree, so we'd write a custom loader, LOD selector keyed to camera distance, eviction policy, network / cache discipline | Best at the cost of large infra investment | Continuous-zoom interactive map — the right eventual answer if Orrery becomes a full geospatial browser |
| C | Medium — only ~10-20 regions per body need insets, stored as separate sphere-projected overlays. Same provenance shape as base textures | Medium — overlay-material swap on approach corridor (camera distance + line-of-sight via region_bounds); UV math to clip the inset to its lat/lon extent. r128-native (no new API) | Bounded by region count × inset size. Mostly cacheable per region. Mobile tier loads on-demand | Aligns with #283's region work. You pay the cost only where users zoom toward, not everywhere |
Decision
Layer A (option C — per-region insets) is the canonical LOD strategy. Layer B (option A cut-down to a single 2K → 4K base swap, NOT 8K) is the backstop for bodies-of-no-interest zoom.
Status update (2026-06-01) — Layer A deferred to v0.9. Layer A is off the table for the v0.7 / v0.8 cycle. The base 2K → 4K swap (Layer B, shipped) plus the Tier-2 patch resolution upgrade tracked in #291 cover the high-zoom fidelity story well enough that the per-region inset work isn't worth opening until v0.9 planning. Do not start Layer A implementation, do not draft schema migrations for
inset_texture_path, and do not propose intermediate "Layer A lite" variants until the v0.9 epic is open. The schema sketch below is preserved as forward- looking design only — treat it as read-only design history during v0.7 / v0.8. Owner re-evaluates 2026-Q4 with v0.9.
Layer A — per-region high-detail insets
Each SurfaceSite that has user-attention gains two new optional fields on the schema:
{
"id": "apollo-11",
"region_bounds": {...}, // ADR-061 (existing)
"inset_texture_path": "textures/insets/moon/apollo-11/2k.jpg", // ADR-073 (new)
"inset_zoom_threshold_ratio": 1.5 // ADR-073 (new, optional)
}The runtime check in SurfaceScene.svelte:
const ratio = camR / BODY_RADIUS;
if (ratio <= site.inset_zoom_threshold_ratio
&& cameraLineOfSightIntersects(site.region_bounds)
&& !insetMounted) {
mountInsetOverlay(site);
} else if ((ratio > site.inset_zoom_threshold_ratio || !insetMounted) && insetMounted) {
unmountInsetOverlay(site);
}The inset is a sphere-projected texture overlay in a bounded UV rectangle around the region's lat/lon extent. Alpha-feathered seam so the inset edge doesn't read as a hard line against the base sphere.
Layer B — base 2K → 4K swap
For zoom levels where no SurfaceSite is in line-of-sight, the base sphere material swaps the map reference between 2k_<body>_daymap.jpg and 4k_<body>_daymap.jpg:
// Hysteresis: swap to 4K when camR <= 4K_IN, swap back to 2K when camR >= 2K_OUT.
// Deadband prevents per-frame thrashing if camR sits right on the boundary.
const EARTH_LOD_4K_IN = 22;
const EARTH_LOD_2K_OUT = 28;
if (camR <= EARTH_LOD_4K_IN && lod !== '4k') swapTo4K();
else if (camR >= EARTH_LOD_2K_OUT && lod !== '2k') swapTo2K();Per-body thresholds are tuned to where the 2K texels visibly exceed screen-pixel size, measured against the body's typical camera-distance range. Lazy-load the 4K texture on first threshold cross; reuse on subsequent crosses; dispose on route teardown alongside the active 2K.
Compose
A user zooming toward Apollo 11 on /moon:
- Wide view, base 2K sphere
- Closing approach → Layer B threshold → swap base to 4K
- Line-of-sight hits Apollo 11
region_bounds→ Layer A → mount Apollo 11 inset overlay on top of the 4K base - Pull back: Layer A unmounts first (line-of-sight check fails), then Layer B reverts to 2K (camR > 2K_OUT)
The flat ground patch (#283 Slice 6) takes over below the Layer A threshold ratio; this ADR's mechanism doesn't fire inside the ground-patch view at all.
Rationale
Why not (B) tile streaming
- r128 has no built-in quadtree. We'd implement the tile loader, LOD selector, eviction policy ourselves. That's a real subsystem, ~500-1000 LOC + tests + provenance per tile.
- The zoom band where tiling shines is the same band the flat ground patch (#283 Slice 6) is taking over. Past the inset threshold, the flat patch is the better UX. Above the inset threshold, the 4K base + inset combo is enough. The "continuous tile streaming" use case lives in a narrow band that doesn't justify the infrastructure cost yet.
- Asset-pipeline scope: tile-based assets need their own gdal-based generation pipeline + per-tile manifest + per-tile provenance. Significantly more than insets, which fit the existing per-region asset pattern.
- Provenance at the tile level becomes complex when mixing sources (e.g. CTX global mosaic + HiRISE inset patches). Per ADR-046 / ADR-047, every distributed asset needs a clean attribution chain. Tile mosaics blur that.
Re-evaluation trigger: if user signal demonstrates that the mid-zoom band on /mars or /moon remains visually weak after this ADR ships and the flat ground patch ships, tile streaming becomes the obvious next step. ADR-073 is not a permanent rejection; it's a deferral until the easier mechanism is proven insufficient.
Why not (A) alone (8K base for everyone)
- GPU cost: 8K texture allocation is ~256 MB per body. Three bodies = ~768 MB. Some mobile GPUs can't allocate that.
- Bandwidth cost: 8K base for three bodies × ~50 MB each = ~150 MB additional precache or on-demand load. Pushes mobile install size past comfortable.
- Coverage waste: an 8K base spends pixels evenly across the sphere, including ocean / mare / regolith where the user has no specific reason to zoom. Insets pay only for regions of interest.
The 2K → 4K base swap (Layer B) is a sub-case of option A — keeping just the smallest step up. It buys most of the visible quality improvement for the bodies-of-no-interest case without paying the full 8K cost.
Why r128 + material.map = ... works
Three.js r128 MeshPhongMaterial and MeshStandardMaterial both support runtime .map reassignment + .needsUpdate = true. Validated locally on /earth in the spike (commit forthcoming). No r130+ API is taken on — ADR-001 pin stays clean.
The lazy-load pattern uses THREE.TextureLoader.load(url, onLoad, onProgress, onError) — the legacy callback shape that's r128-native. Async/await pattern is not used because loadAsync only landed cleanly later. The error callback resets a loadStarted flag so a transient network failure doesn't permanently lock the route into 2K.
Alternatives considered
- Procedural / shader-side LOD via mipmap bias on a single 8K base. Rejected. 8K base eats the GPU + bandwidth cost everywhere; mipmap bias doesn't help if the source texture itself is 2K (no extra detail to reveal). Was a half-step toward (A) without the runtime benefit.
- CSS / canvas hybrid for the inset rather than a Three.js sphere overlay. Rejected. The inset has to sit on the sphere correctly under the camera's projection; doing that in a 2D layer is harder than just adding the overlay material to the existing scene graph.
- One-size-fits-all inset zoom threshold ratio per body. Rejected. Regions vary in size — Apollo 11's 1.5 km × 1 km ellipse hits the threshold at a different camera distance than a 10 km × 50 km surface-feature region. Per-region
inset_zoom_threshold_ratio(optional, defaults to body-wide value) gives the SurfaceSite authors a knob they can tune per-region without code changes. - Pre-load 4K base on mount instead of lazy-load. Rejected. Adds ~4 MB to the initial route paint cost across three bodies × routes that load Earth (cislunar, ISS, Tiangong). Lazy-load gates the cost on actual approach.
Consequences
Positive:
- Visual fidelity matches user intent. Zooming in on a region you care about gets you a higher-detail texture there; orbiting empty terrain still gets a sharp-enough base from the 4K swap.
- Mobile-tier discipline preserved. No new infrastructure on mobile beyond what W6 (#276) was already planning. Layer A insets load on-demand on mobile; Layer B can run if/when the mobile budget allows the 4K base.
- Asset pipeline doesn't grow new shapes. Insets are per-region textures with provenance entries — same pattern as fleet hero images, mission patches, etc.
- r128 pin stays untouched. No new API surface taken on.
- Reversibility. If tile streaming becomes the right answer later, both layers come out cleanly — Layer A's
inset_texture_pathfield stays optional, Layer B's swap becomes one branch in a richer LOD selector.
Negative:
- Two layers means two places to tune thresholds. Layer A's per-region threshold + Layer B's per-body threshold. Documented and defaulted, but the tunable surface is bigger than (A) alone.
- Per-region inset asset generation adds work to the SurfaceSite authoring workflow. Mitigated by tooling — a script in
scripts/to crop / scale a high-res source map to the inset extent and write the path back to the SurfaceSite JSON. Authoring slice in the implementation plan. - The 4K base textures need a real source. Solar System Scope's
download/URL started returning HTML some time afterscripts/fetch-assets.tswas last run (verified 2026-05-30). The fetch script needs a re-point, OR a different source picked. Tracked as part of PRD-021 Slice 2. - One more failure mode to test in e2e. The LOD swap must not flash a lower-res texture mid-swap or leave the route in a half-swapped state on rapid camera input. Covered by the success-criteria gate in PRD-021.
Decision status
Accepted on 2026-05-30. Implementation gated on #283 Slice 6 per PRD-021. Earth Layer B spike committed alongside this ADR (/earth + /fly cislunar texture-swap mechanism in r128) to derisk the runtime pattern before the gate opens.
Layer B implementation status (2026-05-31, drift-cleanup pass):
| Consumer | Status | Wired in |
|---|---|---|
/earth orbital (EarthOrbitalScene) | ✅ shipped | #284 (34e7591a7) |
/explore (per-planet) | ✅ shipped | #287 (b88a77573) |
/earth?mode=surface (SurfaceScene 'earth') | ✅ shipped | This pass |
/moon (SurfaceScene) | ✅ shipped | This pass |
/mars (SurfaceScene) | ✅ shipped | This pass |
/fly cislunar Earth + Moon | ✅ shipped | This pass |
/iss Earth backdrop | ✅ shipped | This pass |
/tiangong Earth backdrop | ✅ shipped | This pass |
The textureUrl4k?: string optional field on SurfaceSceneConfig carries the Layer B contract for SurfaceScene consumers. Cislunar (buildCislunarScene) carries earthTextureUrl4k?: string + moonTextureUrl4k?: string. /iss and /tiangong carry the swap inline because their Earth backdrop isn't routed through a shared scene builder — same pattern, route-local. All consumers dispose the 4K texture explicitly on teardown (it lives in a closure, not the scene graph, so disposeScene alone misses it).
Layer A status: deferred to v0.9 (decided 2026-06-01). The per-region inset mechanism + the inset_texture_path schema field are explicitly NOT on the v0.7 or v0.8 roadmap. Layer B (shipped) plus the Tier-2 patch resolution upgrade in #291 cover the high-zoom fidelity need well enough that the inset work isn't worth the asset-pipeline + authoring cost during this cycle. Hard stop for agents: do not propose Layer A implementation, schema additions, or "Layer A lite" intermediate variants before v0.9 epic planning opens. Re-evaluate in 2026-Q4 alongside the v0.9 product framing.