Skip to content

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_bounds on SurfaceSite — Layer A line-of-sight gate), ADR-072 (shared SurfaceScene.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:

OptionAsset-pipeline costRuntime cost in r128Mobile impactWhere it shines
ALow — one fetch per resolution per body, manifest entry per fileTrivial — material.map = newTexture on thresholdHeavy at 8K — ~250 MB GPU + ~80 MB on-disk per body; even 4K is ~4 MB on-disk per body × 3 bodies + a precache hitDesktop, when the body fills the viewport everywhere. Cheap to ship.
BHigh — tile generation pipeline (gdal_translate or equivalent), per-zoom manifests, per-tile provenance, per-tile license if mixing sourcesHigh — 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 disciplineBest at the cost of large infra investmentContinuous-zoom interactive map — the right eventual answer if Orrery becomes a full geospatial browser
CMedium — only ~10-20 regions per body need insets, stored as separate sphere-projected overlays. Same provenance shape as base texturesMedium — 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-demandAligns 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:

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

ts
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:

ts
// 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:

  1. Wide view, base 2K sphere
  2. Closing approach → Layer B threshold → swap base to 4K
  3. Line-of-sight hits Apollo 11 region_bounds → Layer A → mount Apollo 11 inset overlay on top of the 4K base
  4. 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_path field 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 after scripts/fetch-assets.ts was 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):

ConsumerStatusWired in
/earth orbital (EarthOrbitalScene)✅ shipped#284 (34e7591a7)
/explore (per-planet)✅ shipped#287 (b88a77573)
/earth?mode=surface (SurfaceScene 'earth')✅ shippedThis pass
/moon (SurfaceScene)✅ shippedThis pass
/mars (SurfaceScene)✅ shippedThis pass
/fly cislunar Earth + Moon✅ shippedThis pass
/iss Earth backdrop✅ shippedThis pass
/tiangong Earth backdrop✅ shippedThis 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.

Orrery — architecture documentation · MIT · No tracking