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 | nullsingleton holding every WebGL resource: offscreenHTMLCanvasElement,WebGLRenderingContext, classicprogram(FS1) plus its cached uniform location map, lazyprogramV2(FS2) plus its uniform map, the fullscreen quadWebGLBuffer, and current nebula dimensionsnw/nh. - The vertex shader source
VS1(fullscreen quad passthrough) and the two fragment shader sourcesFS1andFS2. - Inline GLSL implementations of
snoise,fbm,ridged,fbmRidged,hash2,voronoi,fbmVoronoi,hash11,rgb2hsv,hsv2rgb, and the cosinepalettehelper. - The v5.155 art-style stack functions:
styleBorderlands,styleSynthwave,styleVHS,stylePixelArt,styleOilPaint,styleWatercolor,styleComic,styleBlueprint,styleThermal,styleStainedGlass,styleCyberGlitch, and theapplyArtStackmixer. - The v5.155 layer-FX helpers
starLayer,shootingStarLayer, andapplyLayerFx. - 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
PostFxValuesinterface (11 art-style knobs, 5 polish knobs, 8 layer-FX knobs) and theNebulaBakeOpts/NebulaBakeResultinterfaces used by the flipbook bake. - Time scaling: the
polishAnimSpeedknob is applied JS-side asscaledTime = time * (fxPSpd * 2)before being uploaded asu_time.
READS FROM
Archetypefrom../../data/nebula-archetypesfor every per-frame visual parameter:pa/pb/pc/pdcosine palette,bgbackground,warp,density,speed,thresh,nf(frequency),nt(noise type — 0 FBM, 1 ridged, 2 voronoi),sat,lum, optionalmode, optionalm1throughm8mode-specific knobs, optionalspksparkle, optionalssa/ssvshooting-star angle and variance.- Caller-supplied per-frame state: world-space
scrollX/scrollY(used asu_scrollfor parallax in FS1 only), wall-clocktime,globalLightbrightness multiplier, and the optionalPostFxValuesbundle. - The
OES_standard_derivativesextension (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 ofnebulaRender. - For
nebulaBakeFrames: a stitched grid PNGUint8Array, computed byreadPixelsinto aUint8Array, vertical-flipping each tile into anImageDataon a temporary 2D canvas, thentoBlob('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 invokesnebulaRenderonce 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 insidenebulaDestroy. - Does not validate
Archetypefield 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
WebGLShaderobjects are deleted right afterattachShader/linkProgram. - Does not interpolate between archetypes — the four
*Muluniforms (u_warpMul,u_densMul,u_spdMul,u_colorMul,u_scrollMul) are hard-coded to1.0on 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'viaconsole.warnwhengetContext('webgl', ...)returns null innebulaInit;nebulaInitthen returnsfalseand every other entry point silently no-ops because_glstays null. - Logs
'NebulaEngine: FS2 compile failed, falling back to classic'viaconsole.warnwhen_ensureV2Compiledfails;nebulaRenderthen 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 withalpha: false, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: false, queriesOES_standard_derivatives, compiles VS1 plus FS1, links the classic program, allocates the fullscreen-quad buffer (4 vertices uploaded as aTRIANGLE_STRIP), caches uniform locations, and stores everything on_gl. Returnsfalseif WebGL is unavailable.nebulaResize(screenW, screenH): void— recomputesnw = 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 allPostFxValuesdefaults (0for art-style and layer-on flags,0.5for hue / sat / light / animSpeed,0for blur), applies thepolishAnimSpeedscaling to time, then dispatches to FS2 ifarch.mode > 0and_ensureV2Compiled()succeeds, or otherwise to FS1. Both branches set the viewport, bind the quad buffer to attribute 0, push uniforms, and issue a singledrawArrays(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 totileSize × tileSize, loopsframeCounttimes rendering attShader = (i / frameCount) * loopSeconds, reads pixels viareadPixels, vertical-flips each tile into a stitched 2D canvas grid ofceil(sqrt(frameCount))columns, encodes to PNG viatoBlob, 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 callsWEBGL_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
PostFxValuesbundle works on either path. The classic path additionally writes inert multipliers (u_warpMul,u_densMul,u_spdMul,u_colorMul,u_scrollMul) at1.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 thangetUniformLocationcalls. 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_STRIPbuffer is created once; attributeais bound to location 0 viabindAttribLocationbefore link so the vertex layout is fixed and the buffer just gets re-bound each frame. - Mode dispatch via
if/else ifchain inside FS2’svoid mainrather than separate programs per mode. Each mode reads its own subset ofu_m1throughu_m8plus the shared palette /u_density/u_thresh/ etc. Mode 0 of FS2 is unreachable from the public API —mode == 0always 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/hsv2rgbroundtrip 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
polishAnimSpeedknob is applied as a linear multiplier ontimebefore uploading tou_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_derivativesisenabled (notrequired) inside the fragment shader so the program still links on GPUs without it; the cartoony art styles silently become no-ops becausedFdx/dFdyreturn zero.- Bake path restores
nw/nhat the end vianebulaResize(prevW, prevH)so a viewer’s BAKE button never leaves the live canvas at the wrong size after a bake. readPixelsreturns 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
nebulaRenderornebulaBakeFrames.