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
Backgroundsingleton 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.3on mobile /1.0on desktop. - Layer build:
_buildLayers()populates_layers: StarLayer[]with per-layer speedsp = 0.005 + pow(t, 2.5) * 0.18, countcnt = (400 - pow(t, 1.3) * 370) * _STAR_DENSITY, base sizebs = 0.3 + t * 1.2, base alphaba = 0.35 + t * 0.55. EachStarObjhas seededbx/by/phase/twinkle/shapeIdx/sizeVar/rot. - Seeded RNG
sr(n)—sin(n*127.1 + n*n*311.7) * 43758.5453fract — 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, plusfillRectfallback in_drawStarsdispatch. - Object renderers:
_drawStars(twinkle on layers 7+),_drawDust(sin(t*0.8 + phase)*1.5x-wobble, batched into a singlefill()),_drawRocks(per-rock polygon path fromRS). - 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 zeroingcamera.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()honorsPLANET_ARCHETYPE_IDX[world.planetId]first, otherwise picks a random index fromgetSpaceArchetypes(world.biomeId || 'landing_site'); returns0for empty pools. - Init state:
_layersReady,_initAttempted,_webglReady._ensureInit()lazily builds layers and callsnebulaInit()once. - Palette sync:
_lastPaletteBiomeId,_syncPaletteForBiome(biomeId, seed)— returnstrueiff biome changed andsetActivePaletteForBiomewas called. - Baked nebula cache:
_bakedNebula: HTMLCanvasElement|null,_bakedNebulaW/H,_bakedNebulaArch,_bakedNebulaPaletteRev. Plus the (currently unused)_nebulaFrames: HTMLCanvasElement[]/_nebulaFrameTimedeclared 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, parallax0.05and0.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% thick8–25%, 40% wispy2–6%), 80% parallax, baked with its own deterministic LCGs = 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(assetParallaxBiome),drawSlot(asdrawParallaxSlot),disposeParallax../palette/palette-system:setActivePaletteForBiome,resolvePaletteSlot('bg_haze' | 'bg_deep' | 'bg_star'),getPaletteRevision()../parallax/silhouette-stamps:disposeStampCache.
PUSHES TO
nebula-engine:nebulaResize(W, H)thennebulaRender(arch, 0, 0, 0, 2.2)on bake, retrieving the source vianebulaGetCanvas().palette-system:setActivePaletteForBiome(biomeId, seed)during_syncPaletteForBiome, whereseed = (world.planetId || 0) + (world.biomeId?.length || 0).parallax-system:setParallaxBiome(biomeId)per draw;drawParallaxSlot(ctx, 'back', game.time||0, W, H)per draw;disposeParallax()onreset().silhouette-stamps:disposeStampCache()onreset()and whenever_syncPaletteForBiomereports a change.- Public canvas
ctx: CanvasRenderingContext2Dpassed bybridge.ts— receives the#020818base 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/*, andui/*. - 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 fromBackground.draw(). The remaining three slots are kicked frombridge.tsat 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/_nebulaFrameTimekeyframe 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
drawCRTwas removed in v1.46 as dead code. - Does not branch on
PERF_FLAGSdirectly; the import exists but no live code currently reads it. - Does not draw clouds from
Background.draw()—_drawCloudsand_drawCheapStarsare public methods on the singleton butdraw()does not invoke them; they remain for callers (e.g.sunrise_citybiome paths) to opt in. - Does not handle surface archetypes —
drawStarfieldearly-returns whenSURF_MODE[archIdx] === 1.
Signals
Background.reset()— invoked at mission start. Clears archetype selection (_archSelected = false), drops_bakedNebula/_bakedNebulaArch/_bakedNebulaPaletteRev/_nebulaFrames, callsdisposeParallax(), clears_lastPaletteBiomeId, and callsdisposeStampCache(). Star-layer bakes self-invalidate via thearchIdx !== _bakedStarArchcheck on the nextdrawStarfield.Background.setArchetype(idx)— locks_activeArch = idx,_archSelected = true, clears the nebula bake and frames so the nextdrawNebularebakes against the forced archetype.- Bake invalidation triggers (read on each draw):
_bakedStarLayers.length === 0,_bakedStarW !== W,_bakedStarH !== H,_bakedStarArch !== archIdxfor stars;!_bakedNebula,_bakedNebulaW !== W,_bakedNebulaH !== H,_bakedNebulaArch !== archIdx,_bakedNebulaPaletteRev !== getPaletteRevision()for the nebula.
Entry points
Background.draw(ctx)— top-level call frombridge.ts. Returns early whenW <= 0 || H <= 0. CallsdrawNebula(ctx); falls back to_drawFallbackBackground(ctx)if WebGL is unready or the nebula bake failed. Syncs palette + parallax toworld.biomeId, drops the stamp cache on palette change, thendrawParallaxSlot(ctx, 'back', game.time||0, W, H).Background.drawStarfield(ctx)— draws all 10 layers. Skips whenSURF_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#020818base, conditionally bakes a new nebula (Gaussianblur(24px) brightness(1.4) saturate(1.2)+ three palette composites:colorα 0.90 →multiplyα 0.35 →lighterα 0.20 ontobg_haze/bg_deep/bg_star), then composites it back withZOOM = 4, source rect centered withcamera.x * 0.05/camera.y * 0.05parallax, hard-clamped to canvas bounds, drawn underglobalCompositeOperation = '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/PaletteRevints; 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) % Wthenif (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 forg/b, withlt = 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 whenz >= 1.5andlayerIdx >= 4; small/far stars always render asfillRectsquares to keep the hot path cheap. - Dust render is allocation-free per object — single
beginPath(),rect()per particle, onefill()at the end, withfillStyleset once via_cachedRgbabefore the loop. - Rock render reuses the precomputed
RS[shapeIdx]polygon and appliess.rotas a per-rock rotation. Rocks underz < 1.5short-circuit to afillRectsquare. - Nebula globalLight is hard-coded to
2.2at 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 prior1.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 tobg_haze, dominant pass),multiply(darkens deep regions towardbg_deep),lighter(boosts highlights towardbg_star). Greyscale presets (pure_grey,noir) auto-degrade because theirbg_deep/bg_starslots are low-chroma. After the three passes the context is restored to'source-over'α1.0. - Nebula compositing onto the live
ctxusesglobalCompositeOperation = 'lighter'(additive) instead ofsource-over— the comment notes source-over over the#020818sky read as “no nebula at all” for low-density smooth archetypes. Thectx.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 viaMath.max(0, Math.min(W - SW, sx0)). The0.05parallax intentionally corresponds to ~20% on-screen motion after the 4× upscale. _bakeStaticLayerscheatscamera.x = 0; camera.y = 0around 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 individualfillRect/ path calls from the hot path.- Mission seed for palette variants is
(world.planetId || 0) + (world.biomeId?.length || 0).0falls back topreset[0]insidepalette-system. - The
drawCRToverlay 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 onW <= 0 || H <= 0preventsInvalidStateErrorfromdrawImageon hidden / pre-layout tabs.