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 intest:native. - An effects selector in
demo/fleet.html(also?effects=), working in both main-thread and worker-fed modes. - An
EFFECTSaxis inbenchmark:fleet. effectPassesin 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.
| Feed | Off p10 | CRT + bloom p10 | CRT + bloom + synthwave p10 |
|---|---|---|---|
| main | 80.1–118.6 | 80.8–117.7 | 89.0–116.6 |
| worker | 99.4–119.5 | 92.6–119.6 | 85.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_effectsheld 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