compose-pass.frag.ts

PURPOSE

GLSL ES 3.00 fragment shader source (exported as a JS string) for the VFX-workbench layer-stack composite pass. Samples two RGBA inputs (u_dst = accumulator, u_src = incoming layer) and blends them via one of five premultiplied-alpha blend modes selected by u_mode, writing the result to outColor.

OWNS

  • Default export: a single template literal containing the full #version 300 es fragment-shader source (precision highp float).
  • Five branched blend operations switched on u_mode:
    • 0 additive: out = dst + src
    • 1 alpha (Porter-Duff “over”, premultiplied): out = src + dst * (1 - src.a)
    • 2 multiply: out = vec4(dst.rgb * src.rgb, max(dst.a, src.a))
    • 3 screen: out = vec4(1 - (1 - dst.rgb) * (1 - src.rgb), max(dst.a, src.a))
    • 4 erase: out = dst * (1 - src.a)
    • fallback (any other int): out = dst (passthrough)
  • Application of u_opacity to src once, before the blend branch (src = texture(u_src, v_uv) * u_opacity).

READS FROM

  • uniform sampler2D u_dst — accumulator RGBA texture (already-composed layers).
  • uniform sampler2D u_src — incoming layer’s RGBA output (premultiplied alpha).
  • uniform int u_mode — blend-mode selector; encoding must stay in sync with LayerBlendMode in component-schema.ts.
  • uniform float u_opacity — 0..1 multiplier applied to src before blending.
  • in vec2 v_uv — varying UV coordinate from the upstream vertex stage (full-screen quad).

PUSHES TO

  • out vec4 outColor — the framebuffer color attachment bound by the host pass (the next accumulator target in the ping-pong composite chain).

DOES NOT

  • Does not unpremultiply, gamma-correct, or tonemap inputs/outputs.
  • Does not clamp output to [0,1] — additive and screen results can exceed 1.0 on HDR-capable targets (caller’s choice of target format governs clipping).
  • Does not write depth, stencil, or MRT outputs — single outColor only.
  • Does not declare or sample more than two textures; no per-layer color tints, masks, or LUTs live here.
  • Does not branch on discard — every fragment writes a value (including the fallback passthrough).
  • Does not define vertex attributes or bind any uniforms itself — the host program is responsible.

Signals

  • All inputs are assumed premultiplied alpha (per the file-level JSDoc): layer shaders in this workbench write color.rgb * alpha into their outColor before reaching this pass.
  • The u_mode integer encoding is load-bearing and must match LayerBlendMode in component-schema.ts. Drift between the two is a silent bug — the GPU will pick the wrong branch.
  • max(dst.a, src.a) in multiply/screen preserves the more-opaque alpha rather than multiplying alphas — chosen so multiply/screen stay visible when composited onto further layers downstream.
  • Erase uses dst * (1 - src.a) (premultiplied), so an opaque src fully clears dst regardless of src.rgb.

Entry points

  • import composeFrag from './compose-pass.frag' — default export is the GLSL source string, ready to hand to gl.shaderSource (or a shader-compile helper in the workbench).
  • The leading /* glsl */ comment is a tooling hint for editor GLSL syntax highlighting; it is not part of the shader source as far as the GL compiler is concerned (the backtick template literal begins on the same line with #version 300 es).

Pattern notes

  • String-export shader. The shader lives as a default-exported template literal rather than a .frag file loaded via a Vite plugin — keeps the workbench dependency-free and lets TS see it as string.
  • Branch-on-uniform rather than per-mode shader permutations. Acceptable here because the workbench composite is not hot — a handful of layers per frame, not per-pixel particles.
  • Premultiplied-alpha contract is shader-wide, not per-branch. Every layer producer in the workbench owes the contract; this pass relies on it for the “over” math to be correct.
  • Encoding-with-sibling-file is a coupling. u_mode integers are duplicated as the source of truth for the JS-side LayerBlendMode enum — see EXTRACT-CANDIDATE.

EXTRACT-CANDIDATE

  • LayerBlendMode integer encoding (0..4 for additive/alpha/multiply/screen/erase) is currently shared by manual convention between this shader and component-schema.ts. Candidate for a single named-constants table (e.g. BLEND_MODE_ADDITIVE = 0, …) imported by the TS schema and stringified into the shader at build time, so the two cannot drift. Worth doing only once a third consumer appears (e.g. a UI dropdown or a serializer that needs the string names) — until then the duplication is two sites and tolerable.