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
_stateof typePostFxState— 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
PostFxStateinterface and its 24 fields. - The default-values block: art styles default
0(off), polish Hue/Sat/Light/AnimSpeed default0.5(identity), polish Blur defaults0, layer on-flags default0, layer intensities default0.5,layerShootingStarsAngledefaults0.6 / (2 * Math.PI),layerShootingStarsVariancedefaults0. - The
[0, 1]clamp applied on every numericsetPostFxValuewrite. - The “identity” lookup table in
getPostFxIdentity: returns0.5forpolishHue/polishSat/polishLight/polishAnimSpeed/layerBgStarsIntensity/layerColorSparklesIntensity/layerShootingStarsIntensity, returns0.6 / (2 * Math.PI)forlayerShootingStarsAngle, returns0for everything else.
READS FROM
useSyncExternalStorefromreact— backs the two React hooks.
PUSHES TO
- All registered listeners on every state change.
_emit()iterates_listenersand calls each callback after_stateis replaced. - Replaces
_statewith a fresh object reference on every change (_state = { ..._state, [key]: v }) so React identity comparisons insideuseSyncExternalStorefire 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 PostFxStategeneric onsetPostFxValue/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] === vshort-circuits before the spread and emit). - Does not interpret the on-flags itself. Layer-FX
Onvalues are stored asnumber(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_stateobject 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 numericvalueto[0, 1], no-ops if unchanged, otherwise replaces_statewith 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 (theuseSyncExternalStoresnapshot selector reads_state[key]).
Entry points
screens/playground/FxTab.tsx— the in-game Playground “Post-FX” tab. ImportsusePostFxState,setPostFxValue,getPostFxIdentity, andtype PostFxState. Drives all 11 art-style sliders, the 3 layer-FX toggles with their nested settings, and the 5 polish sliders. Reset buttons write0(art styles),getPostFxIdentity(key)(polish, shooting-star angle), or0.5(intensities).metagame/screens/NebulaViewerScreen.tsx— the standalone nebula viewer. ImportsusePostFxState,setPostFxValue,getPostFxIdentity,getPostFxState. Has a “Reset All” action that sweeps every art-style to0, every polish knob to its identity, and every layer-FXOnflag to0.metagame/components/NebulaBackground.tsx— non-React consumer. ReadsgetPostFxState()once intofxRef, then subscribes viasubscribePostFxto refreshfxRef.currenton every store change. The RAF tick passesfxRef.currenttonebulaRenderso 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 viauseSyncExternalStore. - Reference replacement on write.
_state = { ..._state, [key]: v }returns a new object every time.useSyncExternalStoreuses reference equality on the snapshot returned bygetPostFxState, so this guarantees React sees a change without per-key diffing. - Per-key hook is reference-stable too.
usePostFxValueuses 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]insidesetPostFxValue. Readers receive already-valid values; shaders and UI never need defensive clamping. - No-op write guard.
setPostFxValueearly-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.
getPostFxIdentityis a hardcoded switch table rather than reading defaults from_stateinitial values. Lets reset buttons work even after the defaults have been overwritten. - On-flags are numbers, not booleans. Layer-FX toggles store
0or1so the shader can treat them as straight float uniforms; UI compares against>= 0.5when 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.