render: shader contract v2 (chained stages, history, params, event animation) #239

Merged
natsukium merged 7 commits from render/shader-contract-ext into main 2026-10-24 12:45:16 +09:00
Owner

Extends the WGSL post-process shader contract to v2. Chained stages, a previous-frame texture, params and event-triggered animation now exist in kitty 0.49 and ghostty, and felis had none of them. The idle-zero invariant is unchanged: no contract field can request frames, and an animation's end is a time the host knows.

What changes

  • Chained stages. shader.post takes the existing single table or an array of up to 16 stages. Each stage samples the previous stage's output, and a stage that fails to load is dropped while the rest still run.
  • History (binding 3). A stage that declares @group(0) @binding(3) gets its own previous frame as input. The texture is allocated only for a stage that declares it. A final stage with history renders offscreen and is copied to the swapchain by one extra blit, so the surface stays RENDER_ATTACHMENT-only.
  • Params. Per-stage params set WGSL override constants, so the shader source is never rewritten. Each value is type-checked against the declared override, @id keys are mapped from identifiers, and naga's process_overrides runs before pipeline creation.
  • Event-bounded animation. Per-stage animation = { on = [...], duration_ms }. The events are bell, focus_in, focus_out, left_press, and user:<name> from a new shader_event keymap action. duration_ms is required and must be in 1..=10000; values outside that range are rejected, not clamped. The client redraws at 60 Hz until the run ends, then stops. Easing is left to the shader, which gets animation_progress and animation_start_s.
  • Contract v2 fields. Appended after palette, with a 4352-byte struct: time_whole_s / time_fract_s (exact over long-lived windows, where the f32 time_s drifts), focus_change_time_s, stage_index, the animation fields, and an explicit reserved tail. All time fields come from one renderer-lifetime clock that survives stage rebuilds.
  • Load-time safety. A GPU-free check compares every resource binding against the post layout. Stage creation runs inside a wgpu validation error scope, so a shader the device rejects (a mismatched fs_post interface, enable f16;) drops that stage instead of panicking. The binding check also fixes a v1 gap where a mistyped binding panicked at pipeline creation.

The docs cover shaders.md, config.md, keybindings.md, pipeline.md (the "Revisit if … multi-pass composition, or a persistent texture" line is now a recorded decision), spec.md (REQ-701/713/714 updated, new REQ-716), and CHANGELOG.

Not in scope

GLSL/Slang import, kitty's pointer_map, semantic masks, IPC-triggered shader events, host-side easing curves, and vector params. Source compatibility with ghostty/kitty shaders remains a non-goal.

Verification

  • just check and just check-windows pass on every commit (clippy and the Windows cross-lint per commit, the full suite on the tip).
  • Device-free tests on the noop backend cover multi-stage chains with history mid-chain and last (including a resize), stages refused by the device while the rest still encode, and the uniform layout and offsets. Pure unit tests cover config parsing, override type checks and the animation clock, including keeping runs across a reload that keeps the chain.
  • In a real window (headless sway, Vulkan on the host GPU), a three-stage chain (trail, history, params + bell animation) loads with no refusals and draws as intended:
    • History: after clear, the previous screen and the cursor trail remain as a decaying ghost.
    • Bell animation: the flash stage measures 219,4,6 at about 120 ms, 138,10,14 at about 370 ms, and is back to the background color by 1.4 s.
    • Idle: client CPU is 1 tick per 5 s both before the bell and after the run ends.
  • Not exercised in a window: the focus_in / focus_out / left_press / shader_event triggers (they share the bell's clock path, which unit tests cover).
Extends the WGSL post-process shader contract to v2. Chained stages, a previous-frame texture, params and event-triggered animation now exist in kitty 0.49 and ghostty, and felis had none of them. The idle-zero invariant is unchanged: no contract field can request frames, and an animation's end is a time the host knows. ## What changes - **Chained stages.** `shader.post` takes the existing single table or an array of up to 16 stages. Each stage samples the previous stage's output, and a stage that fails to load is dropped while the rest still run. - **History (binding 3).** A stage that declares `@group(0) @binding(3)` gets its own previous frame as input. The texture is allocated only for a stage that declares it. A final stage with history renders offscreen and is copied to the swapchain by one extra blit, so the surface stays `RENDER_ATTACHMENT`-only. - **Params.** Per-stage `params` set WGSL `override` constants, so the shader source is never rewritten. Each value is type-checked against the declared override, `@id` keys are mapped from identifiers, and naga's `process_overrides` runs before pipeline creation. - **Event-bounded animation.** Per-stage `animation = { on = [...], duration_ms }`. The events are `bell`, `focus_in`, `focus_out`, `left_press`, and `user:<name>` from a new `shader_event` keymap action. `duration_ms` is required and must be in 1..=10000; values outside that range are rejected, not clamped. The client redraws at 60 Hz until the run ends, then stops. Easing is left to the shader, which gets `animation_progress` and `animation_start_s`. - **Contract v2 fields.** Appended after `palette`, with a 4352-byte struct: `time_whole_s` / `time_fract_s` (exact over long-lived windows, where the f32 `time_s` drifts), `focus_change_time_s`, `stage_index`, the animation fields, and an explicit reserved tail. All time fields come from one renderer-lifetime clock that survives stage rebuilds. - **Load-time safety.** A GPU-free check compares every resource binding against the post layout. Stage creation runs inside a wgpu validation error scope, so a shader the device rejects (a mismatched `fs_post` interface, `enable f16;`) drops that stage instead of panicking. The binding check also fixes a v1 gap where a mistyped binding panicked at pipeline creation. The docs cover `shaders.md`, `config.md`, `keybindings.md`, `pipeline.md` (the "Revisit if … multi-pass composition, or a persistent texture" line is now a recorded decision), `spec.md` (REQ-701/713/714 updated, new REQ-716), and CHANGELOG. ## Not in scope GLSL/Slang import, kitty's `pointer_map`, semantic masks, IPC-triggered shader events, host-side easing curves, and vector params. Source compatibility with ghostty/kitty shaders remains a non-goal. ## Verification - `just check` and `just check-windows` pass on every commit (clippy and the Windows cross-lint per commit, the full suite on the tip). - Device-free tests on the noop backend cover multi-stage chains with history mid-chain and last (including a resize), stages refused by the device while the rest still encode, and the uniform layout and offsets. Pure unit tests cover config parsing, override type checks and the animation clock, including keeping runs across a reload that keeps the chain. - In a real window (headless sway, Vulkan on the host GPU), a three-stage chain (trail, history, params + bell animation) loads with no refusals and draws as intended: - History: after `clear`, the previous screen and the cursor trail remain as a decaying ghost. - Bell animation: the flash stage measures 219,4,6 at about 120 ms, 138,10,14 at about 370 ms, and is back to the background color by 1.4 s. - Idle: client CPU is 1 tick per 5 s both before the bell and after the run ends. - Not exercised in a window: the `focus_in` / `focus_out` / `left_press` / `shader_event` triggers (they share the bell's clock path, which unit tests cover).
naga validates a module on its own, never against the pipeline layout,
so a well-formed WGSL file that declared binding 1 as a sampler, used a
second bind group, or a uniform struct larger than the contract passed
validate_post_shader and then panicked on the device error scope at
pipeline creation. Checking every resource global against the layout's
group, slots, address spaces and types keeps a bad user file a
recoverable refusal, which is the point of validating before wgpu sees
the source.
Each post stage carried its own start instant, so rebuilding a stage on
a live reload restarted time_s at zero and any timeline the effect kept
jumped. The clock now belongs to the renderer and every stage reads one
reading per frame, which a chain of stages and event animations both
need to agree on.

The appended fields are what that timeline exposes: an exact whole/
fractional split because time_s as f32 loses sub-millisecond precision
after hours, the focus-change time, the stage's position, and the
animation start and progress. The explicit reserved tail keeps the Rust
mirror the size WGSL rounds the struct to, so the layout's minimum
binding size and the declared struct agree.

Assisted-by: Claude Code
A single shader.post table meant the bundled trail and a user effect
could not run together, since the trail cannot be edited to add one.
shader.post now also takes an array of stage tables: stage 0 samples
the cell pass, each later stage samples the one before through two
ping-pong intermediates, and the last writes the swapchain. The single
table keeps working unchanged.

A stage that fails validation, names an unknown builtin, or points at a
missing file is dropped and the rest still run, matching the existing
rule that a failed shader drops its pass instead of substituting one.
Stages are created inside a device validation scope, so a shader only
the device refuses (a feature it did not enable, a fragment interface
that disagrees with the vertex stage) is dropped too rather than
panicking. The cap of 16 stages bounds per-frame passes; excess entries
warn rather than fail the document. The effective config now prints
shader.post as an array whatever form was written.

Assisted-by: Claude Code
Afterglow and motion blur need the stage's own output from the frame
before, which no contract field can carry. A stage that declares
@group(0) @binding(3) gets it: the stage renders into a texture of its
own, which is copied into the texture the next frame binds, so its bind
group stays stable. A final history stage needs one extra blit to the
swapchain, which keeps the surface RENDER_ATTACHMENT-only instead of
requiring COPY_DST from every platform's swapchain.

Only a stage whose module declares the binding pays for the textures.
The history starts zeroed on load and on resize, and nothing about it
requests frames: an effect decays only on frames that happen anyway.

Assisted-by: Claude Code
Reusing one shader at different strengths meant copying the file per
value. A stage's params table now sets the module's override constants
through wgpu's pipeline constants, which needs no source rewriting and
no uniform-layout change.

Each value is checked against the declared override type first, since
naga's conversion from the f64 wgpu takes silently truncates a float
into an integer override. naga's override processing then runs before
pipeline creation, so an unknown name, an override left without a
default, or an override expression that fails to evaluate is a
recoverable refusal of that stage instead of a device-error panic. A
non-scalar value is caught at config load and skips the stage with a
warning.

Assisted-by: Claude Code
A bell flash or focus glow has to play once and stop. shader.animate
cannot do that without a continuous loop, and an unfocused window gets
no frames at all. A stage's animation table names the events that start
it (bell, focus_in, focus_out, left_press, or user:<name> from the new
shader_event keymap action) and a duration; the client redraws at the
frame interval until the run's end and draws one final frame at
progress 1. The host knows every end, so idle still means zero frames,
and the run is bounded by duration_ms rather than by focus.

Progress is linear: easing is a pure function the shader can apply in
a few lines, so felis carries no curve vocabulary. An invalid table
loads the stage without animation and warns instead of clamping, since
a silently shortened animation is behavior the user did not write, and
config check flags a shader_event binding no stage listens for. A
reload that keeps the chain's shaders keeps runs in flight on both the
client and renderer side, and stops a stage on both sides when its
table changes, so the two never disagree about a run.

Assisted-by: Claude Code
docs: shader contract v2
All checks were successful
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 9s
darwin / darwin docs-only no-op (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 16s
windows / windows docs-only no-op (pull_request) Has been skipped
bench / instruction-count regression gate (pull_request) Successful in 48s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 1m14s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 52s
pr / nix flake check (pull_request) Successful in 39s
windows / cargo nextest (Windows) (pull_request) Successful in 3m22s
windows / frontend smoke (Windows) (pull_request) Successful in 48s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m35s
pr / wire schema is compatible with the base (pull_request) Successful in 2s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 27s
windows / cargo clippy (Windows cross) (pull_request) Successful in 14s
0c7ecf1aa0
The reference now covers chained stages, the history binding, params,
event animation and the appended uniforms; the pipeline explanation
records why a linear chain with per-stage history replaced the open
question about multi-pass composition and persistent textures, why
params are WGSL overrides, and how event animation keeps idle-zero.
REQ-716 pins the event-animation bound.

Assisted-by: Claude Code
natsukium deleted branch render/shader-contract-ext 2026-10-24 12:45:16 +09:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
felis-terminal/felis!239
No description provided.