Skip to content

Post-processing shaders (GLSL/WGSL, Ghostty-compatible) + CRT split-flap demo - #67

Merged
EDToaster merged 20 commits into
mainfrom
post-shaders
Oct 22, 2026
Merged

EDToaster merged 20 commits into
mainfrom
post-shaders

Conversation

@EDToaster

Copy link
Copy Markdown
Owner

Adds user post-process shaders (milestone M13). You run ShaderToy/Ghostty-style fragment shaders over the whole terminal, written in GLSL or WGSL, and they hot-reload. The spec is in docs/milestones/m13-user-shaders.md and the README has a new section.

[shader]
paths = ["~/.config/toastty/shaders/crt-splitflap.glsl"]   # chained in order
animation = "auto"                                         # auto | always | never

What's in it

  • Languages:
    • GLSL in the ShaderToy dialect (mainImage). It uses the same uniform names and types as Ghostty's shadertoy_prefix.glsl, so Ghostty shaders paste in unchanged.
    • WGSL (fn mainImage(fragCoord: vec2f) -> vec4f, with uniforms read as toastty.iTime etc.).
    • Both go through naga. The y axis points down, as in Ghostty on macOS, so nothing is flipped. Colours are sRGB-encoded, so a pass-through shader reproduces the frame exactly.
  • Toastty-only uniforms:
    • iTimeBell, iBellCursor, iBellCount and iBells[4], set from BEL. Each iBells entry holds the cell position, the time of the bell and an exact age computed on the CPU.
    • iCellSize and iGridRect, for effects that work per cell.
    • Term now records each BEL and the cursor cell it rang at. A BEL that ends an OSC string doesn't count.
  • Zero idle cost:
    • naga analysis finds which uniforms each shader reads, through helper calls. A shader with no time input draws nothing between events.
    • A shader can declare const float TOASTTY_EVENT_DURATION = 1.5; to animate only for that long after an event it reads (bell, cursor change, focus gain).
    • animation = "auto" stops continuous animation while unfocused and slows to 250 ms frames while occluded.
  • Render path: the content pass goes into scratch, then the post passes run (alternating between two textures for a chain), then a UI pass on top. The error banner, close dialog, chips and debug overlay are never shaded. With no shader configured, the frame path is unchanged.
  • Errors: compile errors show in the in-window banner as file:line:col, at the right line in your file. The previous working chain keeps running. Shader files are watched and reload on save.
  • Demo: examples/shaders/crt-splitflap.glsl. A static CRT look (curvature, scanlines, aperture mask, fringing, vignette). On each bell, a split-flap wave spreads out from the cursor: tiles riffle through characters taken from elsewhere on screen, then land back on their own.

Visual evidence

Real app on Xvfb (1280×800, X11 path), default config plus [shader] paths = ["…/crt-splitflap.glsl"].

The split-flap wave after printf '\a':

the demo shader: after a bell, cell tiles flip like a split-flap board in a wave spreading from the prompt, then settle back to the original text

Idle: the CRT look on its own (curvature, scanlines, vignette).

static CRT look over an ls listing

Mid-wave: the lighter tiles are flipping, the ones at the edge of the ring are folding over the hinge, and the text inside the ring has already settled.

mid-wave frame: a band of flipping tiles showing borrowed characters

A broken edit: the error banner names broken2.glsl:196:14 and is drawn unshaded (flat, not curved). The previous shader keeps running.

shader error banner at the bottom, CRT still applied to the content

CPU, measured from /proc/<pid>/stat with the demo shader running:

utime+stime ticks
idle, 3 s 0
typing one character, next 2 s 0
BackSpace at an empty prompt (bash rings the bell), next 2 s 11 (one ~1.5 s wave, then idle)

Tests

  • cargo test --workspace: all pass.
  • New toastty-render tests:
    • Unit tests for the preludes and error mapping, the uniform-use analysis (helpers, macros, both languages), the duration constant and its clamp, the event windows, and the uniform layout against naga's offsets.
    • GPU tests: pass-through, invert, chains.
    • End-to-end render_term tests on a new test-only offscreen target:
      • a pass-through chain is pixel-identical to no shader, on both full and partial frames;
      • an inverting chain leaves the banner and overlay unshaded;
      • a mouse event alone triggers a post-only frame;
      • a failed reload keeps the old chain;
      • turning the chain off restores the plain frame.
  • New config tests for [shader], shader file path resolution and loading, and the watcher. The watcher tests use real notify events, including symlinked directories and save-by-rename.
  • Term BEL tests.
  • Clippy: no new warnings in the touched crates (main isn't clippy-clean).

Known limitations

  • Transparency: output alpha is presented as is, so a shader that writes alpha = 1 makes a transparent window opaque. The demo keeps the input alpha.
  • Upside-down ShaderToy effects: ShaderToy shaders that draw relative to the bottom of the screen come out upside down, as in Ghostty on macOS.
  • Permanent delete: a shader file deleted for good is silent. The previous chain keeps running with no banner until the next config reload, because saves by rename look the same as a delete.
  • No feedback shaders: there are no multi-buffer ShaderToys (iChannel1+) or feedback buffers.
  • Screenshots cover the X11 path only: no Wayland, no HiDPI.

Adds [shader] paths / animation to toastty-config, and two
renderer-agnostic modules in the binary crate: shader_files (resolve
~ and config-relative paths, read sources, report per-file errors)
and shader_watcher (watch each parent dir, flag + Wake on change).
Not wired into main.rs yet.
…orKind

notify makes relative watch paths absolute, so bare relative targets
never matched. Two spellings of one directory share an inotify watch
and notify reports events under the last-added one; watch and match
against the canonical parent dir instead (file name kept as is).
ShaderFileError now carries the io::ErrorKind so a watcher-triggered
reload can skip NotFound during rename-based saves.
New crate::post module, not yet wired into the renderer:

- compile: GLSL (Shadertoy/Ghostty dialect) and WGSL preludes, naga
  parse + validate, errors mapped back to lines in the user's file,
  GLSL -> WGSL for wgpu.
- analysis: uniform-block members read by the entry point and its
  callees (on a compacted module), TOASTTY_EVENT_DURATION.
- uniforms: one std140/WGSL-compatible block layout, checked against
  naga's offsets for both preludes.
- state: shader clock, iMouse, focus, bells, cursor history, iDate,
  and the auto/always/never animation schedule.
- PostChain: one fullscreen pass per shader, ping-pong textures,
  non-sRGB views (or copy fallback) so pass-through is bit-exact.

Unit tests for the prelude, error mapping and analysis; GPU tests for
pass-through, invert, multi-pass chains and uniform upload.
Renderer API: set_post_shaders (all-or-nothing, keeps the previous
chain on failure), set_post_animation, set_focused, set_pointer,
has_post_shaders. With no chain the frame path is unchanged.

With a chain: the content pass always renders into scratch (which
gains TEXTURE_BINDING and a non-sRGB view format; the surface gets a
non-sRGB view format while a chain is on), the post passes replace the
scratch->swapchain blit, and a UI pass draws the debug overlay, error
banner, close dialog and chips unshaded on top. The IME preedit stays
in the content. Frames with no damage but a due post tick or event run
only the post and UI passes. Bells are read from Term in render_term;
post deadlines join next_redraw_deadline.

Also: text pipeline render_bg_range, color::linear_to_srgb, and an
example (examples/post_shader.rs) to try shaders in a window.
…rrors

- WGSL: the prelude now follows the user's file, so enable / requires /
  diagnostic directives work and user lines map one to one.
- A missing or mistyped mainImage reports the signature it must have
  instead of naga's 'Unknown function'.
- Error locations come from the narrowest span inside the user's file
  (pick_location), unit-tested.
… don't drop a shader change after a failed config reload
- Clamp TOASTTY_EVENT_DURATION to a day and use checked Instant math
  (a huge value used to panic).
- A bell, cursor change or focus gain opens the duration window only
  if the chain reads that event's uniforms (all three when it reads
  none of them).
- With a chain, a failed acquire (Outdated/Timeout/Occluded) no longer
  forces a full content rebuild; it counts as a dropped post frame,
  and time-driven frames slow to 4 Hz while occluded.
- No post deadline while synchronized output pauses rendering.
- iDate: `date +%z` runs on a helper thread, published via atomics,
  refreshed at the next local hour boundary; UTC until then or when
  date is missing (logged once at debug).
- With a chain, the chips and the close dialog no longer force a full
  clear every frame (they're drawn in the UI pass).
…chains

Renderer now draws into a Target: the window surface, or (cfg(test)
only) an offscreen Bgra8UnormSrgb texture shaped like the swapchain.
Renderer::headless builds one on the shared test device, so tests can
drive the real render_term frame path.

New render_tests: a pass-through chain matches the plain path for full
and partial frames (direct and copy-fallback outputs); an inverting
chain shades the content but not the error banner / debug overlay; an
iMouse event runs a post-only frame with no damage; no post deadline
while mode 2026 pauses rendering; a failed reload keeps the chain, and
turning it off restores the plain frame pixel for pixel.
…nt frames

- Only signature problems become the mainImage signature message: an
  unknown mainImage, or (WGSL) an argument-count/type or return-type
  error on the prelude's call. Other errors through the entry point
  (e.g. workgroupBarrier in a fragment shader) keep naga's message.
- WGSL: a file ending inside a comment (prelude commented out, no entry
  point) or an unclosed block (parse error in the prelude) is reported
  at the last user line.
- A post frame dropped by a timeout or an outdated surface keeps its
  pending event, so e.g. an iMouse press still gets its frame; only an
  occluded window drops it.
- set_post_shaders starts the UTC-offset lookup when the chain reads
  iDate, so the first frames aren't in UTC.
@EDToaster
EDToaster merged commit 63dcf82 into main Oct 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant