Post-Processing FX Stack

Screen-space post-processing applied to the full gameplay layer AFTER world/particle/effect drawing but BEFORE HUD. Gives the game a unified cinematic look while keeping UI crisp and readable.

Two cooperating modules

The post-FX stack is split across two files at engine/rendering/:

  • post-processing.ts — Always-on cinematic pipeline (bloom, dust, fog, vignette, scanlines, god rays, contrast/saturation mastering). Hard-coded effects driven by per-planet presets.
  • post-fx-store.ts — Tweakable art-style and layer-FX subscription store driven by sliders (Nebula viewer / FxTab dev surfaces). Three-layer pipeline: Art Styles → Layer FX → Polish.

Always-on pipeline (post-processing.ts)

Order of operations inside drawScreenEffects(ctx, quality):

  1. Bloom — full-scene additive glow via downsampled blur. Desktop: 1/4 resolution, 24px blur, alpha 0.55. Mobile: 1/8 resolution, 10px blur, alpha 0.30 (under 2ms on phone). At strong bloom multipliers (≥3.0) multiple passes accumulate.
  2. Dust above — screen-space soft blobs, atmospheric depth layer. HIGH quality only.
  3. Contrast / Saturation — CSS filter composite, preset-driven. Only fires when preset has non-zero contrastBoost or saturationShift.
  4. Vignette — pre-baked elliptical radial gradient with portrait-mode horizontal squeeze (0.85). Oversized canvas with 200px margin so edges remain pure black past screen bounds. Gated by preset (vignetteEnabled).
  5. God rays — 4 diagonal volumetric beams from top-right, slowly drifting (~0.002 rad/s). Screen-composite at very low alpha (0.04 base, fading on outer beams). Preset-gated; skipped in low quality.
  6. CRT scanlines — pre-baked 1px horizontal lines every 3px at 2.5% alpha. HIGH quality only.

Independent draw calls (called outside drawScreenEffects):

  • drawFog — multi-texture fog system, 5 texture types (noise, wispy, clouds, dense, patchy) at tileable 512px tiles. Screen-blend composite, parallax 0.15, drift 8 px/s. Called after shadows, before sticker blit.
  • drawDustBehind — world-space soft blobs at parallax 0.3, drawn behind ships inside drawWorld.
  • drawBokeh — screen-space soft hexagonal lens-bloom particles with chromatic edge ring, screen-blend.

Per-planet mastering presets

Active preset is switched at run start via PostProcessing.setMode(mode) based on PlanetDef.postProcessing. Two presets defined:

PresetbloomAlphaMultvignetteEnabledcontrastBoostsaturationShiftgodRaysEnabled
dark (default)1.0true0.00.0false
sunlit1.0false0.00.0true (gated by preset)

patchPreset(partial) allows runtime overrides from the VFX dashboard.

Quality tiers

drawScreenEffects accepts a quality parameter:

  • high — full pipeline (bloom + dust + vignette + scanlines + god rays).
  • mobile — lite bloom (1/8 res) + vignette only. No dust, no scanlines.
  • low — vignette only; thermal-emergency fallback that drops everything non-essential.

Tweakable art-style stack (post-fx-store.ts)

A tiny bespoke subscription store (no Zustand) with three layers, all 0..1 sliders:

Layer 1 — Art Styles (11 wetness sliders, default 0)

Each slider fully transforms the screen to that style at wetness 1:

  • borderlands — bold ink outlines + 3-tier posterize + halftone shadows
  • synthwave — purple→magenta→cyan duotone + neon highlight bloom + scanlines
  • vhs — RGB tape splitting + scrolling tracking band + tape grain + desat
  • pixelArt — uv quantize + 5-step palette + 4×4 Bayer dither
  • oilPaint — warped color smoothing + canvas grain + warm cast + sat boost
  • watercolor — paper bleed + lifted midtones + soft edges + desaturate
  • comic — thick ink lines + 2-step flat color + dense halftone
  • blueprint — invert luma + cyan tint + grid overlay + edge-only detail
  • thermal — luma→heat LUT (blue→cyan→yellow→red→white) + edge glow
  • stainedGlass — voronoi cells + jewel-tone sat + thick black borders
  • cyberglitch — heavy chromatic aberration + slice RGB shifts + neon bloom + invert pulses

Layer 2 — Layer FX (3 toggleable extras)

Each effect has an On 0/1 flag (treated as boolean by shader: ≥0.5 = on) plus per-effect intensity:

  • layerBgStars{On,Intensity} — background star dots
  • layerColorSparkles{On,Intensity} — faint colored sparkles
  • layerShootingStars{On,Intensity,Angle,Variance} — deterministic streaks. Angle 0..1 maps to 0..2π radians; variance 0 = parallel rain, 1 = chaotic spray.

Layer 3 — Polish (5 always-applied final tweaks)

  • polishBlur — soft fake blur via gradient damping. 0 = none, 1 = full.
  • polishHue — global hue shift. 0.5 = identity, 0 = -180°, 1 = +180°.
  • polishSat — global saturation. 0.5 = identity, 0 = grayscale, 1 = 2×.
  • polishLight — global lightness. 0.5 = identity, 0 = black, 1 = 2× lift.
  • polishAnimSpeed — animation speed scale. 0.5 = 1× identity, 0 = paused, 1 = 2×.

For Hue/Sat/Light/AnimSpeed, identity is 0.5; sliders map 0..1 to a -shift..+shift range. Blur and intensity defaults are 0 and 0.5 respectively.

Store API

  • getPostFxState() — full state object (reference replaced on every set for React identity comparisons).
  • getPostFxValue(key) — single knob read.
  • setPostFxValue(key, value) — set single knob; clamps numbers to [0, 1], emits to subscribers.
  • getPostFxIdentity(key) — returns the “does nothing” slider position. Used by reset buttons.
  • subscribePostFx(fn) — returns unsubscribe function.
  • usePostFxState() / usePostFxValue(key) — React hooks via useSyncExternalStore. Non-React consumers (e.g. NebulaBackground RAF) read directly via subscribePostFx + getPostFxState.

Performance gating

  • Bloom mode selected per-frame via quality arg (‘high’ / ‘mobile’ / ‘low’).
  • Static overlays (vignette, scanlines) pre-baked to offscreen canvases and rebuilt only on viewport resize.
  • Dust uses a fixed pool (40 behind / 20 above motes) with no per-frame allocation; pre-baked unit gradient stamps rather than createRadialGradient per mote per frame.
  • Fog tiles cached in _fogTiles record keyed by texture-type + tint.
  • Mobile bloom lite mode (_drawBloomLite) capped at ~47×100px scratch canvas with CSS-filter blur under 2ms total per frame.

Source files

  • starship-survivors/src/starship-survivors/engine/rendering/post-processing.ts — 871 lines, always-on pipeline.
  • starship-survivors/src/starship-survivors/engine/rendering/post-fx-store.ts — 172 lines, slider state store.