PURPOSE

Procedural background nebula renderer. A self-contained WebGL module that owns its own offscreen canvas, compiles two fragment shaders (a classic FS1 path for the 100 archetypes and a lazy FS2 path for advanced viewer modes), and exposes a small functional API so the game’s Canvas 2D renderer can composite the nebula via drawImage. Renders at NEB_SCALE = 0.75 of screen resolution because soft noise hides the downscale. Falls back to a flat background color if WebGL or the offscreen context is unavailable.

OWNS

  • The module-level _gl: NebulaGLState | null singleton holding every WebGL resource: offscreen HTMLCanvasElement, WebGLRenderingContext, classic program (FS1) plus its cached uniform location map, lazy programV2 (FS2) plus its uniform map, the fullscreen quad WebGLBuffer, and current nebula dimensions nw / nh.
  • The vertex shader source VS1 (fullscreen quad passthrough) and the two fragment shader sources FS1 and FS2.
  • Inline GLSL implementations of snoise, fbm, ridged, fbmRidged, hash2, voronoi, fbmVoronoi, hash11, rgb2hsv, hsv2rgb, and the cosine palette helper.
  • The v5.155 art-style stack functions: styleBorderlands, styleSynthwave, styleVHS, stylePixelArt, styleOilPaint, styleWatercolor, styleComic, styleBlueprint, styleThermal, styleStainedGlass, styleCyberGlitch, and the applyArtStack mixer.
  • The v5.155 layer-FX helpers starLayer, shootingStarLayer, and applyLayerFx.
  • The v5.155 polish-layer helper applyPolish (blur via gradient damping, plus HSL hue / sat / light with identity-skip).
  • FS2 mode dispatch for modes 1 through 16 (enriched classic, spiral / warp portal, black hole + accretion ring, planet horizon flyover, aurora ribbons, warp tunnel, cavern volumetric fog, close-up water surface, rippling glass refraction, lava flow, close-up aurora curtain, stained glass, cel water, sin-grid sea, lily pad pool, underwater light rays).
  • The PostFxValues interface (11 art-style knobs, 5 polish knobs, 8 layer-FX knobs) and the NebulaBakeOpts / NebulaBakeResult interfaces used by the flipbook bake.
  • Time scaling: the polishAnimSpeed knob is applied JS-side as scaledTime = time * (fxPSpd * 2) before being uploaded as u_time.

READS FROM

  • Archetype from ../../data/nebula-archetypes for every per-frame visual parameter: pa / pb / pc / pd cosine palette, bg background, warp, density, speed, thresh, nf (frequency), nt (noise type — 0 FBM, 1 ridged, 2 voronoi), sat, lum, optional mode, optional m1 through m8 mode-specific knobs, optional spk sparkle, optional ssa / ssv shooting-star angle and variance.
  • Caller-supplied per-frame state: world-space scrollX / scrollY (used as u_scroll for parallax in FS1 only), wall-clock time, globalLight brightness multiplier, and the optional PostFxValues bundle.
  • The OES_standard_derivatives extension (queried but not required — the cartoony shader-post is a no-op if the extension is missing).

PUSHES TO

  • The internal offscreen canvas accessible via nebulaGetCanvas(). The game’s Canvas 2D renderer pulls this canvas and composites it as the background layer; nothing else lives downstream of nebulaRender.
  • For nebulaBakeFrames: a stitched grid PNG Uint8Array, computed by readPixels into a Uint8Array, vertical-flipping each tile into an ImageData on a temporary 2D canvas, then toBlob('image/png'). The previous canvas size is restored before return so the live viewer keeps working.

DOES NOT

  • Does not own a render loop or call requestAnimationFrame — the caller invokes nebulaRender once per frame.
  • Does not clear, swap, or composite onto the main game canvas — it only writes its own offscreen WebGL canvas.
  • Does not manage WebGL context loss / restoration beyond the explicit WEBGL_lose_context.loseContext() call inside nebulaDestroy.
  • Does not validate Archetype field ranges; out-of-range values pass straight through as uniforms.
  • Does not respond to DPR changes itself; the caller drives sizing via nebulaResize(screenW, screenH).
  • Does not maintain an FBO pipeline for the polish blur — the blur is a cheap one-tap derivative damping inside the same fragment program.
  • Does not retain any shader source on the JS side after link beyond the module-level string constants; the WebGLShader objects are deleted right after attachShader / linkProgram.
  • Does not interpolate between archetypes — the four *Mul uniforms (u_warpMul, u_densMul, u_spdMul, u_colorMul, u_scrollMul) are hard-coded to 1.0 on the classic path and the bake path; the original v15 transition multipliers are intentionally inert.

Signals

  • Throws 'NebulaEngine: createShader failed', 'NebulaEngine: createProgram failed', 'NebulaEngine: createBuffer failed', `NebulaEngine: shader compile error: ${log}`, or `NebulaEngine: program link error: ${log}` from the WebGL helpers when the underlying GL call returns null or compile / link status is false.
  • Throws 'nebulaBakeFrames: engine not initialized', 'nebulaBakeFrames: 2D context unavailable', and 'nebulaBakeFrames: toBlob returned null' from the flipbook bake.
  • Logs 'NebulaEngine: WebGL unavailable, falling back to flat color' via console.warn when getContext('webgl', ...) returns null in nebulaInit; nebulaInit then returns false and every other entry point silently no-ops because _gl stays null.
  • Logs 'NebulaEngine: FS2 compile failed, falling back to classic' via console.warn when _ensureV2Compiled fails; nebulaRender then falls back to the FS1 classic path for that frame and future frames.

Entry points

  • nebulaInit(): boolean — idempotent. Creates the offscreen <canvas>, requests a WebGL context with alpha: false, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: false, queries OES_standard_derivatives, compiles VS1 plus FS1, links the classic program, allocates the fullscreen-quad buffer (4 vertices uploaded as a TRIANGLE_STRIP), caches uniform locations, and stores everything on _gl. Returns false if WebGL is unavailable.
  • nebulaResize(screenW, screenH): void — recomputes nw = round(screenW * NEB_SCALE), nh = round(screenH * NEB_SCALE), early-returns if unchanged, otherwise resizes the offscreen canvas.
  • nebulaRender(arch, scrollX, scrollY, time, globalLight = 1.0, fx?): void — per-frame draw. Resolves all PostFxValues defaults (0 for art-style and layer-on flags, 0.5 for hue / sat / light / animSpeed, 0 for blur), applies the polishAnimSpeed scaling to time, then dispatches to FS2 if arch.mode > 0 and _ensureV2Compiled() succeeds, or otherwise to FS1. Both branches set the viewport, bind the quad buffer to attribute 0, push uniforms, and issue a single drawArrays(TRIANGLE_STRIP, 0, 4).
  • nebulaGetCanvas(): HTMLCanvasElement | null — exposes the offscreen canvas for composition.
  • nebulaBakeFrames(arch, opts): Promise<NebulaBakeResult> — multi-frame flipbook bake. Stashes the live canvas size, resizes to tileSize × tileSize, loops frameCount times rendering at tShader = (i / frameCount) * loopSeconds, reads pixels via readPixels, vertical-flips each tile into a stitched 2D canvas grid of ceil(sqrt(frameCount)) columns, encodes to PNG via toBlob, restores the previous canvas size, returns { pngBytes, gridW, gridH, cols, rows, frameCount, fps, tileSize }.
  • nebulaIsReady(): boolean — returns _gl !== null.
  • nebulaDestroy(): void — deletes the quad buffer, both programs (if present), and calls WEBGL_lose_context.loseContext() to free GPU memory. Sets _gl = null. Safe to call multiple times.

Pattern notes

  • Two-program design with lazy compilation. FS1 covers every classic archetype (the 100 in-game nebulae) and pays its shader-compile cost up front during nebulaInit. FS2 covers the advanced viewer / dev-tool modes (mode > 0) and is compiled only on first request via _ensureV2Compiled, so the gameplay path never pays for FS2. If FS2 compile / link fails, the renderer silently falls back to FS1 for that archetype.
  • Shared uniform vocabulary across FS1 and FS2. The 11 art-style knobs, the polish layer, and the layer-FX knobs all use identical uniform names and bodies in both shaders so the same PostFxValues bundle works on either path. The classic path additionally writes inert multipliers (u_warpMul, u_densMul, u_spdMul, u_colorMul, u_scrollMul) at 1.0 — they exist for an older v15 archetype-transition system that the current code does not drive.
  • Uniform locations cached once at link time inside Record<string, WebGLUniformLocation | null> maps (uniforms, uniformsV2) so each frame just does map lookups rather than getUniformLocation calls. The ! non-null assertions at upload sites assume the cache covered every uniform used in the shader.
  • Single-quad draw. A static 4-vertex TRIANGLE_STRIP buffer is created once; attribute a is bound to location 0 via bindAttribLocation before link so the vertex layout is fixed and the buffer just gets re-bound each frame.
  • Mode dispatch via if/else if chain inside FS2’s void main rather than separate programs per mode. Each mode reads its own subset of u_m1 through u_m8 plus the shared palette / u_density / u_thresh / etc. Mode 0 of FS2 is unreachable from the public API — mode == 0 always routes to FS1.
  • Post pipeline is three sequential layers, in order: art-style stack, layer FX (background stars, color sparkles, shooting stars), polish (blur via gradient damping, then HSL hue / sat / light with identity skip when all three are at 0.5). The polish HSL block early-exits the rgb2hsv / hsv2rgb roundtrip when none of the three knobs are moved away from identity, saving per-pixel cost when polish is unused.
  • Time scaling is JS-side: the polishAnimSpeed knob is applied as a linear multiplier on time before uploading to u_time, so a slider at 0 pauses the nebula, 0.5 is identity, and 1 doubles speed. The shader itself sees only the scaled time.
  • OES_standard_derivatives is enabled (not required) inside the fragment shader so the program still links on GPUs without it; the cartoony art styles silently become no-ops because dFdx / dFdy return zero.
  • Bake path restores nw / nh at the end via nebulaResize(prevW, prevH) so a viewer’s BAKE button never leaves the live canvas at the wrong size after a bake.
  • readPixels returns rows bottom-up; the bake flips them top-down per tile while copying into the stitched grid so the PNG matches the live render.
  • No event bus, no subscription, no observable state — every cross-cutting concern (post-FX, mode, scaling) is passed in as plain arguments to nebulaRender or nebulaBakeFrames.