#72

Fleet: GPU effects on the shared surface

Openbettercleverwants to merge intofrom

Closes #68. Stacked on #71 (perf/fleet-1000-60fps); review that first.

Shared-surface pools can now use the GPU effects from #51 through pool.setEffects(effects | null) and pool.effects. Before this, a terminal on a shared surface rejected setEffects().

Design: effects per worker surface, not per terminal

Each worker already keeps a retained backing texture of its whole canvas. With effects on, that backing becomes the effects' scene, and the post passes write the canvas texture in place of the per-frame backing-to-canvas copy. The extra cost is one composite pass, two half-resolution blur passes for bloom, and one pass for a custom shader. It is per worker frame, independent of the terminal count.

Per-terminal effects inside the shared pass would need a scene texture, blur targets and 3–4 passes for every terminal, about 1,000 extra textures at Fleet sizes.

With effects off, the surface keeps its exact previous path: the backing is copied to the canvas, with no extra textures, passes or texture usages. The backing gains TEXTURE_BINDING only while effects are on.

A uniform flag marks shared post-processing:

  • CRT corners outside the tube are transparent instead of black.
  • The custom-shader prelude premultiplies its output by the input's coverage, so the page between terminals stays see-through.
  • The cursor trail is dropped (a surface has many cursors).
  • Animated shaders keep the worker's frame loop going, rerunning only the post passes.

Also in this PR:

  • Six shaders ported from the native app to demo/effects/: synthwave, rain, shockwave, hologram, warp and matrix (glitch was already identical). All of them now pass naga validation in test:native.
  • An effects selector in demo/fleet.html (also ?effects=), working in both main-thread and worker-fed modes.
  • An EFFECTS axis in benchmark:fleet.
  • effectPasses in shared-surface stats.

Measured

1,000 shared terminals, 4 workers, 3 rounds, headed at 120 Hz, M5 Max, AC power, load 5.5–9.4, build 42ff896. Effect passes were confirmed at 0, 3 and 4 respectively.

FeedOff p10CRT + bloom p10CRT + bloom + synthwave p10
main80.1–118.680.8–117.789.0–116.6
worker99.4–119.592.6–119.685.6–119.1

Page rAF stayed at 120 with no long tasks in every run. Browser CPU was 4.2–5.5 cores and RSS 3.1–3.3 GiB. The differences between settings are within the load-driven spread. Device-wide GPU utilization was not sampled, so no GPU-time cost is claimed. Raw data is in docs/benchmarks/fleet/REPORT.md and effects-w4.json.

Fixes found while measuring

  • 193f6a2: the worker's stats timer read the surface while set_effects held it across an await. The timer threw, which failed every terminal on that worker. Stats now pause and resizes are deferred while effects are configured.
  • 42ff896: terminal creation and effect changes now share one exclusive device-setup queue. Effects applied before mounting had raced terminal creation for the surface borrow, and their validation scopes could interleave.

Validation

All of these pass on the merged tree: npm test (146, incl. Fanout), typecheck, build, optimize, cargo fmt --check, test:native (incl. every gallery shader against the prelude), test:wasm, build:demo, test:fleet (46 checks, incl. effects transparency, shader errors with line:column, per-canvas pools rejecting, and effects off restoring pixel-identical output), test:browser, test:xterm, test:accessibility, test:input, test:worker-socket, test:native-pool, test:native-kitty, test:consumer, test:xterm-consumer. Both the libghostty and xterm-compatible profiles were exercised.

🤖 Generated with Claude Code