# FX pipeline — `Jamstack::FX` > **At a glance** — a layered, two-stage, runtime-toggleable post-processing > shader pipeline. Pure Ruby over the bound raylib shader API; **no C**. Each > effect chooses whether it touches only the game world (+ in-world UI) or the > whole frame (game + overlay HUD). Toggling never recompiles a shader. > > **Key files** > - `mrbgems/raylib/mrblib/fx.rb` — `Jamstack::FX` module: `header`, `Pass`, > `Pipeline`, `Frame`, and the shader-body constants (`SCANLINES`, `VIGNETTE`, > `FXAA`, …). > - `game/fx_demo.rb` — the reference scene: builds the pipeline, wires the > overlay-HUD checkboxes/slider to the passes. > - `game/ui/fx_overlay.rml` / `.rcss` — the overlay HUD (toggles + slider). > > **API/spec pointer** — `docs/API_SPEC_RAYLIB.md` (shader fns: > `load_shader_from_memory`, `get_shader_location`, `set_shader_value`, > `set_texture_filter`, `load_render_texture`, `texture_mode`, `shader_mode`). > > **Cross-refs** — the GLSL/header/macro-shim detail lives in > `raylib-binding.md` ("Custom shaders"); the WebGL2/ES3 web specifics in > `web-target.md` ("FX shader pipeline on web"); rendering RmlUi into the FBO > (context dims, the two contexts) in `rmlui-binding.md` ("Rendering RmlUi into > a RenderTexture"). This doc holds only the FX-architecture scar tissue that > lives nowhere else. ## The two-stage model (why it exists) Three render layers, two shader chains, one screen blit: ``` GAME LAYER 3D world + in-world RmlUi -> RenderTexture G | GAME SHADERS ping-pong chain on G (world only; NOT the overlay HUD) | OVERLAY LAYER processed-game quad + overlay RmlUi HUD -> RenderTexture C | TOP SHADERS ping-pong chain on C (whole frame; over EVERYTHING incl HUD) | screen ``` **Why two stages:** so a gameplay effect can transform the world without wrecking the overlay HUD. Warp/scanlines/aberration would smear HUD text into illegibility → they belong in the **game** stage (the HUD is composited *after*, so it stays crisp). Vignette/grayscale/FXAA-of-everything belong in the **top** stage (intentionally affect the HUD too). The overlay HUD is drawn into `C` *in the same `texture_mode(C)` block* as the composited game quad, so top shaders filter both. `Pipeline#w/h` defines the render-target size; `Frame#game_layer` / `#overlay_layer` are the per-frame builder methods (see the usage block at the top of `fx.rb`). ## Runtime toggle — zero recompilation `Pass` compiles its shader **once at construction** and caches uniform locations. `Pass#enabled = false` just removes it from the per-frame chain (`apply_chain` selects `enabled && !suppress`). Toggling from the eval bridge or the in-game console is free — never reload/rebuild. An optional `uniform float intensity` (0..1) lets an effect *fade* rather than snap (bound only if the body declares it). ## `extra_uniforms` — runtime knobs without recompilation A `Pass` takes `extra_uniforms: {name => value}` (floats). Locations are cached at construction; values are set per frame in `apply`. Use this for multi-knob shaders (FXAA's `subpix` / `edgeThreshold` / `edgeThresholdMin`): set the values at runtime from a slider → the running pass picks them up, **no shader recompilation**. Names are fixed at construction; values are mutable. ## `Pass#suppress` — in-chain but skipped this frame A pass can sit in a shader list yet be skipped per-frame via `suppress` (true). `apply_chain` selects `enabled && !suppress`. Distinct from `enabled`: - `enabled` = user toggle ("this FX is on/off"). - `suppress` = programmatic skip ("redundant this frame because another pass already covers it"). **The use case — two-layer FXAA, no double-blur:** `fx_demo.rb` runs FXAA in *both* stages (`fxaa_game` world-only, `fxaa_ui` whole-frame). When `fxaa_ui` is on it already AA's the whole frame *including the world*; letting `fxaa_game` also run would double-AA the world (extra blur). So `fxaa_game.suppress = fxaa_ui.enabled`: both-on ⇒ the game pass is suppressed ⇒ exactly the whole-frame behaviour, no double-blur. (world-on/ui-off ⇒ only the world is AA'd, HUD stays crisp — the mode that justifies the split.) If you see a pass "not running despite enabled=true," check `suppress` first — it is *not* a user-facing toggle. ## FXAA requires BILINEAR on the render textures `Pipeline` ctor sets `TEXTURE_FILTER_BILINEAR` on **every** render texture. This is not cosmetic: **FXAA needs sub-pixel bilinear sampling to blend edges.** With the default point sampling, the FXAA edge-search samples identical texel values → detects no contrast gradient → applies no AA (looks like a no-op). It is set once on all RTs (`@g + @c`) because the top stage may run FXAA on any of them; harmless to the non-FXAA passes. **Any new AA pass that samples neighbours** (SMAA, SSAA downscale, CAS) inherits this correctly — do not "fix" the bilinear back to point. ## FXAA `GREEN_AS_LUMA` + the missing-luma caveat (open option B) The FXAA body uses **green as luma** (`FxaaLuma(rgba) = rgba.g`). Reason: our RGBA8 render targets carry **uniform alpha = 1**, so luma-from-alpha would detect no edges at all. Green is a reasonable perceptual-luma proxy. **Caveat:** pure red/blue edges with **no green component** get little/no AA (their luma delta is small). The escape hatch is **option B — a luma-pack pre-pass**: a cheap pass that computes perceptual luma `(0.299R + 0.587G + 0.114B)` and writes it into the alpha channel, after which FXAA reads alpha-as-luma and catches every edge. Cost: one extra full-screen pass. **Not implemented** — green-as-luma is good enough for the current demo; revisit if red/blue aliasing shows. (This was previously referenced from `fx.rb` as "roadmap option B" — but the roadmap is a *harness* roadmap with no FX section, so that pointer was dangling; the option lives here now.) ## Which shader goes in which stage - **Game stage** (world + in-world UI; leaves overlay HUD crisp): `WARP`, `ABERRATION`/`ABERRATION_CMY`, `SCANLINES`, `CRT` (all-in-one), `fxaa_game`. Rule of thumb: *anything that would smear text or warp geometry*. - **Top stage** (whole frame incl HUD): `VIGNETTE`, `GRAYSCALE`, `COLORGRADE`, `fxaa_ui`. Rule of thumb: *gentle, whole-frame tonal/AA effects*. ## Wiring a HUD control to a pass (the demo pattern) The overlay HUD drives passes via RmlUi. The non-obvious bits (RmlUi-binding has the context/FBO detail; this is the control-wiring detail): - **Checkbox:** `` → `el = doc.element("chk-x"); el.on(:change) { pass.enabled = el["checked"] }`. Read `el["checked"]` (truthy/falsy string). - **Range slider:** `` → `el.on(:change) { q = el["value"].to_f; pass.extra_uniforms[:knob] = q }`. **The value is a String — `.to_f` it.** The `:change` event fires on both checkbox toggles and slider drags. Setting an `extra_uniforms` value mutates the live pass; no rebuild. - **Shared control over multiple passes:** just call both in the handler (`fxaa_quality(fxaa_game, q); fxaa_quality(fxaa_ui, q)`). Whichever actually runs (per `enabled`/`suppress`) uses the latest value. ## SMAA 1x — DONE (alongside FXAA, for A/B testing) `Jamstack::FX::Smaa` (mrblib/smaa.rb) is a **composite** 3-pass effect (edge detect → blend weights → neighbourhood blend) that ducks as a `Pass` for `Pipeline#apply_chain` (responds to `enabled`/`suppress`/`extra_uniforms` + `apply(src, dst, t, scene)`), running its own internal ping-pong over two intermediate render textures (`edge_rt`, `blend_rt`). The shaders are the canonical iryoku/smaa GLSL, preprocessed from `SMAA.hlsl` with `cpp -DSMAA_GLSL_3 -DSMAA_PRESET_HIGH -DSMAA_DISABLE_DIAG_DETECTION …` (diag compiled out) — saved at `mrbgems/raylib/tools/smaa_canonical.glsl`. The `fx_demo.rb` wires two instances (game + ui) + a threshold slider, exactly like FXAA. Scar tissue (all non-obvious, hard-won): - **Multi-texture binding needs no rlgl.** SMAA passes 2 & 3 sample several textures in one shader (edges+area+search; image+blend). raylib's `DrawTexturePro` only auto-binds the *drawn* texture to unit 0 (`texture0`); the extra samplers are bound via **`Rl.set_shader_value_texture`** — raylib's `rlSetUniformSampler` registers the id + sets the sampler uniform, and the actual GL bind is **deferred to the batch flush** (the `DrawTexturePro` draw). So call `set_shader_value_texture` for each extra sampler inside `shader_mode`, then `draw_texture_pro` the main texture. (No `rlActiveTexture`/`rlEnableTexture` is exposed in this binding — and none is needed.) - **Lookup textures live in C, not Ruby.** The `areaTex` (160×560 RGBA8, 358 KB) + `searchTex` (64×16, 4 KB) are baked canonical bytes (`src/ smaa_tex_data.c`, generated by `tools/gen_smaa_tex.rb` from the real iryoku/smaa `Scripts/*.py` — ortho region + search R channel byte-exact) and exposed to Ruby as Strings via `Rl.smaa_area_bytes` / `Rl.smaa_search_bytes` (`mrb_str_new` at runtime), then uploaded with `Rl.update_texture`. They are NOT Ruby literals because mruby (a) caps each string literal at `MRB_PARSER_TOKBUF_MAX` = **65534** bytes and (b) a ~358 KB string constant **hangs the irep loader at boot**. Runtime generation was also ruled out (~12 s in mruby for the closed-form area math; even offset-0-only). A C const array has none of these limits; `mrb_str_new` at runtime has none either. `areaTex` filter = **BILINEAR** (the shader interpolates the area LUT), `searchTex` = **POINT** (it's an index — must not interpolate). - **SMAA 1x samples only offset-row 0.** `subsampleIndices = 0` for 1x (the SMAA comment says so), so the areaTex `texcoord.y += SUBTEX_SIZE*offset` stays in the first 1/7 block. We bake the full canonical area (all 7 rows) anyway; the diag half is zeroed (`SMAA_DISABLE_DIAG_DETECTION` — the shader never reads it). - **The SMAA PS functions take `sampler2D` args + `float4 offset[3]`.** SMAA's own vertex shader computes `offset[3]`; raylib's default VS can't, so each pass's `main()` **inlines the VS offset math** from `fragTexCoord` + `rtMetrics` (`uniform vec4 rtMetrics; #define SMAA_RT_METRICS rtMetrics`). `SMAA_MAX_SEARCH_STEPS` = 16 (PRESET_HIGH) is used only in the blend pass's `offset[2]`. The threshold is a `uniform float smaaThreshold` (swapped into the preprocessed LumaEdgeDetectionPS via `.sub`, only in the edge pass) so the slider tunes it without recompiling. - **Intermediate RTs are BILINEAR-filtered (NOT POINT).** The chain's color RTs are BILINEAR (Pipeline sets that for FXAA); SMAA samples the color input softly as a result — acceptable, slightly soft. **`edge_rt`/`blend_rt` SMAA owns MUST be BILINEAR too** — this is critical and was previously wrong (POINT). SMAA's `SMAAArea` reads the crossing edges `e1`/`e2` via a sub-texel sample of `edge_rt`; POINT makes them binary `{0,1}` → `SMAAArea` samples the areaTex's zero corner regions → **zero weights → no AA**. BILINEAR blends the sub-texel sample → `e1/e2 ∈ {0,0.25,0.75,1.0}` → reads the real area data → AA works (matches three.js's LINEAR edgesRT/weightsRT). See `smaa-root-cause.md` for the full diagnosis. `areaTex` stays BILINEAR; `searchTex` stays POINT (it's an index). ## Open options (the AA upgrade path) - **Option B — luma-pack pre-pass** (above): fixes FXAA's red/blue blind spot, ~1 pass. - **TAA** — temporal: needs motion vectors + a history RT + a resolve with neighbourhood clamping. Best on moving cameras; most work. Pair with a *sharpen* pass (CAS), not with another spatial AA. - **SSAA** — render the game pass at 2× and downsample (bilinear, already enabled): trivial to add as one Pass; catches everything but costs fill-rate + memory. - **SMAA diagonal detection** — currently disabled (`SMAA_DISABLE_DIAG_DETECTION`); enabling needs the diagonal `areaTex`/`searchTex` (brute-force generated) + porting `SMAACalculateDiagWeights`/`SMAASearchDiag*` back in. Ortho-only is the LOW/MEDIUM-preset config. See `roadmap.md` only for harness milestones — FX feature options live here.