PURPOSE

Owns the runtime knob values for the three-layer post-FX pipeline (11 Art Styles, 3 toggleable Layer FX with their settings, 5 Polish tweaks) that drives the nebula fragment shaders. Provides a tiny bespoke subscription store — no Zustand — with both a React hook surface (usePostFxState, usePostFxValue) for UI and a non-React surface (getPostFxState + subscribePostFx) for the NebulaBackground RAF loop.

OWNS

  • Module-scoped state object _state of type PostFxState — the single source of truth for all 24 knob values (11 art-style wetness, 5 polish, 3 layer-FX on-flags, 5 layer-FX settings — bg-stars intensity, color-sparkles intensity, shooting-stars intensity/angle/variance).
  • Module-scoped _listeners: Set<() => void> — subscriber registry.
  • The PostFxState interface and its 24 fields.
  • The default-values block: art styles default 0 (off), polish Hue/Sat/Light/AnimSpeed default 0.5 (identity), polish Blur defaults 0, layer on-flags default 0, layer intensities default 0.5, layerShootingStarsAngle defaults 0.6 / (2 * Math.PI), layerShootingStarsVariance defaults 0.
  • The [0, 1] clamp applied on every numeric setPostFxValue write.
  • The “identity” lookup table in getPostFxIdentity: returns 0.5 for polishHue/polishSat/polishLight/polishAnimSpeed/layerBgStarsIntensity/layerColorSparklesIntensity/layerShootingStarsIntensity, returns 0.6 / (2 * Math.PI) for layerShootingStarsAngle, returns 0 for everything else.

READS FROM

  • useSyncExternalStore from react — backs the two React hooks.

PUSHES TO

  • All registered listeners on every state change. _emit() iterates _listeners and calls each callback after _state is replaced.
  • Replaces _state with a fresh object reference on every change (_state = { ..._state, [key]: v }) so React identity comparisons inside useSyncExternalStore fire correctly.

DOES NOT

  • Does not persist values to localStorage, IndexedDB, Supabase, or any other store. Knob values are in-memory only; closing/reloading the page resets everything to defaults.
  • Does not load presets, scenes, or saved configurations. Reset behavior (FxTab/NebulaViewer reset buttons) lives in the UI screens and is implemented by writing identity values via setPostFxValue.
  • Does not validate keys at runtime — type safety comes from the K extends keyof PostFxState generic on setPostFxValue/getPostFxValue/usePostFxValue/getPostFxIdentity.
  • Does not clamp non-numeric values. The clamp branch only runs for typeof v === 'number'.
  • Does not emit when a write is a no-op (_state[key] === v short-circuits before the spread and emit).
  • Does not interpret the on-flags itself. Layer-FX On values are stored as number (0 or 1); the “≥0.5 = on” threshold is a shader-side and UI-side convention, not enforced here.
  • Does not own the GLSL shaders, the nebula engine, or any drawing — purely state.
  • Does not own the post-FX linger system (streaks, scars, ghost arcs, exhaust, etc.) — that’s a separate module at engine/vfx/post-fx.ts, unrelated despite the name.

Signals

  • getPostFxState() — Returns the current _state object reference. Cheap; safe to call every frame from RAF loops.
  • getPostFxValue(key) — Returns a single knob value, typed by the key.
  • setPostFxValue(key, value) — Clamps numeric value to [0, 1], no-ops if unchanged, otherwise replaces _state with a spread copy and emits to all subscribers.
  • getPostFxIdentity(key) — Returns the slider position that produces “no effect” for the given knob. Used by reset buttons.
  • subscribePostFx(fn) — Registers a listener; returns an unsubscribe function that removes it from _listeners.
  • usePostFxState() — React hook returning the full state. Re-renders the component on any knob change.
  • usePostFxValue(key) — React hook returning a single knob. Re-renders only when that knob changes (the useSyncExternalStore snapshot selector reads _state[key]).

Entry points

  • screens/playground/FxTab.tsx — the in-game Playground “Post-FX” tab. Imports usePostFxState, setPostFxValue, getPostFxIdentity, and type PostFxState. Drives all 11 art-style sliders, the 3 layer-FX toggles with their nested settings, and the 5 polish sliders. Reset buttons write 0 (art styles), getPostFxIdentity(key) (polish, shooting-star angle), or 0.5 (intensities).
  • metagame/screens/NebulaViewerScreen.tsx — the standalone nebula viewer. Imports usePostFxState, setPostFxValue, getPostFxIdentity, getPostFxState. Has a “Reset All” action that sweeps every art-style to 0, every polish knob to its identity, and every layer-FX On flag to 0.
  • metagame/components/NebulaBackground.tsx — non-React consumer. Reads getPostFxState() once into fxRef, then subscribes via subscribePostFx to refresh fxRef.current on every store change. The RAF tick passes fxRef.current to nebulaRender so slider drags drive GLSL uniforms with no React re-render.

Pattern notes

  • Tiny bespoke store, no Zustand. A single module-scoped object plus a Set<() => void> plus three functions (getPostFxState/setPostFxValue/subscribePostFx). Smaller surface than a framework store and integrates with React only via useSyncExternalStore.
  • Reference replacement on write. _state = { ..._state, [key]: v } returns a new object every time. useSyncExternalStore uses reference equality on the snapshot returned by getPostFxState, so this guarantees React sees a change without per-key diffing.
  • Per-key hook is reference-stable too. usePostFxValue uses two separate snapshot getters that both read _state[key]; React skips re-renders when the returned primitive number is unchanged from the last snapshot, so dragging one slider only re-renders components subscribed to that exact key.
  • Clamp at the boundary, not in the reader. Numeric values are clamped to [0, 1] inside setPostFxValue. Readers receive already-valid values; shaders and UI never need defensive clamping.
  • No-op write guard. setPostFxValue early-returns when _state[key] === v. Avoids spurious re-renders when a UI control writes back the value it just read.
  • Identity is data, not computed. getPostFxIdentity is a hardcoded switch table rather than reading defaults from _state initial values. Lets reset buttons work even after the defaults have been overwritten.
  • On-flags are numbers, not booleans. Layer-FX toggles store 0 or 1 so the shader can treat them as straight float uniforms; UI compares against >= 0.5 when reading.
  • No persistence — fresh defaults on every page load. A reload clears all art styles to off, sends polish back to identity, and turns off all layer FX. If saved presets are ever wanted, they belong in a separate module that wraps getPostFxState/setPostFxValue — this store stays in-memory.
  • The class is named “post-fx” but is unrelated to engine/vfx/post-fx.ts. That file is the cosmetic linger system (streaks/scars/ghost arcs spawned by weapons). This store is the shader knob bank for the nebula art-style/polish pipeline.