PURPOSE

Sky-layer rendering — composites the WebGL-procedural nebula, a 10-layer Canvas-2D parallax starfield (stars / dust / rocks), an optional Canvas-2D fallback background, atmospheric cloud haze, and a “cheap stars” mobile path. Picks one archetype per mission from the biome’s space-archetype pool and keeps the active palette in sync so the nebula tint follows the live palette swap. Owns the Background singleton consumed by bridge.ts.

OWNS

  • Background singleton exported at module scope, with methods: reset(), setArchetype(idx), drawStarfield(ctx), drawNebula(ctx), draw(ctx), _drawClouds(ctx), _drawFallbackBackground(ctx), _drawCheapStars(ctx).
  • Layer constants: TL = 10 (total parallax layers), FLD = 5000 (star field size), _STAR_DENSITY = 0.3 on mobile / 1.0 on desktop.
  • Layer build: _buildLayers() populates _layers: StarLayer[] with per-layer speed sp = 0.005 + pow(t, 2.5) * 0.18, count cnt = (400 - pow(t, 1.3) * 370) * _STAR_DENSITY, base size bs = 0.3 + t * 1.2, base alpha ba = 0.35 + t * 0.55. Each StarObj has seeded bx/by/phase/twinkle/shapeIdx/sizeVar/rot.
  • Seeded RNG sr(n)sin(n*127.1 + n*n*311.7) * 43758.5453 fract — deterministic across runs.
  • 16 precomputed rock polygons in RS: [angle, radiusFrac][][], each with 5–8 verts.
  • 8 shape functions: drawStar5, drawCross, drawDia, drawTri, drawHex, drawSpark, drawDot, plus fillRect fallback in _drawStars dispatch.
  • Object renderers: _drawStars (twinkle on layers 7+), _drawDust (sin(t*0.8 + phase)*1.5 x-wobble, batched into a single fill()), _drawRocks (per-rock polygon path from RS).
  • RGBA string cache _rgbaCache[256] keyed by (_rgbaCacheR/G/B) — quantizes alpha to 256 buckets via _cachedRgba(r, g, b, alpha); rebuilt only when integer rgb changes.
  • Pre-baked static star layers _bakedStarLayers: (HTMLCanvasElement|null)[], sized to _bakedStarW/_bakedStarH, keyed by _bakedStarArch. Built in _bakeStaticLayers(archIdx) after temporarily zeroing camera.x/y. Layers skipped (kept dynamic): dust (type === 'd') and twinkling stars (i >= 7 && type === 's'). Drawn via _drawBakedLayer(ctx, baked, sp), which tiles up to 4 copies to cover viewport wrap.
  • Archetype selection state: _activeArch: number, _archSelected: boolean. _selectArchetype() honors PLANET_ARCHETYPE_IDX[world.planetId] first, otherwise picks a random index from getSpaceArchetypes(world.biomeId || 'landing_site'); returns 0 for empty pools.
  • Init state: _layersReady, _initAttempted, _webglReady. _ensureInit() lazily builds layers and calls nebulaInit() once.
  • Palette sync: _lastPaletteBiomeId, _syncPaletteForBiome(biomeId, seed) — returns true iff biome changed and setActivePaletteForBiome was called.
  • Baked nebula cache: _bakedNebula: HTMLCanvasElement|null, _bakedNebulaW/H, _bakedNebulaArch, _bakedNebulaPaletteRev. Plus the (currently unused) _nebulaFrames: HTMLCanvasElement[] / _nebulaFrameTime declared for the 24-frame / 8fps keyframe plan.
  • Fallback canvas: _fallbackBg, _fallbackBgW/H — radial gradient (#0a1628 → #060e1e → #020818) plus 80 scattered seeded star dots.
  • Cheap-star bakes: _cheapStarLayer1/2, _cheapStarW/H, _bakeCheapStars() — 60 far dots + 35 near dots, parallax 0.05 and 0.15.
  • Cloud bake: _cloudCanvas, _cloudW/H, _bakeClouds() — 250 soft radial blobs on a 2×W × 2×H tile, warm (200, 140, 110) sunset fog, two-tier alpha (60% thick 8–25%, 40% wispy 2–6%), 80% parallax, baked with its own deterministic LCG s = 0x12C3A7.

READS FROM

  • ../core: W, H, camera (x/y), game (game.time), world (world.planetId, world.biomeId).
  • ../core/config: PERF_FLAGS (imported; no current branches reference it).
  • ../core/device-capabilities: isMobile() — gates _STAR_DENSITY.
  • ../../data/nebula-archetypes: ARCHETYPES, STAR_COLORS, STAR_TYPES, SURF_MODE, getSpaceArchetypes(biomeId), PLANET_ARCHETYPE_IDX.
  • ./nebula-engine: nebulaInit(), nebulaResize(W, H), nebulaRender(arch, ox, oy, t, globalLight = 2.2), nebulaGetCanvas(), nebulaIsReady().
  • ./parallax/parallax-system: setBiome (as setParallaxBiome), drawSlot (as drawParallaxSlot), disposeParallax.
  • ./palette/palette-system: setActivePaletteForBiome, resolvePaletteSlot('bg_haze' | 'bg_deep' | 'bg_star'), getPaletteRevision().
  • ./parallax/silhouette-stamps: disposeStampCache.

PUSHES TO

  • nebula-engine: nebulaResize(W, H) then nebulaRender(arch, 0, 0, 0, 2.2) on bake, retrieving the source via nebulaGetCanvas().
  • palette-system: setActivePaletteForBiome(biomeId, seed) during _syncPaletteForBiome, where seed = (world.planetId || 0) + (world.biomeId?.length || 0).
  • parallax-system: setParallaxBiome(biomeId) per draw; drawParallaxSlot(ctx, 'back', game.time||0, W, H) per draw; disposeParallax() on reset().
  • silhouette-stamps: disposeStampCache() on reset() and whenever _syncPaletteForBiome reports a change.
  • Public canvas ctx: CanvasRenderingContext2D passed by bridge.ts — receives the #020818 base fill, the baked nebula (globalCompositeOperation = 'lighter', 4× zoom crop), all 10 parallax star layers, and the 'back' parallax slot.

DOES NOT

  • Does not draw terrain, gameplay entities, weapons, enemies, pickups, post-FX, or UI. Those live in draw.ts, vfx/*, and ui/*.
  • Does not own the WebGL context — that lives in nebula-engine.ts. This module only orchestrates baking and compositing.
  • Does not draw 'mid' / 'near' / 'fg' parallax slots; only 'back' is fired from Background.draw(). The remaining three slots are kicked from bridge.ts at later render stages.
  • Does not animate the nebula per frame — the bake is reused until W, H, archetype index, or palette revision changes. The historical _nebulaFrames / _nebulaFrameTime keyframe arrays exist but are not currently driven by any code path.
  • Does not run a CRT/scanline/bloom overlay any more — the comment on line 34 notes that drawCRT was removed in v1.46 as dead code.
  • Does not branch on PERF_FLAGS directly; the import exists but no live code currently reads it.
  • Does not draw clouds from Background.draw()_drawClouds and _drawCheapStars are public methods on the singleton but draw() does not invoke them; they remain for callers (e.g. sunrise_city biome paths) to opt in.
  • Does not handle surface archetypes — drawStarfield early-returns when SURF_MODE[archIdx] === 1.

Signals

  • Background.reset() — invoked at mission start. Clears archetype selection (_archSelected = false), drops _bakedNebula / _bakedNebulaArch / _bakedNebulaPaletteRev / _nebulaFrames, calls disposeParallax(), clears _lastPaletteBiomeId, and calls disposeStampCache(). Star-layer bakes self-invalidate via the archIdx !== _bakedStarArch check on the next drawStarfield.
  • Background.setArchetype(idx) — locks _activeArch = idx, _archSelected = true, clears the nebula bake and frames so the next drawNebula rebakes against the forced archetype.
  • Bake invalidation triggers (read on each draw): _bakedStarLayers.length === 0, _bakedStarW !== W, _bakedStarH !== H, _bakedStarArch !== archIdx for stars; !_bakedNebula, _bakedNebulaW !== W, _bakedNebulaH !== H, _bakedNebulaArch !== archIdx, _bakedNebulaPaletteRev !== getPaletteRevision() for the nebula.

Entry points

  • Background.draw(ctx) — top-level call from bridge.ts. Returns early when W <= 0 || H <= 0. Calls drawNebula(ctx); falls back to _drawFallbackBackground(ctx) if WebGL is unready or the nebula bake failed. Syncs palette + parallax to world.biomeId, drops the stamp cache on palette change, then drawParallaxSlot(ctx, 'back', game.time||0, W, H).
  • Background.drawStarfield(ctx) — draws all 10 layers. Skips when SURF_MODE[archIdx] === 1. Bakes static layers on demand, then for each layer either _drawBakedLayer (cached) or dispatches to _drawDust / _drawStars (dust + twinkling layers stay dynamic).
  • Background.drawNebula(ctx) — paints the #020818 base, conditionally bakes a new nebula (Gaussian blur(24px) brightness(1.4) saturate(1.2) + three palette composites: color α 0.90 → multiply α 0.35 → lighter α 0.20 onto bg_haze / bg_deep / bg_star), then composites it back with ZOOM = 4, source rect centered with camera.x * 0.05 / camera.y * 0.05 parallax, hard-clamped to canvas bounds, drawn under globalCompositeOperation = 'lighter'.
  • Background.reset() / Background.setArchetype(idx) — mission lifecycle hooks (see Signals).

Pattern notes

  • Two-stage bake architecture: every expensive source (WebGL nebula, static parallax layers, clouds, cheap-stars, fallback) renders once to an offscreen canvas and is drawImage’d each frame. Cache keys live as module-scope _baked*W/H/Arch/PaletteRev ints; invalidation is a single ||-chain at the top of each draw method.
  • Star parallax math wraps the field with (s.bx - camera.x * layer.sp) % W then if (x < 0) x += W; baked layers use the same wrap by drawing up to 4 tiled copies in _drawBakedLayer (base + horizontal + vertical + corner).
  • Star color per layer interpolates linearly between the low and high triplets of STAR_COLORS[archIdx]: r = sc[0] + (sc[3]-sc[0]) * lt, same pattern for g / b, with lt = i / (TL - 1). Falls back to [140, 160, 220, 240, 245, 255] when an archetype omits its entry.
  • Per-archetype star “shape” comes from ARCHETYPES[archIdx].shape — but only fires when z >= 1.5 and layerIdx >= 4; small/far stars always render as fillRect squares to keep the hot path cheap.
  • Dust render is allocation-free per object — single beginPath(), rect() per particle, one fill() at the end, with fillStyle set once via _cachedRgba before the loop.
  • Rock render reuses the precomputed RS[shapeIdx] polygon and applies s.rot as a per-rock rotation. Rocks under z < 1.5 short-circuit to a fillRect square.
  • Nebula globalLight is hard-coded to 2.2 at render-time after smooth-archetype cosine palettes were found to cap below [0.08, 0.09, 0.43] and read as near-invisible at the prior 1.25 (note dated 2026-04-22). Brightness + saturation are then stacked at draw-bake time so the same shader output works for Sparse Silent Void, Watercolor Dream, and Low Orbital.
  • Palette-tint pass is three composites in order: color (re-tints hue + saturation to bg_haze, dominant pass), multiply (darkens deep regions toward bg_deep), lighter (boosts highlights toward bg_star). Greyscale presets (pure_grey, noir) auto-degrade because their bg_deep/bg_star slots are low-chroma. After the three passes the context is restored to 'source-over' α 1.0.
  • Nebula compositing onto the live ctx uses globalCompositeOperation = 'lighter' (additive) instead of source-over — the comment notes source-over over the #020818 sky read as “no nebula at all” for low-density smooth archetypes. The ctx.save() / ctx.restore() wraps the call so neighbouring draws keep their own composite state.
  • 4× zoom crop is SW = W/4, SH = H/4, centered at (W-SW)/2 + camera.x * 0.05, clamped via Math.max(0, Math.min(W - SW, sx0)). The 0.05 parallax intentionally corresponds to ~20% on-screen motion after the 4× upscale.
  • _bakeStaticLayers cheats camera.x = 0; camera.y = 0 around the bake so the cached canvas holds base coordinates; the per-frame draw then layers a parallax offset on top via _drawBakedLayer. The pre-bake comment notes this eliminates ~1500 individual fillRect / path calls from the hot path.
  • Mission seed for palette variants is (world.planetId || 0) + (world.biomeId?.length || 0). 0 falls back to preset[0] inside palette-system.
  • The drawCRT overlay was removed in v1.46 (dead code, never called) — the module-level comment is the only remnant.
  • The keyframe nebula animation system (_nebulaFrames, _nebulaFrameTime) is declared but inert; the current draw path produces a single bake and applies camera parallax instead of cycling frames.
  • 0-sized canvas guards exist at three entry points (draw, drawNebula, _drawFallbackBackground) — early-return on W <= 0 || H <= 0 prevents InvalidStateError from drawImage on hidden / pre-layout tabs.