Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

wisp

A Pixi-shaped 2D scene graph + filter chain library on top of wgpu. Native Rust, no JavaScript, no DOM. Scene tree, sprite batcher, filter chain, mask system, text.

What it gives you

  • Scene treeContainer / Sprite / Graphics / Text, with transforms inherited from the parent.
  • Sprite batcher — single draw call per atlas, scene-tree order preserved.
  • Filter chain — composable post-process passes (blur, drop shadow, motion blur, color matrix), each with a passes() method so the renderer can allocate scratch RenderTextures on demand.
  • Mask system — rounded clip, privacy blur, solid redaction, spotlight, dim-outside, ellipse, freehand path. Works on raster AND vector primitives.
  • Text — atlas-cached bitmap text for HUDs + a Cosmic-Text / Glyphon-backed FlexibleText for high-quality body copy.
  • Headless export — render any scene to a RenderTexture, read pixels back as BGRA bytes, or push them straight into a GStreamer appsrc for video encode.

What it costs you

  • A wgpu adapter. Anything Metal / Vulkan / DX12 / GLES 3 capable.
  • Rust 1.82+ (some examples use newer features).
  • Awareness of wgpu's Queue::submit rhythm — wisp gives you the scene API but expects you to drive the frame loop.

What it is not

  • A game engine. There's no input handling, audio, networking, asset pipeline, or ECS.
  • A web canvas library. wisp targets native; the web works via wgpu's WebGPU backend but is not a primary surface.
  • A reactive UI framework. Wisp draws what you tell it to draw.

Why it exists

It's the renderer behind Screen Studio — a native Rust screen recorder. The full project is in the screen monorepo; this book is the renderer-only deep dive. If you want context on how the recorder uses wisp, the project book at /Screen/ has the integration story.

**`wisp` does not depend on `media`, `decode`, `playback`, `capture`,
or any application crate.** The dependency arrows go one way:

- `media` / `decode` / `playback` produce data (BGRA frames, audio
  histograms, geometry) and hand it to `wisp` via standalone types
  it already owns (`VideoTexture`, `Sprite`, `Graphics`).
- `wisp` provides the scene graph; everything else composes against it.

Any change that makes wisp pull from a higher-level crate breaks the
ability to publish wisp to crates.io as a standalone renderer. See
`_docs/wisp-book/src/intro.md` for the publishable-crate contract.
This is one of **two** sibling mdBooks deployed to the same GitHub
Pages site:

- **Project book** — `/Screen/` (recorder + capture + encoder + Tauri shell).
- **Wisp book** — `/Screen/wisp/` (renderer-only reference; publishable to crates.io independently).

Cross-references in either book go through the
`mdbook-preprocessor-cross` preprocessor so URLs adapt per book:

- `\{\{wisp-link path/to/chunk\}\}` — emits a relative URL inside
  the wisp book, an absolute `/Screen/wisp/path/to/chunk.html` URL
  from the project book.
- `\{\{shared path/to/fragment.md\}\}` — inlines a markdown
  fragment from `_docs/shared/` (this snippet you're reading is
  one).

Plain markdown links from the wisp book back to the project use
absolute URLs (`/Screen/...`) since the inverse direction is
single-target.

Get started

Quickstart

Three minutes from cargo add to a textured quad on screen.

1. Add the dependency

[dependencies]
wisp = "0.1"

2. The smallest scene that draws

#![allow(unused)]
fn main() {
use wisp::prelude::*;

fn build_scene() -> Stage {
    let mut stage = Stage::new(StageOptions {
        size: (1920, 1080),
        clear_color: [0.0, 0.0, 0.0, 1.0],
    });
    let id = stage.spawn(Sprite {
        position: vec2(960.0, 540.0),
        size: vec2(800.0, 450.0),
        anchor: vec2(0.5, 0.5),
        texture: TextureRef::Solid([0.4, 0.6, 1.0, 1.0]),
        ..Default::default()
    });
    stage.tick(0.0);
    stage
}
}

3. Drive the frame loop

Pick a host:

  • Native window via winit — wisp's Application::from_wgpu accepts a wgpu::Device + wgpu::Surface and renders straight to it.
  • Headless — render into a RenderTexture and call read_pixels for PNG dumps or push the BGRA bytes into GStreamer appsrc → vtenc_h264_hw → mp4mux → filesink for MP4 output.

See Headless export and Recorder mock for both flows running end-to-end.

4. Add a filter

#![allow(unused)]
fn main() {
let id = stage.spawn(Container::default());
stage.attach_filter(id, BlurFilter::new(8.0));
stage.attach_filter(id, DropShadowFilter::new()
    .with_offset(vec2(8.0, 8.0))
    .with_blur(12.0));
}

Filters compose left-to-right. The filter chain example animates three filters layered on one container.

5. Read the deep dive

  • Renderer overviewContainer / Sprite / Graphics / Text, the frame pump, RenderTexture.
  • Sprite batcher — how the renderer collapses N sprites into one draw call.
  • Filter chain — composing multiple post-process passes.
  • Text architecture — bitmap vs flexible text, glyph atlas, layout pipeline.
  • Mask system — dynamic mask textures, vector → mask bridging.

Used in production

wisp is the renderer behind Screen Studio, a native Rust screen recorder. If you want to see how an editor / capture pipeline / encoder integrates with wisp, the screen project book at /Screen/ has the full integration story.

wisp — overview

wisp is a Pixi-equivalent 2D scene graph + filter chain on wgpu.

It exists to power the recorder. Pixi's API shape (Stage → Container → Sprite, filters as a per-node chain, render-to-texture targets) maps cleanly to the recorder's compositor needs: a video sprite under cursor effects, drop shadows on the recording quad, filter chains for color grading, etc.

Every renderable feature ships with a story; every story is screenshotted into assets/wisp/<id>.png and shows up in Stories.

Core types

  • Stage — root scene container; owns the slotmap of nodes.
  • Container / Sprite / Graphics / Text / Mesh — node types.
  • Transform — position / rotation / scale; nested via parent/child.
  • FilterBlurFilter, DropShadowFilter, MotionBlurFilter, ColorMatrixFilter. Composable, applied at render.
  • Renderer — orchestrates the scene → texture path; stat counts come back in RenderStats { draw_calls, sprites_drawn, … }.
  • RenderTexture / VideoTexture — render targets; the latter for per-frame uploads (the path the recorder uses for capture frames).

For the full API see the rustdoc index.

wisp — stories

Every shipped renderable feature has a story. Each is a deterministic construction of a stage that exercises the feature in isolation; the same story drives the interactive just storybook gallery, the integration tests (tests/story_smoke.rs, tests/story_fingerprints.rs), and these screenshots.

Regenerate with just snapshots-wisp.

Renderer foundation

Hello quad — M0.5

hello quad

The smallest possible end-to-end render: one solid-coloured quad through the quad pipeline. Proves the application + renderer + render-to-texture path.

Sprite batcher — M0.10

sprite batcher

Many sprites issued as a single instanced draw call. Demonstrates the batching discipline that keeps draw counts low even as the scene grows.

Transform nesting — M0.7

transform nesting

Parent/child transform composition. Children inherit the parent matrix; nested rotations / scales compose correctly.

Text — M0.13

text bitmap

Embedded ASCII bitmap font (font8x8). No external font files; just a glyph sprite atlas built at startup.

Graphics primitives (SDF-based)

Rounded rect — M0.16

rounded rect

Signed-distance-field rounded rectangle with fwidth-based AA. Crisp edges at any scale.

Ellipse — M0.16

ellipse

SDF ellipse with the same AA path. Replaces a pre-tessellated mesh.

Gradients — M0.16

gradients

Linear and radial gradient fills. Same Graphics node, different fill rules.

Filters (multi-pass post-process)

Blur — M0.17

blur

Separable Gaussian (9-tap horizontal + vertical). Two render-target ping-pongs per filter application.

Drop shadow — M0.17

drop shadow

Multi-pass: extract → blur h → blur v → composite. Becomes the recorder's recording-quad shadow.

Motion blur — M0.18

motion blur

Velocity-vector blur. Catches a sense of motion on the cursor and zoom.

Color matrix — M0.18

color matrix

Three copies of the same source — identity, grayscale, brightness — through a 4×5 RGBA matrix. The building block for any "look" preset.

Mesh

Perspective — M0.19

perspective

3D Y-axis rotation in WGSL. Foundation for the recorder's "tilt" preset.

Textured quad — M0.6

hello quad

A 64×64 procedural checker pattern uploaded as a Texture and rendered as a single Sprite, anchored at the center, rotating slowly.

This is the M0.6 baseline: Texture::from_rgba constructs a GPU texture from raw bytes, Sprite::from_texture wraps it in a scene-graph node, and Renderer::render_stage composites it onto the canvas.

The unified quad shader (quad.wgsl) takes per-vertex UV and a per-instance model matrix + tint. Anchor (0.5, 0.5) centers the sprite at its position so rotation pivots around the sprite center rather than its top-left corner.

Once the recorder is wired up, the screen-capture frame becomes a VideoTexture (M0.11) consumed by exactly this same Sprite path — the recorded video is just another textured quad.


wisp API · Stories index

Nested transforms — M0.7 / M0.8

transform nesting

Three nested containers, each spinning at a different rate. Children inherit their parents' transforms — the inner ring's apparent path is the composition of all three rotations.

This is the M0.7+M0.8 contract made visible: the renderer's pre-order traversal multiplies parent-world × local on the way down (compose(world_parent, &local)), so each sprite's final clip-space position is parent_outer · parent_mid · parent_inner · local_sprite.

The recorder uses the same pattern for nested compositions: the recording quad is a child of a "padding" container, which is a child of a "background" container. Animating the padding container's transform slides the entire recording smoothly without touching individual children.

Reverse-direction rotation in the middle ring is intentional — it makes the composition obvious. If you stopped one ring at a time you could read off each transform's contribution.


Container API · Transform · Stories index

Sprite batcher — M0.9

sprite batcher

100 sprites, all sharing the same texture Arc, rendered in a single draw call.

This is the M0.9 anti-regression contract. The renderer's collect_batches walks the scene in pre-order, groups instances by (texture_id, blend_mode), and emits one instance buffer per batch. When sprites share a texture they collapse into one batch.

For the recorder this is what makes the cursor-trail-of-100-clicks scenario feasible: even if the cursor effects layer fans out into many sprites in a short window, draw-call cost stays constant.

Texture::id() is Arc::as_ptr(&inner) as usize — texture-pointer-equality. Cloning a Texture shares the GPU resource and the batch key.


Sprite API · Texture · Stories index

Rounded rect with stroke — M0.12 / M0.13

rounded rect

Three rectangles sharing one shader, one pipeline, one draw call.

The unified graphics_solid.wgsl handles rect (radius=0), rounded rect, and ellipse via a kind flag. Anti-aliasing comes from fwidth(d) of the SDF, giving clean edges at any zoom level without MSAA.

Stroke is rendered as a second instance with mode=1. The vertex shader expands the bounding quad by stroke_width/2 so the band has room to draw. Fill + outline both batch into the same draw call.

For the recorder this primitive becomes: video padding/corners, keyboard chip backgrounds, caption backgrounds, click ripples (as outlined ellipses), mask highlights — all the chrome around the recording.


Graphics API · Stroke · Stories index

Animated click ripple — M0.13

click ripple

Three click ripples animated via the story's tick hook.

Each ripple is one outlined ellipse: filled with a low-alpha center, stroked with a brighter outer edge that fades as the radius grows. Three are staggered in time to read as separate clicks.

The ellipse SDF uses the standard scaled-circle approximation: (length(p / r) - 1.0) * min(r.x, r.y). Visually correct for moderate eccentricities; exact ellipse SDF requires iteration and isn't necessary for ripple effects.

This is exactly the recorder's click-ripple feature (M0.18+ in the recorder roadmap): captured cursor events trigger ellipse animations on the timeline, each a few hundred ms long. The renderer batches every ripple in the frame into one draw call.


Graphics API · Stories index

Arc + annular sector — M-VEC.20

arc + annular sector primitives

Two new SDF primitives in Graphics:

  • draw_annular_sector(center, r_inner, r_outer, start, end) — filled pie slice (when r_inner = 0) or donut slice (when r_inner > 0).
  • draw_arc(center, radius, start, end, stroke_width) — thin stroked curve; internally an annular sector with r_inner = radius - stroke_width / 2, r_outer = radius + stroke_width / 2.

Angles are radians; 0 aligns with +x, counter-clockwise positive. The angular span clamps to [0, 2π]; an end angle ≥ start angle + 2π collapses to a full ring (or full disc when r_inner = 0).

Why this lives in wisp

Pie / donut / gauge / sunburst charts need arcs at chart-level resolution (hundreds of pixels of radius). Approximating an arc with many short line segments produces visible faceting and breaks SDF anti-aliasing. Putting arcs in Graphics lets every consumer get clean AA edges via the same fwidth(d) pipeline that rect / rounded rect / ellipse use.

SDF math

The shader rotates the local point so the wedge centerline aligns with +y, then mirrors across +y so only the right half-plane needs handling. Inside the wedge, distance reduces to r - r_outer (disc case) or max(r - r_outer, r_inner - r) (annulus case). Outside the wedge, distance is to the radial wedge edge — a clamped projection onto sc * t for t ∈ [r_inner, r_outer]. Implementation in graphics_solid.wgsl::sdf_annular_sector.

The wedge symmetry (abs(p.x) after rotation) is what makes this fit one branch-free SDF. It also means the maximum supported angular span is — wider spans are clamped at the call site.

```admonish warning title="Stroke vs draw_arc" draw_arc is the convenient stroked-curve form; it lowers to an annular sector with thickness = stroke_width. If you want a bordered annular sector (filled region + outline), use draw_annular_sector with the graphics' current stroke set — that emits two instances (fill + outline band) like draw_rect does.


## Chart consumers

This primitive unblocks three chart tickets that were filed against
`wisp-chart`:

| Chart | Linear | Uses |
|---|---|---|
| Pie / donut (M-CHART.17) | [AUT-197](https://linear.app/harwood/issue/AUT-197) | one `draw_annular_sector` per slice |
| Gauge (M-CHART.15) | [AUT-195](https://linear.app/harwood/issue/AUT-195) | coloured threshold-zone annular sectors + a needle |
| Sunburst (M-CHART.36) | [AUT-216](https://linear.app/harwood/issue/AUT-216) | nested annular sectors per hierarchy level |

## Verified by

`crates/wisp/tests/render_annular_sector.rs` — four tests pin each
geometric case:

1. **Full disc** — `r_inner = 0`, full angular span; centre pixel reads
   as the fill colour, edge pixel reads as background.
2. **Quarter wedge** — 90° pie slice; upper-right pixel reads as fill,
   lower-left pixel reads as background.
3. **Donut band** — `r_inner > 0`, full angular span; centre pixel reads
   as background (hole), mid-band pixel reads as fill.
4. **Stroked arc** — `draw_arc` with narrow band; centerline pixel reads
   as fill, just-inside pixel reads as background, well-outside-angular-span
   pixel reads as background.

---

[`Graphics` API](../../api/wisp/scene/struct.Graphics.html) · [Stories index](../stories.md)

Convex polygon — M-VEC.21

convex polygon shapes

Graphics::draw_polygon(vertices) fills any convex polygon listed in counter-clockwise winding order. The polygon is implicitly closed — the last vertex connects back to the first. Polygons under 3 vertices are silently skipped.

This is the chart-layer enabler for area fills, sankey ribbons, funnel-area trapezoids, and the ternary simplex outline — all of which are convex by construction in v1.

Convex-only for v1

Fan triangulation from vertex 0 produces visible overlap when the polygon isn't convex. Non-convex input is undefined behaviour for v1. Full tessellation (lyon_tessellation + non-convex SDF for edge AA) is a follow-on chunk; today's chart-side consumers are all convex and don't need it.

No edge anti-aliasing

The triangle-list path renders with hard pixel edges (no fwidth()-based smoothstep like the SDF primitives). Where crisp edges matter — chart labels, callout borders — stroke the polygon perimeter with draw_line segments; those go through the existing SDF line path and are properly anti-aliased.

Render path

A separate WGSL shader (graphics_polygon.wgsl) and a sister BlendPipelineMap inside GraphicsPipeline handle polygons. The scene walk separates each Graphics node's primitives into:

  1. SDF instances (rect / rounded rect / ellipse / line / annular sector) — flow through graphics_solid.wgsl as instanced quads.
  2. Polygon triangles — fan-triangulated CPU-side from vertex 0, world matrix baked into clip coordinates, then flow through graphics_polygon.wgsl as a plain triangle list.

Both share the polygon node's blend-mode bucket so a chart that mixes SDF primitives (axis gridlines, point markers) with polygons (area fill) composites in the order their primitives were emitted on the node.

Verified by

crates/wisp/tests/render_polygon.rs — three tests:

  1. Square — minimum-viable 4-vertex polygon; centre fills, exterior is background.
  2. Regular pentagon — non-rect convex; centre fills, exterior is background.
  3. Trapezoid — funnel-area-style asymmetric quad; centre fills, off-edge corner reads as background.

Chart consumers

ChartLinearUses
Area chartAUT-190filled polygon below curve
SankeyAUT-217edge ribbons (cubic Bezier → flatten → polygon)
Funnel (area mode)AUT-218trapezoidal connections between stages
TernaryAUT-210simplex triangle outline
Contour (filled, partial)AUT-209convex bands today; full non-convex deferred

Graphics API · Stories index

Gradient fills — M0.14

gradients

Two gradient fills in one Graphics, one draw call.

The left rounded rect uses a linear gradient — the shader projects each fragment's local position onto the start → end line and mixes the two colors by the projection parameter t.

The right ellipse uses a radial gradientt is the fragment's distance from center divided by radius, clamped to [0, 1].

Both gradients evaluate in primitive-local coordinates ([-half_extents, +half_extents]), so the gradient transforms with the primitive's container — rotate the parent and the gradient rotates too, just like a painted gradient on a moving cel.

For the recorder: linear gradients give the padded "wallpaper" backgrounds (the kind Screen Studio ships) for one extra fill kind; radial gradients give vignette-style highlights around clicks or focus points.


Fill API · Stories index

Bitmap text — M0.15

text bitmap

Bitmap glyph rendering using the embedded font8x8 ASCII set.

Each character is an 8×8 pixel bitmap packed into a 128×128 atlas (16×16 grid). The text pipeline emits one instance per glyph; instances from all Text nodes that share a font atlas batch into a single draw call (this story = 2 Text nodes × N chars = 1 draw call).

Why bitmap not vector: zero external font files, deterministic, tiny dep. When the recorder needs anti-aliased type at multiple sizes, we add fontdue as a separate Font variant — but the Font / Text API stays the same.

Layout: cursor flows left-to-right; \n resets cursor.x = 0 and moves cursor.y down by line_height = cell_size × 1.25. Anchor is the top-left of the first line; transform.position places that anchor in scene-graph world coords.

Color: per-Text. To recolor mid-string would require multiple Text nodes — fine for the recorder (keyboard chips, captions) where we never mix colors mid-glyph.


Text API · Font · Stories index

Text rotation under Container transform — M-TEXT.20

wisp::Text glyphs are placed by a world matrix that's the composition of every Container::transform between the text node and the stage root. That includes the text's own text.container.transform.rotation. The propagation is automatic — no separate rotation field on Text, no glyphon-side bypass.

Why this matters for charts

Y-axis titles need to read bottom-to-top, which is Text rotated -π/2 (90° clockwise on a +Y-up renderer). Radar / polar axis labels follow the same path. Without rotation propagation, every chart family that needs vertical text would need a wisp-side enabler. With it, the chart layer just sets text.container.transform.rotation and renders.

Convention

  • Rotation is counter-clockwise in radians: 0.0 = unrotated; +π/2 = quarter turn CCW; -π/2 = quarter turn CW (the Y-axis-label rotation).
  • The rotation pivot is the text's local origin by default — the same point transform.position places in world space. To rotate around a different anchor (e.g. the centre of the string), set transform.pivot accordingly.
  • Rotation composes with transform.scale and parent containers through Mat3 multiplication in scene::transform::compose.

Verified by

crates/wisp/tests/text_rotation.rs — three assertions:

  1. Unrotated 5-char text produces a wide bounding box (width > height).
  2. The same text with rotation = -π/2 produces a tall bounding box (height > width).
  3. The aspect-ratio flip is large enough to be unambiguous (h_aspect > 1.5, v_aspect < 0.67).

These tests double as anti-regression guards: any future change to the text pipeline that drops the world matrix multiplication on glyph instances will fail them.

Example

#![allow(unused)]
fn main() {
use wisp::{Color, Font, Stage, Text};

let font = Font::bitmap_8x8(&app);
let mut title = Text::new(font, "Revenue ($M)").with_cell_size(0.04);
title.color = Color::rgba_u8(34, 34, 34, 255);
// Position next to the Y-axis tick labels.
title.container.transform.position = glam::Vec2::new(-0.9, 0.0);
// Rotate -90° so the text reads bottom-to-top.
title.container.transform.rotation = -std::f32::consts::FRAC_PI_2;

stage.add_child(stage.root(), title);
}

Text API · Container · Transform

Blur filter — M0.16

blur

Sharp source on the left; the same texture, blurred via a two-pass separable Gaussian, on the right.

The Filter trait is straightforward: passes() returns the number of render passes the filter needs (BlurFilter returns 2 — one horizontal, one vertical), and render_pass(ctx, input, output, pass) does the work for that pass. The Renderer's apply_filter orchestrator allocates a scratch RenderTexture for multi-pass filters and ping-pongs.

For the recorder this gives us:

  • The mask/highlight tool's blurred sensitive regions.
  • The glassmorphism background variant.
  • A building block for DropShadowFilter (M0.17 = alpha extract → blur → composite).

The 9-tap kernel uses a small fixed weight table tuned for visual blur quality at typical UI radii (1–8 texels). For larger blurs we'd ramp the kernel size or run multiple passes.


BlurFilter API · Stories index

Drop shadow — M0.17

drop shadow

A rounded rect rendered offscreen, then composited on top of its own blurred shadow.

DropShadowFilter does four passes inside a single Filter::render_pass call: it allocates two scratch RenderTextures, extracts the source alpha (offset and tinted) into scratch_a, runs separable Gaussian blur (h then v) using BlurFilter's pipeline, then composites the source over the blurred shadow with alpha-over math.

For the recorder this gives the cinematic recording-card look: a padded recording quad floating over a wallpaper background with a soft drop shadow underneath. The shadow color/alpha controls how grounded the recording feels; the offset controls perceived light direction.

Re-using BlurFilter's run_blur_pass between filters keeps the shader sharing tight — DropShadow doesn't duplicate Gaussian math, it just calls into it.


DropShadowFilter API · Stories index

Motion blur — M0.18

motion blur

A solid dot on the left; the same dot smeared along a (900, 600) velocity vector on the right.

MotionBlurFilter reuses the separable Gaussian shader from BlurFilter but swaps the axis-aligned (1,0) / (0,1) directions for the unit-velocity vector. Kernel size scales with velocity.length() / peak_velocity_pps clamped at max_kernel_px — constants 1400 / 14 lifted from OpenScreen's zoomTransform.ts.

For the recorder this is the foreshadowing motion blur during zoom transitions and panning. When the camera (the recording viewport) is in motion, every frame's source content gets smeared along the velocity vector — the same trick Screen Studio uses to make zoom feel cinematic instead of jarring.


MotionBlurFilter API · Stories index

Color matrix — M0.18

color matrix

Three copies of the same source — identity, grayscale, and brightness ×1.4 — using ColorMatrixFilter.

The shader applies a 4×5 matrix (out = M · [r, g, b, a, 1]). Named constructors give common operations: identity(), grayscale() (Rec.709 luminance weights), brightness(scale). Compose them by chaining filter applications, or build a custom matrix for tone curves, channel shuffles, sepia, etc.

For the recorder this becomes: per-clip color grading, accessibility filters (high contrast, deuteranopia simulation), and the building block for any "look" preset.


ColorMatrixFilter API · Stories index

Perspective rotation — M0.19

A textured quad rotating around the Y axis with perspective foreshortening — the M0.19 Mesh node + custom WGSL shader. The animation above is the storybook's tick running for 3 seconds at 30 fps, captured to MP4 via just snapshots-wisp-animated (gstreamer-backed).

The shader rotates the quad's vertex positions in 3D (x and z change with cos/sin, y stays fixed), then projects with 1 / (1 + z * persp_strength). At full edge-on the quad disappears (the projection collapses); at face-on it looks like a normal sprite. Tunable strength (0.0 = orthographic, 1.0 = aggressive foreshortening).

For the recorder, this gives the camera-bubble "tilt-on-focus" treatment and any time the recording quad needs to feel like a card flipping in space rather than a flat sprite.

Mesh nodes still batch by texture — multiple meshes sharing one texture render in a single draw call (verified by the meshes_sharing_texture_batch_into_one_draw_call test).


Mesh API · Stories index

Filter chain — M0.20

filter chain

The M0.20 proof point that wisp's Filter trait composes cleanly when multiple post-processing passes need to stack.

The pipeline:

flowchart LR
    Stage --> |Renderer::render_stage| RTbase[RT_base]
    RTbase --> |BlurFilter<br/>radius=lerp 0.5..6| RTa[RT_a]
    RTa --> |DropShadowFilter<br/>blur=lerp 0..14<br/>offset=8,8| RTb[RT_b]
    RTb --> |MotionBlurFilter<br/>velocity=lerp 0,0..60,0| RTfinal[RT_final]

Three filters in sequence, each fed by the previous filter's output. Each filter declares passes() and the Renderer::apply_filter helper allocates a scratch RenderTexture for multi-pass filters (Blur is two-pass — separable Gaussian; the others are one-pass).

crates/wisp/examples/filter_chain.rs animates all three parameters together over 60 frames so the chain visibly layers — the highlight above is frame 30, where blur radius is 3.25 px, drop-shadow blur is 7 px, and motion-blur kernel is at 50% of peak_velocity_pps.

The example is fully headless — same render path the M-EXPORT pipeline will use when it consumes a project file and emits PNGs (or pushes BGRA frames into GStreamer's appsrc → encoder → mp4mux graph, per AUT-144). Run with:

cargo run -p wisp --example filter_chain
# 60 frames at target/filter_chain/frame_NN.png
# highlight at _docs/book/src/assets/wisp/example-filter-chain.png

BlurFilter · DropShadowFilter · MotionBlurFilter · Renderer::apply_filter

Recorder mock — M0.21

recorder mock

The M0.21 proof point: the full layered scene tree from recorder-features-and-render-api.md §4, rendered headless.

The compositor stack, back-to-front:

  1. Gradient background panel — a Graphics linear gradient covering NDC.
  2. Recording quad — placeholder for the screen capture, drawn as a rounded Graphics with stroke (the real M2 path swaps in a Sprite wrapping a VideoTexture fed by ScreenCaptureKit).
  3. Camera bubble — rounded Sprite in a corner.
  4. Cursor sprite + click ripple — a small textured Sprite plus a Graphics ellipse scaled-up beneath it.
  5. Keyboard chip — rounded Graphics with a Text label inside.
  6. Caption textText under the recording quad.

Render output: target/recorder_mock.png. Stats reported on stdout: draw_calls=5, sprites=3, graphics=4, glyphs=43, meshes=0. Every primitive type wisp ships is exercised in one frame — if recorder_mock runs cleanly, the public API surface is sufficient for the recorder's editor preview.

cargo run -p wisp --example recorder_mock

The mock uses synthetic textures (a checker pattern for the recording quad's stand-in, a simple sprite for the cursor). M2+ replaces these with real capture frames; the scene-graph shape stays the same.

Stage · Sprite · Graphics · Text · Recorder feature inventory (offsite — workspace docs)

Headless export — M0.21

headless export · frame 30 of 60 at 1080p

The M0.21 closing proof point: 60 frames at 1920×1080, fully headless, dumped as PNGs. This is the path the M-EXPORT pipeline inherits — render the project file's scene graph for each frame's timeline tick, dump pixels, push BGRA into GStreamer's appsrc → vtenc_h264_hw → mp4mux → filesink (or its mfh264enc / vaapih264enc / x264enc platform variants).

The example renders the same recorder-mock-shaped scene but animates it:

  • Recording quad rotates ±8° (one cycle over 60 frames).
  • Cursor sprite oscillates horizontally on a Lissajous-style path.
  • Text label scale pulses 1.0 → 1.15 → 1.0.
cargo run -p wisp --example headless_export
# Outputs:
#   target/headless_export/frame_00.png … frame_59.png   (60 frames)
#   _docs/book/src/assets/wisp/example-headless-export.png  (highlight)

The highlight above is frame 30 — mid-animation peak. Sixty 1080p PNGs total ~30 MB on disk; compositing them into a real MP4 is M2+ scope when the encode crate lands.

What this rules out (and rules in)

  • Rules in: wisp can render a non-trivial scene (4 layers, 60+ glyphs) at 1080p in milliseconds per frame. The export pipeline isn't GPU-bound for typical recording scenes.
  • Rules in: the RenderTextureread_pixels path is allocation- free per frame after warmup (the texture pool persists across frames).
  • Rules out (deferred to M-EXPORT): real per-frame timeline data (currently parameters are computed from frame / 60.0); the export format negotiation (we write PNG-per-frame; production pipeline pushes raw BGRA into GStreamer's appsrc and converts to yuv420p inline with videoconvert ! video/x-raw,format=I420).

GStreamer is the single media stack

The M0.21 spec originally called for examples/video_texture.rs to "loop an MP4 decoded via ffmpeg-next." During M-DEC the project locked in GStreamer as the only media library (see AUT-144 and the stack reference); the equivalent decode path now lives at crates/playback/examples/play_file.rs. The headless side that M0.21 cared about — render-loop-to-PNG — is exactly what headless_export ships, and the eventual encode side will push the same BGRA frames into a GStreamer appsrc pipeline.

Renderer::render_stage · RenderTexture::read_pixels · Playback overview

Blend modes

28 blend modes contact sheet

Backdrop is a red→blue horizontal gradient; foreground is the Apollo 17 "Blue Marble" NASA photograph (public domain). Picking real-world content over synthetic gradients makes each mode legible: continents punch through Multiply, oceans tint under Hue / Color, black space inverts on Difference, and so on.

The full PixiJS v8 catalog, implemented in two architectural buckets:

  • Standard (8 modes) — single-pass GPU blend equations via wgpu::BlendState. Free.
  • Advanced (20 modes) — offscreen filter pass that samples backdrop + foreground and runs a per-mode blend shader. One extra render-target per advanced-blended node.

Catalog

ModeBucketFormula
normalstandardsrc.a · src + (1 - src.a) · dst
multiplystandarddst · src + (1 - src.a) · dst
addstandardsrc + (1 - src.a) · dst
screenstandard1 - (1 - src) · (1 - dst)
subtractstandarddst - src (clamped)
minstandardmin(src, dst) per channel
maxstandardmax(src, dst) per channel
erasestandarddst · (1 - src.a)
overlayadvanced(base < 0.5) ? 2·base·blend : 1 - 2·(1-base)·(1-blend)
hard-lightadvancedoverlay with the test on blend instead of base
soft-lightadvancedW3C piecewise (smoother than hard-light)
pin-lightadvanced(blend < 0.5) ? min(base, 2·blend) : max(base, 2·blend - 1)
hard-mixadvancedstep(0.5, base + blend - 0.5)
vivid-lightadvancedcombination of color-burn (low blend) + color-dodge (high blend)
linear-lightadvancedbase + 2·blend - 1 clamped
color-burnadvanced1 - min(1, (1 - base) / blend)
color-dodgeadvancedmin(1, base / (1 - blend))
linear-burnadvancedbase + blend - 1 clamped
linear-dodgeadvancedbase + blend clamped
darkenadvancedmin(base, blend)
lightenadvancedmax(base, blend)
differenceadvancedabs(base - blend)
exclusionadvancedbase + blend - 2·base·blend
negationadvanced1 - abs(1 - base - blend)
divideadvancedclamp(base / blend, 0, 1)
saturationadvanced (HSL)set_lum(set_sat(base, sat(blend)), lum(base))
coloradvanced (HSL)set_lum(blend, lum(base))
luminosityadvanced (HSL)set_lum(base, lum(blend))

API

Standard modes

Set on the node's container; rendered automatically in render_stage:

#![allow(unused)]
fn main() {
let mut graphics = Graphics::new();
graphics.fill(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)));
graphics.draw_rect(Rect::new(-1.0, -1.0, 2.0, 2.0));
graphics.container.blend_mode = BlendMode::Multiply;  // ← here
}

Each pipeline (sprite, graphics, text, mesh) holds a BlendPipelineMap of one wgpu::RenderPipeline per native mode (8 pipelines per shader pre-built at construction time). Drawing groups instances by (texture, blend_mode) and binds the right pipeline for each batch.

Advanced modes

Auto-dispatched by render_stage since M-BLEND.2. Just set the container's blend mode to an advanced variant and call render_stage the same way you would for a native mode:

#![allow(unused)]
fn main() {
let mut sprite = Sprite::from_texture(tex);
sprite.container.blend_mode = BlendMode::Overlay;  // ← Tier C, just works
let mut stage = Stage::new();
let _ = stage.add_child(stage.root(), sprite);
renderer.render_stage(&app, view, Color::BLACK, &stage);
}

Internally the renderer detects advanced-blend nodes during scene traversal and routes them through the offscreen pipeline:

flowchart TD
    Start([render_stage]) --> Collect["collect_advanced_blend_nodes(stage)<br/>→ Vec&lt;NodeId&gt;"]
    Collect --> Check{any advanced<br/>nodes?}
    Check -->|no| Fast["fast path:<br/>one render pass into view<br/>(identical to pre-M-BLEND.2)"]
    Check -->|yes| Alloc["allocate dest_a, dest_b<br/>at app dims (ping-pong RTs)"]
    Alloc --> Phase1["Phase 1: render scene MINUS<br/>advanced subtrees → dest_a"]
    Phase1 --> Phase2[/"Phase 2: for each advanced<br/>node in pre-order"/]
    Phase2 --> SubRender["a. render that subtree → foreground RT"]
    SubRender --> AdvBlend["b. apply_advanced_blend(<br/>mode, backdrop=dest_a,<br/>foreground, output=dest_b)"]
    AdvBlend --> Swap["c. swap dest_a ↔ dest_b"]
    Swap --> Phase2
    Phase2 --> Phase3["Phase 3: blit final dest<br/>→ view via BlitPipeline"]

The fast path is unchanged for native-only stages — no perf regression for callers who don't use advanced modes.

Manual API

The explicit per-RT API is still exposed for cases where you want to pre-bake compositions or feed the result to another filter:

#![allow(unused)]
fn main() {
renderer.apply_advanced_blend(
    &app,
    BlendMode::Overlay,
    &backdrop_rt,    // already-rendered destination
    &foreground_rt,  // this node's contribution
    &output_rt,
);
}

The backdrop and foreground are pre-rendered separately. The apply_advanced_blend call binds the right shader (one of 20 pre-built at Renderer::new time) and writes the composite to output_rt.

Shader template

All 20 advanced modes share a single template at crates/wisp/shaders/advanced_blend.wgsl. The Rust resolver in render/advanced_blend.rs substitutes a per-mode blend_fn(base, blend) -> vec3<f32> snippet at pipeline construction. The template handles:

  • Fullscreen-triangle vertex shader.
  • Texture + sampler bindings for backdrop + foreground.
  • HSL helpers (lum, clip_color, set_lum, sat, set_sat) used by the saturation / color / luminosity trio.
  • Output compositing: mix(backdrop, blended, foreground.a) for RGB + source-over alpha.

Tests

The catalog is exhaustively tested:

  • crates/wisp/tests/blend_modes_standard.rs — 8 tests, one per native mode. Asserts center-pixel readback after compositing red over blue (or similar known-input pair).
  • crates/wisp/tests/blend_modes_advanced.rs — 20 tests, one per advanced mode. Each picks input colors that yield a deterministic, human-checkable expected output (with a 2-LSB tolerance for GPU rounding variance).

Pivot from PixiJS

The implementation maps closely onto PixiJS's pixi.js/advanced-blend-modes sub-export. One intentional simplification:

  • Single template, not 20 separate shader files. PixiJS ships each advanced mode as its own .ts file with a parallel WGSL/GLSL fragment. We share a template and inject blend_fn per mode at pipeline construction. Smaller surface, equivalent runtime cost.

Auto-dispatch (the original M-BLEND.1 deferral) shipped in M-BLEND.2 — behavior matches PixiJS for the common case of "set blend_mode on a node, render normally."

Generate the contact sheet

cargo run -p wisp --example blend_modes_gallery
# writes _docs/book/src/assets/wisp/blend-modes.png

Background gradient is red → blue (horizontal); foreground gradient is yellow → cyan (vertical). Each tile shows the composite for one mode, with the kebab-case label overlaid in white.

Rounded crop / mask foundation

Linear: AUT-31

rounded crop

The first mask primitive in wisp — and the foundation every later mask issue (AUT-20 through AUT-35) extends.

What landed

  • New MaskShape enum in crates/wisp/src/scene/clip.rs. Today's only variant is RoundedRect { rect, radius }. Future variants (Circle, Ellipse, Path) come from later issues.
  • New Container::clip: Option<MaskShape> field. Defaults to None — fast-path renders are unchanged.
  • New crates/wisp/shaders/clip.wgsl — fragment shader that samples a foreground RT and multiplies the alpha by the rounded-rect SDF.
  • New crates/wisp/src/render/clip.rs — pipeline that runs the shader with per-call uniforms.
  • Renderer reshape: the slow-path dispatcher (which already handled Tier-C advanced blends from M-BLEND.2) now also handles clipped containers.

Architecture

Container::clip plugs into the M-BLEND.2 dispatch model. A node is "dispatched" if it has an advanced blend mode OR a clip set. Both trigger the offscreen path:

flowchart TD
    Start(["render_stage(view, stage)"]) --> Collect["collect_dispatched_nodes(stage)<br/>pre-order walk"]
    Collect --> Any{any<br/>dispatched?}
    Any -->|no| Fast["fast path:<br/>one render pass<br/>(native-only identical)"]
    Any -->|yes| Phase1["Phase 1: render scene<br/>MINUS dispatched subtrees → dest_a"]
    Phase1 --> Phase2[/"Phase 2: for each<br/>dispatched node, pre-order"/]
    Phase2 --> Subtree["render subtree → foreground"]
    Subtree --> HasClip{has clip?}
    HasClip -->|yes| ClipApply["clip.apply(shape, foreground) → masked<br/>composite_src = masked"]
    HasClip -->|no| NoClip["composite_src = foreground"]
    ClipApply --> Advanced{advanced<br/>blend?}
    NoClip --> Advanced
    Advanced -->|yes| AdvBlend["apply_advanced_blend(mode,<br/>dest_a, composite_src) → dest_b<br/>swap a ↔ b"]
    Advanced -->|no| Compose["blit.compose_over(<br/>composite_src, dest_a)<br/>alpha-blend"]
    AdvBlend --> Phase2
    Compose --> Phase2
    Phase2 --> Phase3["Phase 3: BlitPipeline::blit(<br/>final_dest, view)"]

A container with BOTH a clip AND an advanced blend mode does the clip first, then the advanced composite. Order matters: the user expects the advanced math to operate on the masked subtree, not the unmasked one.

Coordinate system

Screen space, not local space

MaskShape::RoundedRect { rect, radius } is in NDC [-1, +1]²screen space, not container-local space. A clip + a non-identity transform on the same container clips in screen space and then transforms the subtree inside it, which is usually not what intuition expects. Transform-aware clipping ("clip a moving sprite to its own bounds") is a future enhancement.

The recording-quad use case (a fixed-position recording surface with rounded corners) drove this choice.

SDF anti-aliasing

The mask uses a standard rounded-rectangle SDF:

fn sdf_rounded_rect(p: vec2<f32>, half: vec2<f32>, r: f32) -> f32 {
    let q = abs(p) - half + vec2<f32>(r);
    return length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - r;
}

let mask = clamp(0.5 - d / aa, 0.0, 1.0);
return vec4<f32>(fg.rgb, fg.a * mask);

aa = 2 / min(width, height) so the AA band spans roughly one output pixel. Hard edges on cropped photos / video become smooth without per-call resolution scaling.

API

#![allow(unused)]
fn main() {
let mut clip_container = Container::new();
clip_container.clip = Some(MaskShape::RoundedRect {
    rect: Rect::new(-0.75, -0.55, 1.5, 1.1),
    radius: 0.14,
});
let clip_id = stage.add_child(stage.root(), Node::Container(clip_container)).unwrap();
let _ = stage.add_child(clip_id, recording_sprite);
}

Or apply a clip to a leaf directly:

#![allow(unused)]
fn main() {
let mut sprite = Sprite::from_texture(tex);
sprite.container.clip = Some(MaskShape::RoundedRect { ... });
let _ = stage.add_child(stage.root(), sprite);
}

Tests

crates/wisp/tests/clip_rounded_rect.rs — 4 pixel-readback cases:

  • center_pixel_is_inside_the_clip — confirms the masked-in region renders the foreground color at full opacity.
  • far_corner_is_outside_the_clip — confirms a pixel well outside the clip rect shows the parent's clear color.
  • pixel_inside_rect_but_outside_corner_radius_is_clipped — confirms the rounded shape is honored, not just the bounding rect.
  • no_clip_renders_normally_via_fast_path — regression guard that scenes without Container::clip skip the offscreen dispatch.

Rectangle privacy blur

Linear: AUT-20

Renderer::apply_privacy_blur(region, radius, base, output) is the first masked-filter primitive. It composes three previously shipped pieces:

  1. Blur the entire base RT into a scratch RT via BlurFilter::new(radius).
  2. Clip the blurred copy to a MaskShape::Rect { region }. Outside the rect, alpha drops to zero; inside, alpha stays at 1.
  3. Compose the masked overlay over a fresh copy of the base via the blit pipeline's compose_over (alpha-blending). Outside the rect the base shows through pixel-perfect; inside the rect the blurred version wins.

The signature deliberately mirrors apply_filter — region + radius + in/out RTs — so the caller (today: a story; tomorrow: the recorder front-end) doesn't need to know about the three-RT pipeline behind it. AUT-21 generalizes the rect to a rounded rect by swapping MaskShape::Rect for MaskShape::RoundedRect; the rest of the composition is unchanged.

Architecture decisions

  • Three RTs, not two. A scratch blur_rt holds the wholesale blurred copy; a scratch masked_rt holds the clipped overlay. We could fuse blur+clip into a single shader, but reusing the existing filter and clip pipelines keeps each primitive single-purpose and testable in isolation.
  • compose_over over BlitPipeline::REPLACE. A second blit pipeline with BlendState::ALPHA_BLENDING is needed so the masked overlay's transparent pixels don't punch holes in the base. The pipeline cache sees both as just two more entries in BlitPipeline.
  • Coordinates in NDC. region is in NDC ([-1, +1]²), matching the existing clip primitive. Pixel-space callers convert at their edge — keeps the renderer single-coord-system.

API

wisp::Renderer::apply_privacy_blur

Rounded privacy blur

Linear: AUT-21

AUT-21 generalizes the AUT-20 primitive: apply_privacy_blur now accepts any MaskShape, so MaskShape::RoundedRect { rect, radius } produces a privacy redaction with cinematic rounded corners.

The renderer code is unchanged from AUT-20 — same three-stage pipeline (blur → clip → compose). What changed is the API: the second argument went from region: Rect to shape: MaskShape. AUT-20's call site becomes MaskShape::rect(region) (zero-cost — the Rect constructor just wraps the rect in the enum). AUT-22's strength slider, AUT-23's solid redaction, and AUT-30/-34/-35's circle/ellipse/freehand variants will all plug in here without further pipeline work.

Architecture

  • One primitive, many shapes. MaskShape is an enum (Rect, RoundedRect; future: Circle, Ellipse, Path). The clip pipeline's WGSL switches on the enum's variant via uniform buffer data, so adding a shape is "new variant + SDF formula" — no new pipeline, no new bind group layout.
  • Bounding-rect-but-corner is a strict cutout. For rounded shapes we want the corner pixels to remain perfectly equal to base (no partial alpha leak). The SDF AA band is one output pixel wide; tests sample a few pixels in to escape the band and confirm bit-exact base bytes there.

API

wisp::Renderer::apply_privacy_blur — now generic over MaskShape.

Privacy blur strengths

Linear: AUT-22

BlurStrength adds the renderer-data API for "how strong." The PrivacyBlur struct now bundles a MaskShape (where) with a BlurStrength (how strong); a new Renderer::apply_privacy_blur_data(blur, base, output) consumes that struct directly so the editor doesn't shuttle raw f32 radii.

#![allow(unused)]
fn main() {
use wisp::{BlurStrength, PrivacyBlur, math::Rect};

let blur = PrivacyBlur::rect(Rect::new(-0.5, -0.5, 1.0, 1.0))
    .with_strength(BlurStrength::Strong);
renderer.apply_privacy_blur_data(&app, &blur, &base, &output);
}

Variants:

VariantPixel radiusUse
Soft6Cinematic polish — shapes hint through.
Medium (default)12Balanced redaction — text unreadable.
Strong24Heavy redaction — wipes nearly all detail.
Custom(f32)clamped [0, 64]Escape hatch for custom requirements.

Architecture

  • Symbolic enum + numeric escape hatch. Editor projects persist BlurStrength::Soft (a name); the radius mapping can be retuned without breaking project files. Custom(f32) lets stories and tests pin an exact pixel value when they need determinism.
  • Builder-style overrides. PrivacyBlur::rect(r).with_strength(s) reads top-down without forcing callers to hand-construct the struct.
  • Default = Medium. Unconfigured blurs land at the balanced preset, matching the most common publish-safety case.

API

Solid redaction

Linear: AUT-23

Renderer::apply_solid_redaction(shape, color, base, output) is the trust counterpart to privacy blur. Instead of attenuating detail inside a shape (blur), it replaces every pixel inside the shape with an opaque color. Reuse of the same MaskShape enum means rect / rounded-rect / circle / ellipse / freehand-path all work identically to the blur primitive.

#![allow(unused)]
fn main() {
use wisp::{Color, MaskShape, math::Rect};

let region = Rect::new(-0.5, -0.3, 1.0, 0.5);
renderer.apply_solid_redaction(
    &app,
    MaskShape::rounded_rect(region, 0.12),
    Color::rgba_u8(20, 20, 20, 255),
    &base,
    &output,
);
}

Architecture

  • Same composition shape as privacy blur. Three RTs (fill, masked, output); the only difference vs apply_privacy_blur is step 1 — instead of running BlurFilter over base, we clear a scratch RT to the redaction color via LoadOp::Clear. Keeps the pipeline cache small (one extra clear pass, no new shaders).
  • Color → wgpu::Color is a four-line f64::from. Linear f32 inputs map directly onto the clear-color floats. No gamma curve to worry about because the renderer's output format is Rgba8Unorm (or Rgba8UnormSrgb for display, in which case wgpu does the gamma itself).
  • Use opaque colors. A non-1 alpha lets base show through, which defeats the trust use case. Tests pin the inside-region pixel to (R, G, B, 255) exactly.

When to choose redaction over blur

Privacy blur is polish — text becomes unreadable but shape and motion still hint through. Solid redaction is trust — no information leaks through. A future inspector should communicate this in copy: solid is the safe default for high-stakes content (API keys, passwords, customer IDs); blur is the polished default for visual privacy (faces, screen names, low-stakes URLs).

API

wisp::Renderer::apply_solid_redaction

Spotlight / highlight

Linear: AUT-28

Renderer::apply_spotlight(shape, dim_color, base, output) is the attention-guiding primitive. Pixels inside shape show through unchanged; pixels outside are blended toward dim_color. Reuse of the same MaskShape enum means rect / rounded-rect / future circle / ellipse / freehand all work identically.

#![allow(unused)]
fn main() {
use wisp::{Color, MaskShape, math::Rect};

let focus = Rect::new(0.18, -0.6, 0.55, 0.45);
renderer.apply_spotlight(
    &app,
    MaskShape::rounded_rect(focus, 0.06),
    Color::rgba(0.0, 0.0, 0.0, 0.7),
    &base,
    &output,
);
}

Architecture

Same composition as solid redaction with one bit flipped: the clip pipeline runs in apply_inverted mode. The WGSL invert is a uniform flag (invert: f32); same pipeline, no separate shader.

flowchart LR
    Fill["fill_rt<br/>(cleared to dim_color)"]
    Fill --> Clip["ClipPipeline<br/>(shape, invert=true)"]
    Clip --> Masked[masked_rt]
    Base[base] --> |"Blit::REPLACE"| Output
    Masked --> |"Blit::ALPHA_BLENDING (over)"| Output

Adding an invert flag to one shader is cheaper than building a second "outside-only" pipeline:

  • One shader, one bind-group layout, one pipeline cache entry.
  • The dispatcher's existing apply_clip keeps working as-is (default invert=false).
  • AUT-29 (dim-outside) becomes a thin wrapper that sets a stronger dim_color alpha; no further renderer work.

API

wisp::Renderer::apply_spotlight

Dim outside

Linear: AUT-29

DimOutside + DimStrength are the renderer-data API for the spotlight focus effect. DimStrength::{Light, Medium, Heavy, Custom(f32)} symbolically picks how dark the surrounding context becomes; apply_dim_outside_data is a one-line wrapper over apply_spotlight that passes a black overlay at the right alpha.

#![allow(unused)]
fn main() {
use wisp::{DimOutside, DimStrength, math::Rect};

let focus = Rect::new(-0.5, -0.55, 1.0, 0.7);
let dim = DimOutside::rounded_rect(focus, 0.08)
    .with_strength(DimStrength::Heavy);
renderer.apply_dim_outside_data(&app, &dim, &base, &output);
}
VariantOutside alphaUse
Light0.4Surrounding still legible.
Medium (default)0.7Visibly dimmed but recognizable.
Heavy0.9Cinematic spotlight-only.
Custom(f32)clamped [0, 1]Exact alpha for stories/tests.

Architecture

DimOutside is a thin shell over the AUT-28 spotlight primitive. The renderer code is unchanged from M-MASK.6; only the data API and the strength enum are new. The same observation as PrivacyBlur/ BlurStrength: editor projects persist a stable name (Light, Medium, Heavy), and the numeric alpha mapping can be retuned later without breaking project files.

API

Ellipse mask

Linear: AUT-34

MaskShape::Ellipse { center, half_extents } adds anisotropic elliptical cutouts. Unlike Circle, ellipse needs a real new SDF since the rounded-rect formula doesn't degenerate to an ellipse with unequal half-extents.

Note on MaskShape::Circle (Linear: AUT-30) — a circle is just an ellipse with half_extents.x == half_extents.y, so it's available through this same family. Earlier work also exposed MaskShape::Circle directly as a degenerate RoundedRect (half_extents = (r, r), corner_radius = r); both forms produce the same coverage texture. New code should prefer Ellipse or RoundedRect — they're the cleaner primitives.

#![allow(unused)]
fn main() {
use wisp::MaskShape;

let wide = MaskShape::ellipse(glam::Vec2::ZERO, glam::Vec2::new(0.85, 0.4));
let tall = MaskShape::ellipse(glam::Vec2::ZERO, glam::Vec2::new(0.4, 0.85));
}

Architecture

A shape_kind: f32 flag in the clip uniforms picks between the rounded-rect SDF and the new ellipse SDF in clip.wgsl. Same pipeline, same bind-group layout — one extra if in the WGSL plus one new SDF helper.

fn sdf_ellipse(p: vec2<f32>, half: vec2<f32>) -> f32 {
    let s = p / max(half, vec2<f32>(1e-6));
    let inside = dot(s, s) - 1.0;
    return inside * min(half.x, half.y);
}

Why a pseudo-SDF: the closed-form ellipse SDF involves a quartic root, expensive on every fragment. The scaled-quadratic (x/a)^2 + (y/b)^2 - 1 shares the same zero level set; multiplying by min(a, b) puts the result in roughly NDC distance units so the existing AA-band code (smoothstep over aa = 2/min(w, h)) still produces a ~1-pixel-wide edge — visually indistinguishable from the exact SDF for masking purposes.

All four mask primitives (apply_clip / apply_privacy_blur / apply_solid_redaction / apply_spotlight / apply_dim_outside_data) accept the new variant automatically — same pattern as MaskShape::Circle from M-MASK.8.

API

wisp::MaskShape::Ellipse

Freehand path mask

Linear: AUT-35

Renderer::apply_path_clip(points, foreground, output) and Renderer::apply_solid_redaction_path(points, color, base, output) add freehand-shape masking. Unlike the SDF shapes, paths are expressed as raw point lists; the WGSL runs a classic crossings-test point-in-polygon at every pixel.

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp::Color;

let star: Vec<Vec2> = (0i16..10).map(|i| {
    let r = if i % 2 == 0 { 0.85 } else { 0.35 };
    let theta = f32::from(i) * std::f32::consts::PI / 5.0
        - std::f32::consts::FRAC_PI_2;
    Vec2::new(theta.cos() * r, theta.sin() * r)
}).collect();

renderer.apply_path_clip(&app, &star, &foreground, &output);
renderer.apply_solid_redaction_path(
    &app,
    &star,
    Color::rgba_u8(20, 20, 20, 255),
    &base,
    &output,
);
}

Architecture

A new PathClipPipeline lives alongside the SDF-based ClipPipeline. The WGSL fragment shader (path_clip.wgsl):

  • Accepts up to 32 polygon vertices via a uniform buffer (array<vec4<f32>, 32> for alignment; only .xy used).
  • Runs the classic crossings test (Jordan curve theorem) — for each edge (a, b) of the closed polygon, tally crossings of the horizontal ray going +x from the fragment point. Odd parity = inside.
  • Multiplies the foreground sample's alpha by the inside test.

V1 is hard-edge (no AA). The rasterized output is integer-pixel accurate; AA can be retrofitted later via a distance-to-nearest-edge approximation in the same fragment shader.

The 32-point cap is a uniform-buffer size limit. Above that, the storage-buffer route (or polygon-segment-batched render passes) unlocks larger paths.

Why this isn't a MaskShape::Path variant

MaskShape is Copy — every variant holds POD data so the enum stays cheap to pass by value through the auto-dispatch path. A path needs an owned Vec<Vec2> (or Arc<[Vec2]>) to store the points, which would force MaskShape to drop Copy and adopt Clone. The ripple to existing call sites isn't worth it for a premium-shape expansion. Path-clip lives next to the SDF clip, accessed via its own dedicated public methods.

API

Dynamic mask textures

Linear: AUT-43

Renderer::generate_mask_texture(shape, w, h) and the path variant Renderer::generate_path_mask_texture(points, w, h) produce single-purpose coverage RenderTextures. Output stores (m, m, m, m) so consumers can sample as alpha (composition) or as RGB (display / debug). An inverted variant (generate_mask_texture_inverted) does the same with the mask flipped — useful for spotlight / dim-outside.

#![allow(unused)]
fn main() {
use wisp::{MaskShape, math::Rect};

let mask = renderer.generate_mask_texture(
    &app,
    MaskShape::rounded_rect(Rect::new(-0.5, -0.5, 1.0, 1.0), 0.2),
    256,
    256,
);
// `mask` is an RGBA8 RT with coverage in alpha (and mirrored in RGB
// for visual debugging). Sample `.a` for composition.
}

Architecture

This primitive owns only coverage. The existing apply_clip / apply_privacy_blur / apply_solid_redaction / apply_spotlight / apply_path_clip primitives still compute SDF + foreground sample in a single shader; M-DYN.1 introduces the separated path so future work can:

  1. Cache the mask (M-DYN.2 / AUT-44) — identical regions across frames don't regenerate.
  2. Reuse one mask across multiple effects — privacy blur and redaction over the same region don't run the SDF twice.
  3. Drive masks from vector data (M-VEC.3 / AUT-55) — vector shapes become alpha textures via the same primitive.
  4. Refactor the existing combined-shader primitives onto this model in M-VEC.4-6.
flowchart LR
    Shape[shape data] --> Pipeline[MaskTexturePipeline]
    Pipeline --> Alpha["alpha RT (m, m, m, m)"]
    Alpha --> Mask["sample.a × foreground = masked"]

Two pipelines under the hood:

  • MaskTexturePipelinemask_texture.wgsl. Same SDF math as clip.wgsl (rounded-rect / ellipse + degenerate cases for rect / circle), output vec4(m, m, m, m). No texture binding.
  • PathMaskTexturePipelinepath_mask_texture.wgsl. Same uniform-buffered point-in-polygon as path_clip.wgsl, hard edges for V1, 32-vertex cap.

API

Mask texture cache

Linear: AUT-44

The textures the cache stores — alpha RTs from M-DYN.1's MaskTexturePipeline. Static scenes (a fixed-position privacy crop, a stable webcam overlay) regenerate these once and reuse the cached Arc<RenderTexture> across every frame.

Renderer::cached_mask_texture(shape, w, h) and the inverted companion return Arc<RenderTexture>s memoized on (shape data, dimensions, invert flag). Identical inputs across frames return the same GPU texture instead of regenerating.

#![allow(unused)]
fn main() {
use wisp::{MaskShape, math::Rect};

// First call: GPU work. Subsequent calls with the same args: O(1).
let mask = renderer.cached_mask_texture(
    &app,
    MaskShape::rounded_rect(Rect::new(-0.5, -0.5, 1.0, 1.0), 0.2),
    256,
    256,
);

// Cache observability.
let (hits, misses) = renderer.mask_cache_stats();
println!("mask cache: {hits} hits, {misses} misses");
}

The cache backs M-VEC.4..6's vector-driven mask refactor: when a PrivacyBlur / DimOutside / vector mask is re-evaluated each frame, identical static regions produce the same key and reuse the existing texture.

Architecture

  • Keying. MaskKey bit-casts every f32 field in the shape (rect coords, radius, ellipse half-extents) to u32, then hashes the resulting [u32; N] representation. Exact-bit equality means a re-emitted identical shape value Just Works; NaN is handled consistently (canonical NaN bits are equal to themselves).
  • Eviction. FIFO at MAX_ENTRIES = 64. Backed by a HashMap + VecDeque; on overflow the oldest insertion is dropped. The cap bounds GPU memory at ~16 MB worst case (64 × 256² × 4 bytes), well under the budget on integrated GPUs.
  • Sharing. Returns Arc<RenderTexture> so the cache and the caller can both hold references. Drop the Arc and the cache may still hold the texture; clear the cache and any outstanding Arc still owns its data until the last reference goes.
  • Path masks not cached in V1. Hashing Vec<glam::Vec2> is non-trivial and freehand polygons typically mutate between frames. Use generate_path_mask_texture directly; manage caching at the call site if needed.

API

Vector shape model

Linear: AUT-53

Sample Vectors rendered as a contact sheet — rounded rect, circle, ellipse, freehand path, line stroke. Each tile is the output of Renderer::render_stage after building a one-node Vector scene. The shape data shown here is exactly what VectorShape + Vector carry — the rest of this chapter is the type surface that produces it.

VectorShape and Vector are Wisp's shared shape language. Every visual tool — masks, crops, highlights, callouts, cursor effects, later SVG import — drives off the same data so each tool doesn't invent its own geometry model.

This is not SVG support. It is Wisp's own deterministic primitive set, shaped to ship under our budget. M-VEC.13 may add a small SVG subset import (rect, circle, ellipse, path); full SVG (CSS cascade, animations, filters, external resources) is explicitly deferred — see AUT-71/72/73 guardrails.

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp::{Color, Fill, Vector, VectorShape, VectorStroke, math::Rect};

let circle = Vector::new(VectorShape::circle(Vec2::ZERO, 0.4))
    .with_fill(Fill::Solid(Color::rgba(1.0, 0.5, 0.0, 1.0)))
    .with_stroke(VectorStroke::new(0.02, Color::WHITE))
    .with_opacity(0.9);
}

Shape catalog

VectorShape is non-exhaustive. Initial variants:

VariantNotes
Rect { rect }Sharp-corner axis-aligned.
RoundedRect { rect, radius }Corner radius in NDC.
Circle { center, radius }Square bounding box.
Ellipse { center, half_extents }Anisotropic.
Path { points: Vec<Vec2> }Closed polygon, up to 32 vertices.

Future shape variants (M-VEC.10 path stroke commands, M-VEC.13 SVG import, M-VEC.16 feathered) will extend the enum without breaking callers (#[non_exhaustive]).

Compatibility with MaskShape

MaskShape (the analytic SDF subset shipped during M-MASK) and VectorShape overlap in their non-path variants. Conversion is explicit:

#![allow(unused)]
fn main() {
let v = VectorShape::rounded_rect(rect, 0.2);
let mask: Option<MaskShape> = v.as_mask_shape();
// Some(MaskShape::RoundedRect { rect, radius: 0.2 })
}

This is what M-VEC.4..6 will use to refactor existing mask primitives onto the vector model without rewriting the SDF shader.

For paths: VectorShape::Path carries an owned Vec<Vec2> so VectorShape is Clone, not Copy. That's the same reason MaskShape::Path was never added — see M-MASK.10's chapter. as_path_points() exposes the slice for the path-mask machinery.

API

Render vector primitives

Linear: AUT-54

Vector::add_to_stage(&mut stage, parent) (and the underlying to_graphics() lower-level method) make a Vector primitive renderable. Analytic shapes (rect / rounded-rect / circle / ellipse) convert directly into a Graphics node that the existing graphics pipeline draws.

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp::{Color, Fill, Transform, Vector, VectorShape, VectorStroke};
use wisp::math::Rect;

let root = stage.root();
let _id = Vector::new(VectorShape::ellipse(Vec2::ZERO, Vec2::new(1.0, 0.55)))
    .with_fill(Fill::Solid(Color::rgba_u8(120, 200, 130, 255)))
    .with_stroke(VectorStroke::new(0.06, Color::WHITE))
    .with_transform(Transform {
        position: Vec2::new(0.35, 0.0),
        scale: Vec2::splat(0.14),
        ..Transform::default()
    })
    .add_to_stage(&mut stage, root);
}

Architecture

This is a thin layer on top of the existing graphics rasterizer — no new pipeline, no new shader. Vector::to_graphics() walks the match arms and emits the matching Graphics::draw_* call:

VectorShapeGraphics call
Rect { rect }draw_rect(rect)
RoundedRect { rect, radius }draw_rounded_rect(rect, radius)
Circle { center, radius }draw_ellipse(center, Vec2::splat(radius))
Ellipse { center, half_extents }draw_ellipse(center, half_extents)
Path { points }None (deferred to M-VEC.10)

Fill and VectorStroke route into the existing Graphics::fill() and Graphics::stroke() setters. Transform lands on the produced Graphics::container.transform.

opacity is folded into fill + stroke colors as an alpha multiplier at conversion time. The renderer doesn't have a per-node opacity channel today; this is the practical equivalent for V1 and matches how PixiJS-style stacks have historically modeled "opacity on the graphics primitive" (multiply into the paint).

Path rendering — deferred

VectorShape::Path returns None from to_graphics() because the existing graphics pipeline doesn't draw paths as visible geometry. M-VEC.10 (AUT-62) lands move_to / line_to / quadratic / cubic / close / stroke / fill path commands. Until then, paths can only drive masks via the path-clip and path-mask-texture primitives.

API

Render vectors to alpha-mask textures

Linear: AUT-55

Output of the bridge: the M-DYN.1 alpha contact sheet — rect, rounded-rect, circle, ellipse, freehand star. Every tile is what generate_vector_mask_texture produces for the corresponding Vector. The bridge picks between the analytic SDF path and the freehand path mask based on the shape variant; the resulting alpha RT is byte-identical to what generate_mask_texture(MaskShape::…) or generate_path_mask_texture(&[Vec2]) would emit directly.

Renderer::generate_vector_mask_texture(vector, w, h) and the cached companion bridge a [Vector] primitive to the M-DYN.1 alpha mask texture path. The bridge is a single dispatch:

flowchart LR
    Vector --> Bridge{vector.shape}
    Bridge -->|analytic SDF| Mask["generate_mask_texture<br/>(MaskShape, w, h)"]
    Bridge -->|path points| PathMask["generate_path_mask_texture<br/>(&[Vec2], w, h)"]

Only vector.shape is consulted — fill / stroke / opacity / transform don't affect mask coverage.

#![allow(unused)]
fn main() {
use wisp::{Vector, VectorShape, math::Rect};

let vec = Vector::new(VectorShape::rounded_rect(
    Rect::new(-0.5, -0.5, 1.0, 1.0),
    0.2,
));

// Same alpha texture as `generate_mask_texture(MaskShape::RoundedRect{..})`
// — the bridge adds zero pixel-level difference.
let mask = renderer.generate_vector_mask_texture(&app, &vec, 256, 256);

// Cached version — analytic shapes go through the M-DYN.2 cache;
// path shapes bypass (V1 limitation).
let cached_mask = renderer.cached_vector_mask_texture(&app, &vec, 256, 256);
}

This is the bridge that M-VEC.4..6 will use to refactor existing mask primitives onto vector data:

  • M-VEC.4 (privacy blur) — Vector → mask texture → blur kernel composed only inside the mask.
  • M-VEC.5 (solid redaction) — Vector → mask texture → solid fill composed only inside the mask.
  • M-VEC.6 (rounded screen / webcam crops) — Vector → mask texture → clip pass over the recording surface.

Architecture

The dispatch reads VectorShape and routes:

  • Rect / RoundedRect / Circle / Ellipseas_mask_shape() → analytic SDF generator.
  • Path { points }as_path_points() → polygon mask generator.
  • Future variant (catalog is #[non_exhaustive]) → empty mask + a debug_assert! so we notice during development.

Cached version mirrors the same dispatch:

  • Analytic → cached_mask_texture(...) → goes through the M-DYN.2 cache.
  • Path → fresh generate_path_mask_texture(...) wrapped in Arc; bypasses the cache (paths can't currently be hashed; documented in M-DYN.2's chapter).

API

Privacy blur on vector masks

Linear: AUT-56

Output is pixel-identical to the M-MASK.3 rounded privacy blur — the same blur radius, the same rounded-rect coverage, just driven by a Vector instead of a MaskShape. The bridge picks the analytic SDF path; the cached mask texture is shared across frames and across effects.

Renderer::apply_privacy_blur_vector(vector, radius, base, output) drives the privacy-blur composition from a Vector instead of a MaskShape. Two practical wins:

  1. Path support. Freehand polygons can now drive privacy blur directly. Previously you had to call apply_path_clip against a manually-blurred RT and stitch the composition yourself.
  2. Cache reuse. The mask texture comes from cached_vector_mask_texture(...), so a static privacy region re-evaluated each frame skips regeneration.

The old apply_privacy_blur(shape: MaskShape, ...) API still works unchanged. Internally it now wraps the MaskShape in a Vector and forwards to apply_privacy_blur_vector. Output is byte-equivalent to the previous inline-clip implementation — the existing M-MASK.2 / M-MASK.3 / M-MASK.4 tests pass without modification.

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp::{Vector, VectorShape, math::Rect};

// New: path-driven privacy blur (impossible before M-VEC.4).
let diamond = Vector::new(VectorShape::path(vec![
    Vec2::new( 0.0,  0.6),
    Vec2::new( 0.6,  0.0),
    Vec2::new( 0.0, -0.6),
    Vec2::new(-0.6,  0.0),
]));
renderer.apply_privacy_blur_vector(&app, &diamond, 12.0, &base, &output);

// Old API still works — equivalent to apply_privacy_blur_vector
// with `Vector::new(VectorShape::rect(...))`.
renderer.apply_privacy_blur(&app, MaskShape::rect(rect), 8.0, &base, &output);
}

Architecture

The new pipeline replaces the inline clip.wgsl pass with a two-shader sequence: generate the mask once, then compose. Both existing M-MASK call paths route through it.

flowchart LR
    Base[base] -->|BlurFilter radius| BlurRT[blur_rt]
    Vector[vector] -->|generate_vector_mask_texture| MaskRT[mask_rt]
    BlurRT --> Compose["mask_compose pass<br/>blur_rt × mask_rt"]
    MaskRT --> Compose
    Compose --> MaskedRT[masked_rt]
    Base ===>|"Blit::REPLACE"| Output
    MaskedRT -->|compose_over| Output

New primitive: Renderer::apply_mask_to_texture(foreground, mask, output) is the public surface for the mask × foreground step. Documented separately so AUT-57 (solid redaction) and AUT-58 (rounded crop) reuse it.

The cost of an extra render pass per primitive call is offset by mask-cache hits. Static masks (the common case in screen recordings) regenerate exactly once per (shape, dims, invert) tuple.

Existing M-MASK chapters — preserved

The M-MASK.2 (rectangle privacy blur), M-MASK.3 (rounded privacy blur), and M-MASK.4 (configurable strength) chapters describe the public API — that surface is unchanged. The architecture sections in those chapters describe the previous inline-clip pipeline; that description is now historical. The active pipeline is the one documented above.

API

Solid redaction on vector masks

Linear: AUT-57

Output is pixel-identical to the M-MASK.5 solid redaction — same coverage, same fill color, just driven by a Vector instead of a MaskShape. Now also accepts path-shaped vectors (custom polygons, not just analytic shapes).

Renderer::apply_solid_redaction_vector(vector, color, base, output) drives the solid-redaction composition from a Vector. Like M-VEC.4's privacy blur, the existing apply_solid_redaction(shape: MaskShape, ...) API stays unchanged — internals route through the shared mask + compose path. The 4 existing M-MASK.5 tests pass byte-equivalent.

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp::{Color, Vector, VectorShape};

// Path-driven redaction (impossible before M-VEC.5):
let diamond = Vector::new(VectorShape::path(vec![
    Vec2::new( 0.0,  0.6),
    Vec2::new( 0.6,  0.0),
    Vec2::new( 0.0, -0.6),
    Vec2::new(-0.6,  0.0),
]));
renderer.apply_solid_redaction_vector(
    &app,
    &diamond,
    Color::rgba_u8(10, 30, 200, 255),
    &base,
    &output,
);
}

Architecture

Identical structure to M-VEC.4. Only step 1 (the what's inside) differs from privacy blur:

StagePrivacy blur (M-VEC.4)Solid redaction (M-VEC.5)
1BlurFilter(radius)clear fill_rt to color
2cached_vector_mask_texture(vec)same
3apply_mask_to_texture(blur, mask)apply_mask_to_texture(fill, mask)
4blit base + compose_oversame

Reuses the M-VEC.4 MaskComposePipeline and the M-DYN.2 mask cache without adding any new pipelines or shaders.

API

Clip + spotlight on vector masks

Linear: AUT-58

apply_clip_vector — rounded crop driven by a Vector.

apply_spotlight_vector — same vector data, spotlight composition.

Output is pixel-identical to the M-MASK.1 / M-MASK.6 equivalents — the vector path lets you drive both off the same Vector value, and adds freehand-polygon variants of each.

Closes the M-VEC.4..6 refactor zone. apply_clip (the rounded-crop foundation from M-MASK.1) and apply_spotlight (M-MASK.6) now route through the shared mask + compose path. Path-driven variants of both land here.

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp::{Color, Vector, VectorShape};

// Path-driven crop (impossible before M-VEC.6):
let diamond = Vector::new(VectorShape::path(vec![
    Vec2::new( 0.0,  0.6),
    Vec2::new( 0.6,  0.0),
    Vec2::new( 0.0, -0.6),
    Vec2::new(-0.6,  0.0),
]));
renderer.apply_clip_vector(&app, &diamond, &foreground, &output);

// Path-driven spotlight (impossible before M-VEC.6):
renderer.apply_spotlight_vector(
    &app,
    &diamond,
    Color::rgba(0.0, 0.0, 0.0, 0.7),
    &base,
    &output,
);
}

Architecture

  • apply_clip (and the new apply_clip_vector) — uses cached_vector_mask_texture + apply_mask_to_texture. Output is byte-equivalent to the previous ClipPipeline path.
  • apply_spotlight_vector — same pattern but with the inverted mask. Analytic shapes use cached_mask_texture_inverted; the path variant routes through path_clip.apply in invert: true mode (the cached path-mask doesn't have an inverted form yet — straightforward future extension).

Auto-dispatch path NOT refactored

render_stage auto-dispatches Container::clip = Some(MaskShape) through the inline ClipPipeline directly. That path is hot — called per dispatched node every frame — and the existing single- shader implementation already optimizes it. The vector-mask refactor adds an extra render pass per call which is fine for explicit primitives (offset by mask cache hits) but would be a regression on the hot path. The auto-dispatch keeps using the inline clip pipeline; explicit apply_clip calls go through the new path.

API

Export & copy-frame mask parity

Linear: AUT-27 · AUT-33

Headless export of a masked scene — the read_pixels PNG is byte-identical to what the preview surface shows for the same Stage. That parity is the contract this chunk pins down: every mask primitive (apply_clip, apply_privacy_blur, apply_solid_redaction, apply_spotlight, apply_dim_outside) produces the same pixels in preview and in export.

wisp has a single Renderer::render_stage entry point that drives both preview and headless export. Anything read_pixels returns is the same bytes the preview surface shows. AUT-27 and AUT-33 lock in that "same code path, same bytes" contract for every mask primitive.

What's tested

  • AUT-27 export parity (crates/wisp/tests/export_mask_parity.rs) — render the same scene to two distinct RenderTextures back-to- back and assert the byte slices are equal. Five primitives covered: apply_clip, apply_solid_redaction, apply_spotlight, apply_privacy_blur, and apply_clip_vector (the freehand-path variant).

  • AUT-33 copy-frame parity (crates/wisp/tests/copy_frame_mask_parity.rs) — render a masked scene, call read_pixels (the surface a future copy-frame button will sit on), and verify pixels INSIDE the mask region show the masked content while pixels OUTSIDE show the base unchanged.

Together: nothing about the mask primitives is preview-only. The exported file and the copied frame both honor every mask the user applies.

Why this matters

Trust. If apply_solid_redaction in preview shows a black box but the export omits it, a creator could publish a video with secrets visible. The renderer-first architecture eliminates the possibility by routing every output through the same code path — and these tests catch any future regression that tries to diverge them.

Architectural rule (from AUT-27)

The same scene composition function should drive editor preview, headless export, and copied frame/screenshot. Avoid separate preview-only mask code.

Already true today — Renderer::render_stage is the only path that produces frames. These tests guard against future drift.

Composition primitives with explicit masks

Linear: AUT-45 · AUT-46 · AUT-47 · AUT-48

Contact sheet of the alpha mask textures M-DYN.1 produces — rect, rounded-rect, circle, ellipse, freehand star. The M-DYN.3..6 composition primitives take one of these as input and combine it with a base RT to produce the final masked output (privacy blur, solid redaction, spotlight, clip). The mask generation is decoupled from composition so callers can share one alpha across multiple effects in the same frame.

The high-level mask primitives (apply_privacy_blur, apply_solid_redaction, apply_spotlight, apply_clip*) generate the mask texture internally. Sometimes you want the mask externally: share one alpha texture across multiple effects in the same frame without regenerating it three times.

These four explicit-mask companion primitives accept the mask as a parameter:

PrimitiveWhat's insideWhat's outsideIssue
compose_blur_through_mask(base, radius, mask, output)blurred basebase unchangedM-DYN.3 / AUT-45
compose_solid_through_mask(base, color, mask, output)solid colorbase unchangedM-DYN.4 / AUT-46
compose_dim_through_inverted_mask(base, dim_color, inverted_mask, output)base unchanged (mask=0)dim_color over base (mask=1)M-DYN.5 / AUT-47
apply_clip_vector(vector, fg, output) with Circle / RoundedRectwebcam frametransparentM-DYN.6 / AUT-48
#![allow(unused)]
fn main() {
use wisp::{MaskShape, math::Rect};

let region = Rect::new(-0.4, -0.4, 0.8, 0.8);
let shape = MaskShape::rounded_rect(region, 0.15);

// Generate the mask once.
let mask = renderer.generate_mask_texture(&app, shape, w, h);

// Use it for blur, redaction, and spotlight on the same frame.
renderer.compose_blur_through_mask(&app, &base, 12.0, &mask, &out_blur);
renderer.compose_solid_through_mask(
    &app, &base, Color::rgba_u8(20, 20, 20, 255), &mask, &out_redact,
);
let inverted = renderer.generate_mask_texture_inverted(&app, shape, w, h);
renderer.compose_dim_through_inverted_mask(
    &app, &base, Color::rgba(0.0, 0.0, 0.0, 0.7), &inverted, &out_spot,
);
}

Architecture

These primitives are the explicit-mask versions of the high-level methods. The high-level methods now route through them:

flowchart TD
    A["apply_privacy_blur(MaskShape, ...)"] --> B[wraps shape in Vector]
    B --> C["apply_privacy_blur_vector(Vector, ...)"]
    C --> D["cached_vector_mask_texture(Vector)"]
    D --> E[mask]
    C --> F["compose_blur_through_mask(<br/>base, radius, mask, output)"]
    E --> F

Same pattern for redaction and spotlight. The lower-level primitives are public so callers that already have a mask texture (maybe shared across effects, maybe loaded from a file, maybe from a custom shader) can use them directly.

M-DYN.6 — webcam crops are already there

apply_clip_vector(vector, foreground, output) already accepts VectorShape::Circle and VectorShape::RoundedRect. M-DYN.6 spec says webcam overlays should crop through the dynamic mask path — that's exactly what apply_clip_vector does (mask generated via MaskTexturePipeline, composed via MaskComposePipeline). No new primitive needed; the chapter exists to make the connection explicit. See M-VEC.6 chapter for details.

Tests

  • crates/wisp/tests/blur_mask_reuse.rs (M-DYN.3) — explicit-mask blur matches the high-level path; one mask shared across blur and apply_mask_to_texture produces correct output.
  • crates/wisp/tests/compose_through_mask.rs (M-DYN.4 + .5) — explicit-mask redaction and spotlight both match their high-level counterparts byte-equivalent.

API

Vector spotlight + inverse-dim

Linear: AUT-59

apply_spotlight_vector — focus a region while dimming the rest.

apply_dim_outside_vector — same shape, inverse coverage: attenuate everything outside the focus path.

apply_spotlight_vector (M-VEC.6) and apply_dim_outside_vector (M-VEC.7, this chunk) are the vector-driven entry points for guiding viewer attention. New in this chunk: apply_dim_outside_vector — the path-accepting companion to apply_dim_outside_data.

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp::{DimStrength, Vector, VectorShape};

let diamond = Vector::new(VectorShape::path(vec![
    Vec2::new( 0.0,  0.6),
    Vec2::new( 0.6,  0.0),
    Vec2::new( 0.0, -0.6),
    Vec2::new(-0.6,  0.0),
]));
renderer.apply_dim_outside_vector(
    &app,
    &diamond,
    DimStrength::Heavy,
    &base,
    &output,
);
}
MethodShape sourceStrength source
apply_spotlight(MaskShape, Color, ...)analytic SDFraw Color alpha
apply_spotlight_vector(Vector, Color, ...)any vector (incl. paths)raw Color alpha
apply_dim_outside_data(DimOutside, ...)analytic SDFDimStrength preset
apply_dim_outside_vector(Vector, DimStrength, ...) (new)any vector (incl. paths)DimStrength preset

Tests

crates/wisp/tests/dim_outside_vector.rs:

  • vector_dim_outside_matches_data_route_for_analytic_shape — byte-equivalence with the existing apply_dim_outside_data path.
  • vector_dim_outside_path_dims_around_polygon — diamond polygon preserves base inside, dims red to mid-range with DimStrength::Medium.

Highlight + callout primitives

Linear: AUT-60 · AUT-61

Highlight (M-VEC.8) and Callout (M-VEC.9) are preset constructors for the most common attention-guiding overlays. They produce plain Vectors — same data type as the rest of the M-VEC catalog — so they chain through every existing builder (with_transform, with_opacity, add_to_stage) and feed into vector storyboards (M-VEC.12).

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp::{Callout, Color, Highlight, VectorShape, VectorStroke, math::Rect};

let outline = Highlight::outline(
    VectorShape::rounded_rect(Rect::new(-0.7, 0.25, 0.45, 0.25), 0.06),
    Color::rgba_u8(255, 230, 80, 255),
    0.025,
);

let label = Callout::label_box(
    Rect::new(-0.6, -0.4, 0.6, 0.25),
    Color::rgba_u8(220, 170, 80, 230),
    Some(VectorStroke::new(0.012, Color::WHITE)),
    0.04,
);

let badge = Callout::badge(Vec2::new(0.55, 0.5), 0.085, Color::rgba_u8(220, 60, 50, 255));
}

Catalog

Highlight (M-VEC.8 / AUT-60)

ConstructorReturnsUse
outline(shape, color, width)stroke-only VectorGlowing border around buttons, fields.
filled(shape, color, alpha)filled Vector with multiplied alphaTranslucent highlight; underlying content visible.
pill(rect, color, alpha)rounded-rect with radius = h/2Menu-item / chip / inline emphasis.
glow(shape, color, width)stroke at 0.4× alphaCheap glow approximation. True Gaussian glow lands with M-DYN.7 feathering.

Callout (M-VEC.9 / AUT-61)

ConstructorReturnsUse
label_box(rect, fill, stroke, radius)rounded-rect with optional outlineAnnotation cards.
badge(center, radius, fill)filled circleNumbered step markers, dots.
caption_pill(rect, fill)wide rounded-rect, radius = h/2Single-line bottom captions.

Known gaps

  • Arrow / pointer-line callouts need stroke-along-path commands (M-VEC.10 / AUT-62). Once that lands, an arrow_to(from, to) constructor can join the Callout module without breaking changes.
  • True Gaussian glow depends on M-DYN.7 (AUT-49 P2) feathering. Until then, Highlight::glow is a wider-stroke approximation.

API

Vector path stroke + boolean ops

Linear: AUT-62 · AUT-63

Two M-VEC chunks shipped in one chapter — both extend the vector catalog with primitives that the rest of the M-VEC track depends on.

M-VEC.10 — Path stroke (AUT-62)

PathBuilder chains move_to / line_to / quad_to / cubic_to / close commands. Path::flatten(tolerance) does adaptive Bezier subdivision via the perpendicular-distance test, returning a Vec<Vec2> polygon. Path::stroke_to_graphics(width, color, tolerance) rasterizes the path as a stroked Graphics (one draw_line per flattened segment).

#![allow(unused)]
fn main() {
use glam::Vec2;
use wisp::{Color, PathBuilder};

let curve = PathBuilder::new()
    .move_to(Vec2::new(-0.6, -0.4))
    .quad_to(Vec2::new(0.0, 0.6), Vec2::new(0.6, -0.4))
    .build()
    .stroke_to_graphics(0.025, Color::rgba_u8(80, 200, 240, 255), 0.005);
}

Callout::arrow_to(from, to, width, color) is the first consumer — it's the path-stroke companion to the static Callout::label_box / badge / caption_pill from M-VEC.9.

V1 limitations:

  • Joins between segments are butt-style. Mitered / round joins await follow-up.
  • Path::flatten returns the raw polygon; consumers feed it into VectorShape::Path for masking. The 32-vertex MAX_PATH_POINTS cap in path_clip.wgsl still applies for masking; visible stroked rendering doesn't share that cap.

M-VEC.11 — Mask boolean ops (AUT-63)

Renderer::combine_masks(a, b, op, output) produces a new mask texture from two inputs and a MaskCombineOp:

OpResult
Unionmax(a, b) — pixel covered by either mask.
Intersecta × b — pixel covered by both.
Subtracta × (1 − b) — pixel covered by a but not b.
#![allow(unused)]
fn main() {
use wisp::{MaskCombineOp, MaskShape, math::Rect};

let a = renderer.generate_mask_texture(&app, MaskShape::circle(...), w, h);
let b = renderer.generate_mask_texture(&app, MaskShape::circle(...), w, h);

let out = RenderTexture::with_format(&app, w, h, format);
renderer.combine_masks(&app, &a, &b, MaskCombineOp::Intersect, &out);
}

Outputs are regular alpha-mask RenderTextures, so they flow into any downstream composition primitive (apply_mask_to_texture, compose_blur_through_mask, etc.).

Backed by mask_combine.wgsl: one shader, three op codes, branches on a uniform u32.

Lesson — WGSL vec3 alignment (CLAUDE.md)

The boolean-ops uniform struct shipped with a layout mismatch on first run — WGSL vec3<u32> is 16-byte aligned, so a struct { op: u32, _pad: vec3<u32> } is 32 bytes, not 16. The matching Rust struct must pad to the same size or wgpu rejects the bind group. Captured in CLAUDE.md "WGSL ↔ Rust uniform layout."

Vector primitive examples gallery

Linear: AUT-64

Closes the M-VEC track. Single-canvas overview of the catalog — glance to confirm the surface, then drill into the chunk chapter for any specific primitive.

RegionPrimitiveChapter
Top rowBasic shapes — Rect / RoundedRect / Circle / Ellipsevector-render
Top rightHighlight::outline ringvector-highlight-callout
MiddleCallout::arrow_to + PathBuilder::quad_tovector-path-stroke
LowerCallout::label_box, badge, Highlight::pillvector-highlight-callout
BottomCallout::caption_pillvector-highlight-callout

M-VEC track index

ChunkIssuePrimitive
M-VEC.1AUT-53Vector shape primitive model
M-VEC.2AUT-54Render vectors as scene geometry
M-VEC.3AUT-55Vector → alpha-mask texture bridge
M-VEC.4AUT-56Privacy blur uses vector masks
M-VEC.5AUT-57Solid redaction uses vector masks
M-VEC.6AUT-58Clip + spotlight use vector masks
M-VEC.7AUT-59Vector spotlight + inverse-dim
M-VEC.8AUT-60Highlight overlay primitives
M-VEC.9AUT-61Callout primitives
M-VEC.10AUT-62Path stroke commands
M-VEC.11AUT-63Mask boolean operations
M-VEC.12AUT-64This gallery (close)

M-DYN track index

ChunkIssuePrimitive
M-DYN.1AUT-43Dynamic alpha-mask texture primitive
M-DYN.2AUT-44Mask texture cache
M-DYN.3-6AUT-45/-46/-47/-48Explicit-mask composition primitives

Companion mask + export chapters

Path boolean ops — union, intersection, difference, XOR

wisp's path-booleans engine. In-house polygon clipping, no third- party crate, hyper-wgpu focused. Powers shape composition for callouts, cutouts, intersections, and ring highlights.

The full design rationale lives in _docs/adr/M-BOOL-backend.md; this chapter is the user-facing tour.

API at a glance

#![allow(unused)]
fn main() {
use wisp::path::{Path, PathBuilder};
use wisp::path::boolean::{combine, combine_n, BooleanOp, BoolOptions};

let circle_a: Path = /* … */;
let circle_b: Path = /* … */;

let union        = combine(&circle_a, &circle_b, BooleanOp::Union,        BoolOptions::default());
let intersection = combine(&circle_a, &circle_b, BooleanOp::Intersection, BoolOptions::default());
let difference   = combine(&circle_a, &circle_b, BooleanOp::Difference,   BoolOptions::default());
let xor          = combine(&circle_a, &circle_b, BooleanOp::Xor,          BoolOptions::default());

// N-ary fold:
let all = combine_n(&[&circle_a, &circle_b, &circle_c], BooleanOp::Union, BoolOptions::default());
}

Output is a Path — drops into every existing wisp consumer (Graphics::draw_path, VectorShape::Path for masking, headless PNG export, mdBook chapter screenshots).

When to use which op

Decision tree

  • Combining two callouts into one outline?Union.
  • Clipping a brand mark to a webcam bubble?Intersection.
  • Cutting a hole in a backdrop?Difference (A − B).
  • Ring / donut highlight?Xor (or Difference of two concentric paths — both work).

Versus the other composition primitives in wisp:

ToolWhenWhat you get
Path booleansYou want a new vector path that bounds the combined regionA Path you can fill, stroke, mask through, export
MaskShape::* (M-MASK)You want to clip an existing render-texture to a shapeA render-pass that gates pixels by an alpha mask
BlendMode::* (M-BLEND)You want to compose two layers' pixelsA GPU blend equation, no new vector geometry
apply_filter(...) (M-FILTER)You want a post-process (blur, drop shadow, …)A pixel-level effect, no new geometry

Boolean ops produce vector geometry; the others produce pixels. If your downstream is "fill the shape with a colour" or "use the shape as a mask for something else," booleans are the right tool.

Algorithm

flowchart LR
    A[Path A] --> Flat[Flatten Beziers<br/>via Path::flatten]
    B[Path B] --> Flat
    Flat --> Subs[Decompose into<br/>directed edges]
    Subs --> Inter["Find all<br/>pair-wise<br/>intersections"]
    Inter --> Split[Split edges<br/>at intersections]
    Split --> Class["Classify each fragment<br/>by inside/outside of A and B"]
    Class --> Rule["Op rule:<br/>retain fragments on<br/>output boundary"]
    Rule --> Stitch[Stitch tip-to-tail<br/>into closed contours]
    Stitch --> Out[Output Path]

The classification step asks, for each candidate edge fragment: "Is the region just-above this edge inside the output, and the region just-below outside (or vice versa)?" If yes, the edge is on the output boundary and we keep it. Per-op:

OpKeep iff
Union(in_A ∨ in_B) differs on the two sides
Intersection(in_A ∧ in_B) differs on the two sides
Difference(in_A ∧ ¬in_B) differs on the two sides
Xor(in_A ⊕ in_B) differs on the two sides

That single mechanism implements all four ops — no per-op clipping algorithm. New ops (e.g. Porter-Duff variants in M-BOOL.12) are one new op_rule line.

What's in v1, what's deferred

Shipped this PR (M-BOOL.0..7, .9, .13, .16):

  • In-house polygon-clipping engine.
  • Public API: combine, combine_n, BooleanOp, BoolOptions, FillRule.
  • Fluent builder on Path: .union_with, .intersect_with, .cut, .xor_with.
  • Curved-input support — QuadTo / CubicTo flatten internally at BoolOptions::flatten_tolerance. See Boolean ops on curved paths for the tolerance trade-off and per-subpath flattening helper (Path::flatten_subpaths).
  • All 4 primitive ops on closed polygons (with curve support via flatten).
  • 24 unit tests + 8 proptest cases covering geometry correctness, fluent equivalence, multi-subpath behaviour, empty-input edge cases, curve flattening, and algebraic laws (commutativity, associativity, identity, self-cancellation, De Morgan).
  • Re-exported at wisp::path::boolean::*.

Why Path, not Graphics, hosts the fluent ops

The M-BOOL.9 ticket originally spec'd Graphics::union_with / .intersect_with / .cut / .xor_with. Graphics in this codebase carries a draw-call list of SDF primitives (draw_rect, draw_ellipse, draw_line) — not a single Path — so there's no natural shape to feed into the boolean engine. The fluent methods live on Path instead, which is a single vector shape. The Graphics-level convenience returns when M-BOOL.10 / BooleanGroup introduces a scene-graph node that draws a baked Path directly.

Deferred follow-ups (each is a separate Linear ticket):

TicketWhat's deferredWhy this PR doesn't touch it
M-BOOL.8 / AUT-169FillRule::NonZero semantics + native holesNeeds winding-number tracking in the sweep; v1 honours EvenOdd only
M-BOOL.10 / AUT-171BooleanGroup scene-graph nodeRender-pass integration; depends on stable engine
M-BOOL.11 / AUT-172Bake boolean result → alpha-mask RenderTextureWires the engine into M-VEC.3's mask pipeline
M-BOOL.12 / AUT-173Complete Porter-Duff blend modesOrthogonal to the engine — lives in wisp::blend
M-BOOL.14 / AUT-175Cache + bake-to-mask for static booleansPerf opt — needs M-BOOL.11 first
M-BOOL.15 / AUT-176Four-circle Venn storybook storyNeeds wisp-storybook rendering of Path output (depends on M-BOOL.10 for ergonomics)
M-BOOL.17 / AUT-178criterion benchmarksAdds criterion dependency
M-BOOL.18 / AUT-179Offset/Minkowski, SDF, glyph booleansExplicitly P3 deferred per the ticket

API stability

The public surface (combine, combine_n, BooleanOp, BoolOptions, FillRule) is intentionally minimal and is guaranteed to be additive across the follow-up tickets. The fluent builder in M-BOOL.9 will sit on top of these functions without replacing them.

Known v1 limitations

  • Self-intersecting input polygons → undefined output. Matches Clipper2 v1 behaviour. Pre-validate via a future Path::validate helper (lands in M-BOOL.8).
  • Degenerate edges (length ≈ 0) silently discarded.
  • Coincident collinear edges kept once, label-union'd.
  • FillRule::NonZero is accepted but treated as EvenOdd until M-BOOL.8 ships winding-number tracking.

Fluent vs raw API

#![allow(unused)]
fn main() {
use wisp::path::Path;

// Raw `combine` form:
let result = combine(&a, &b, BooleanOp::Union, BoolOptions::default());

// Fluent form (same answer, chains nicely):
let result = a.union_with(&b);

// Chain three-way ops:
let highlight = a.union_with(&b).cut(&c);
}

Both forms are equivalent (fluent_*_matches_combine tests pin this); pick by readability. Use combine when you need custom BoolOptions (tolerance, fill rule); use the fluent methods for the common defaults.

Boolean ops on curved paths — flatten tolerance

The boolean engine (previous chapter) takes Path inputs but operates on polygons internally — combine() flattens every QuadTo / CubicTo to a polyline before clipping. This chapter covers the trade-off you control through BoolOptions::flatten_tolerance.

Why flatten at all

Path-boolean algorithms (Vatti, Greiner-Hormann, Bentley-Ottmann) are formulated over straight-line segments. Curves don't have a closed-form intersection algorithm that's efficient for general boolean ops — every robust implementation flattens first.

The cost is fidelity: the output is a polygon. Re-fitting curves to the result is a separate problem (out of scope; future work catalogued in M-BOOL.18).

Default tolerance: 0.5 device pixels

Reading the default

BoolOptions::default().flatten_tolerance == 0.005 in NDC units ([-1, 1]). At a 1920×1080 viewport, NDC 0.0051080 * 0.005 / 22.7 pixels in screen space along the long axis. That's the "smooth-at-arm's-length" tolerance — visible polygonization only appears under a zoom or on retina displays at 100% scale.

The default trades a few extra edges per curve for output that looks indistinguishable from a true curve at typical viewing sizes.

The trade-off table

ToleranceEdges/circle (radius ≈ 0.4 NDC)Use when
0.001~80–100Print export, retina screenshots, geometry under heavy zoom
0.005 (default)~24–32Live preview, 1080p compositions, storybook captures
0.05~10–12Low-res thumbnails, motion-blur sources (re-blurred anyway)
0.1~6–8Visibly polygonal — only when polygonization is the look

(Edge counts measured against the in-tree circle() test helper at the listed tolerances; see crates/wisp/src/scene/path/boolean.rs::flatten_tolerance_controls_output_resolution.)

Tolerance is per-flattening, not per-result

The flatten_tolerance controls how finely input curves get chopped before clipping. The clip output may carry fewer edges than the flattened input (collinear segments get merged) — so the final polygon vertex count is "inputs flattened at tolerance T, then simplified by the clip." Halving tolerance does not necessarily double output edges.

What's an acceptable tolerance for your use case

The shorthand: set tolerance to the smallest pixel size you want to be invisible.

  • Storybook captures at 1024×1024 → ~half a pixel ≈ NDC 0.001, but 0.005 is usually fine because the eye doesn't resolve sub-pixel curvature.
  • Mask textures for vector clipping → match the mask resolution. A 512×512 mask doesn't benefit from sub-NDC-0.005 precision; the mask quantises to its own grid first.
  • Export-quality booleans (PDF, SVG-out, print) → 0.001 or tighter; the consumer may re-stroke and zoom.

Gotchas

  1. Collinear input edges (rounded rect straight sides meeting another straight side at the same y) trip the engine's coincident-edge handling and can split a single union into multiple subpaths. Workaround: offset the inputs so their straight edges don't overlap, or compose with circles / pure curves where edges are never collinear.
  2. Self-intersecting input after flattening → undefined output (same as v1). Keep input convex or pre-validate.
  3. f32 precision — at NDC < 1e-6, two segments that should be coincident may drift by less than f32::EPSILON and the engine can either merge or split them. Don't push tolerance below 0.0001.

What v1 ships

  • All four ops (Union, Intersection, Difference, Xor) on Bezier-containing inputs.
  • Per-subpath flattening — multi-subpath Path inputs preserve their disjoint regions through the op.
  • Three regression tests in boolean.rs:
    • curve_input_union_produces_curved_outline — two overlapping circles produce one contour with materially more edges than a square baseline.
    • curve_input_difference_carves_circle_out_of_circle — crescent shape from A − B keeps A's far side and carves B's centre.
    • flatten_tolerance_controls_output_resolution — tighter tolerance produces strictly more edges than a looser one.

The public Path::flatten_subpaths(tolerance) -> Vec<Vec<Vec2>> helper exposes the same per-subpath flattening the engine does internally, for consumers that need to inspect the polyline form without running a full boolean op.

Deferred

Wisp text architecture

Linear: AUT-75

Wisp owns the text data model. App, editor, project state, and storybook code never see cosmic_text::* or glyphon::* types — they see WispText, WispTextStyle, and a few related value types. Backends (WispTextEngine + WispTextRenderer) plug in behind this trait surface; the project format and inspector controls stay backend-stable when we swap or upgrade them.

Type relationships

sequenceDiagram
    participant Caller
    participant Engine as WispTextEngine
    participant Layout as Box&lt;dyn WispTextLayout&gt;<br/>(backend-specific, opaque)
    participant Renderer as WispTextRenderer<br/>(GPU side)

    Caller ->> Engine: layout(WispText { content, style,<br/>position, max_width_ndc })
    Note over Engine,Layout: line-break, shape,<br/>per-glyph metrics
    Engine -->> Caller: Box&lt;dyn WispTextLayout&gt;
    Caller ->> Layout: metrics() (Caller-visible)
    Caller ->> Renderer: draw(layout, text)
    Note over Renderer,Layout: renderer downcasts<br/>to concrete backend type
    Renderer ->> Layout: read backend-private buffer

For most callers WispText is the only type they construct directly. Backends are selected through whichever method on Renderer consumes the text — today the M0.15 bitmap path; after M-TEXT.4 it'll be AtlasText; after M-TEXT.3 it'll also be FlexibleText.

Value types

TypePurpose
WispFontHandle(u32)Opaque font reference. Atlas backend treats it as a slot id; Cosmic Text backend treats it as a Family + Weight + Style query.
WispFontWeightThin / Light / Regular / Medium / Bold / Black / Custom(u16) clamped to [100, 900]. CSS-compatible.
WispFontStyleNormal / Italic.
WispTextAlignLeft / Center / Right.
WispTextStyleBundle: font + size_ndc + color + line_height + letter_spacing + weight + style + align.
WispTextMetricsLayout output: line_count, max_width, total_height, baseline.
WispTextThe user-facing primitive: content + style + position + optional wrap.

WispTextStyle exposes a builder (with_font, with_size, with_color, with_weight, italic, with_align) so callers can assemble styles inline. WispText::new(content) defaults to white, size_ndc = 0.06, line height 1.2, regular weight, normal style, left-aligned, no wrap.

Trait surface

#![allow(unused)]
fn main() {
pub trait WispTextLayout: Debug + Send + Sync {
    fn metrics(&self) -> WispTextMetrics;
}

pub trait WispTextEngine {
    fn layout(&self, text: &WispText) -> Box<dyn WispTextLayout>;
}

pub trait WispTextRenderer {
    fn draw(&self, layout: &dyn WispTextLayout, text: &WispText);
}
}

Backends pair their own engine + renderer implementations and exchange a backend-specific concrete layout type behind the trait. The renderer side downcasts when needed; callers never see the concrete layout.

Send + Sync on WispTextLayout is required so caches (M-DYN.2-style) can hold layouts across frames. Engines and renderers may live behind &self so the renderer struct can hold them without interior mutability contention.

Backends

BackendEngine + RendererStatus
AtlasTextbitmap font atlas, M0.15 eraM-TEXT.4 — repackages the existing Text node.
FlexibleTextcosmic_text layout + glyphon renderM-TEXT.2 + M-TEXT.3.

Future backends (e.g. MsdfText for resolution-independent glyph rendering, SvgText for vector outlines) drop in behind the same trait without touching app/editor code.

Why two backends

  • AtlasText preserves the M0.15 contract: bytemap atlases, one draw call per font, deterministic batch. Cheap, fast, fixed-size, works without external font files.
  • FlexibleText opens up real font fallback, BiDi, line breaking, shaping. Necessary for captions, callouts, and any user-typed text. Heavier per-frame, mitigated by M-TEXT.5 render-to-texture caching.

The boundary lets stories, tests, and the recorder pick the right backend per use case without a project-format change.

API

AtlasText vs FlexibleText

Linear: AUT-78

Wisp ships two text backends behind the WispTextEngine + WispTextLayout trait surface (M-TEXT.1 / AUT-75). Pick by use case. Both are first-class — neither replaces the other.

When to use which

Use caseBackend
Static labels / HUD overlays / FPS countersAtlasText
Watermarks (a logo line, a fixed credit)AtlasText
Thousands of identical glyphs per frame (debug, telemetry)AtlasText
Captions on recorded clipsFlexibleText
Callout / annotation text the user typesFlexibleText
Anything needing fallback fonts, BiDi, CJK, ligaturesFlexibleText
Anything needing weight / italic to actually change the rasterizationFlexibleText

Rule of thumb: AtlasText for things the codebase puts on screen. FlexibleText for things the user types.

Comparison table

PropertyAtlasTextFlexibleText
Layout enginefont-cell metric walkcosmic_text
Rasterizationbitmap atlas (font8x8 today)glyphon (sub-pixel, per-frame)
Word wrap (text.max_width_ndc)❌ ignored — only \n breaks
BiDi / shaping
Weight / italic respected❌ ignored at layout
Letter spacing✅ (style.letter_spacing_ndc)
Alignment✅ Left / Center / Right
Line height✅ (style.line_height × size_ndc)
Color✅ solid tint (style.color)✅ + per-span (M-TEXT.13)
Non-ASCII❌ silently dropped
Font fallback
Performance per frameO(glyphs), one atlasO(glyphs) + cache lookup
Determinism for snapshot tests✅ pixel-perfect✅ once cached
Project-format storageWispText + style.font slotWispText + style.font query
GPU memoryone 128×128 atlasdynamic, glyph-cache sized
External font files❌ embedded✅ system + bundled

Layout semantics — AtlasText

The atlas backend treats style.size_ndc as a cell side length in NDC. Every font8x8 cell is square, so:

glyph width  = size_ndc
glyph height = size_ndc
horizontal advance = size_ndc + style.letter_spacing_ndc
line step  = size_ndc * style.line_height

Lines are split on \n. text.max_width_ndc is ignored — soft-wrapping isn't part of AtlasText (use FlexibleText). Codepoints absent from the bitmap atlas (anything ≥ 128) are silently dropped; this matches the M0.15 scene::Text node behavior.

style.weight and style.style (italic) don't change the rasterization — there's only one atlas. The fields are accepted so a single WispTextStyle can be authored in code that may switch backends later. FlexibleText will honor them.

Trait surface alignment

AtlasTextEngine implements WispTextEngine and produces an AtlasTextLayout (which implements WispTextLayout). The concrete AtlasTextLayout::glyphs() method exposes the per-glyph NDC quad + atlas UVs to the renderer side without requiring a downcast — the trait metrics() keeps the dyn-friendly entrypoint.

The M0.15 scene::Text node + text_pipeline continue to drive the on-GPU draws today; AtlasTextEngine formalizes the layout half so M-TEXT.5 can route the same data through the upcoming render-to-texture path when text needs to participate in masks / filters / blends.

FlexibleText — Cosmic Text layout

Linear: AUT-76

FlexibleText is the styled / wrapped / shaped text path. It uses cosmic_text for layout (line breaking, BiDi, font fallback, shaping). The rasterization half lands in M-TEXT.3 / AUT-77 (glyphon) — this chapter covers the layout half only.

Trait surface

FlexibleTextEngine implements WispTextEngine; the layout it produces is a FlexibleTextLayout (which implements WispTextLayout). The cosmic-text Buffer is held inside FlexibleTextLayout as a crate-private field — it never leaves the wisp crate as a public type.

flowchart TD
    WispText -->|"engine.layout(text)"| Layout["FlexibleTextLayout<br/>{ buffer (private),<br/>metrics }"]
    Layout -->|"metrics()"| Metrics[WispTextMetrics]

The renderer (M-TEXT.3 glyphon) is a sibling crate-internal module — it reads buffer directly without a dyn downcast.

NDC ↔ pixels — reference basis

Cosmic Text is pixel-based; wisp is NDC-based. The engine adopts a reference height of REFERENCE_PX = 1000 pixels:

font_size_px   = style.size_ndc * REFERENCE_PX
line_height_px = font_size_px * style.line_height
glyph_x_ndc    = glyph_x_px / REFERENCE_PX

Picking 1000 px as the basis:

  • keeps numbers within f32 precision,
  • gives sub-pixel positioning headroom for size_ndc = 0.06 (= 60 px ≈ caption type),
  • matches what glyphon's atlas cache expects for typical desktop UIs.

The renderer (M-TEXT.3) re-scales to the actual target dimensions at draw time. This means the same FlexibleTextLayout can be drawn into any-size target without re-shaping — important for the RT cache (M-DYN.2-style) we'll layer in.

Style mapping

WispTextStyle fieldcosmic-text translation
size_ndcMetrics::font_size = size_ndc * REFERENCE_PX
line_heightMetrics::line_height = font_size * line_height
weightAttrs::weight = Weight(weight.value())
style (Normal/Italic)Attrs::style = Style::Normal/Italic
colornot consumed at layout time — applied per-glyph in M-TEXT.3
letter_spacing_ndcnot yet — cosmic-text doesn't expose tracking; glyph-level adjust in M-TEXT.3
alignlayered on at render time once line widths are known

Wrap behavior

text.max_width_ndcBuffer::set_wrapEffect
NoneWrap::Nonesingle line, only \n hard-breaks
Some(w)Wrap::Wordword-wrap at w * REFERENCE_PX pixels

Hard \n always breaks regardless. CJK / no-space scripts fall back to Wrap::WordOrGlyph-style behavior in cosmic-text — that's the engine's call, not ours.

FontSystem ownership

cosmic_text::FontSystem is !Sync. The engine wraps it in a Mutex so:

  • the engine itself is Send + Sync (verified by a compile-time assert_send/assert_sync test),
  • caches and the renderer can hold an Arc<FlexibleTextEngine>,
  • multiple threads can layout concurrently (one at a time, but without &mut-only borrow contention).

FlexibleTextEngine::new() calls FontSystem::new() which loads system fonts. Tests that don't want system-font dependence can use FlexibleTextEngine::with_font_system(custom) to inject a hand-curated Database.

FlexibleText — Glyphon WGPU rasterizer

Linear: AUT-77

This chunk lands the rasterization half of FlexibleText. The FlexibleTextEngine (M-TEXT.2) shapes a WispText into a FlexibleTextLayout (a cosmic-text Buffer); this chunk hands that buffer to glyphon to paint into a wgpu::TextureView.

The hero above is regenerated by cargo run -p wisp-storybook --bin wisp-export-text-screenshots. It loads three OFL-licensed font files (Inter Regular

  • Bold, and JetBrains Mono Regular) into a fresh cosmic-text FontSystem via FlexibleTextEngine::from_font_paths, then routes them through the engine + renderer to demonstrate per-text family selection (WispText::with_font_family), weight, color, and proportional-vs-monospace metrics — end-to-end in wgpu, no system fonts involved.

api

Why glyphon

Glyphon is the de-facto wgpu rasterizer for cosmic-text. It owns the glyph atlas (LRU, growable), the per-glyph instance buffer, and the draw pipeline; we provide the FontSystem and the target view. Building this ourselves would replicate ~800 LoC of carefully-tuned atlas-packing code without a corresponding upside.

The crate is small (≈ 5k LoC), license-clean (MIT/Apache-2.0), and pinned to =0.8.0 to match wgpu 24 + cosmic-text 0.12 at the API level (= not ^ because glyphon's wgpu version is exact, not semver-driven).

Shape — a sibling of Renderer, not a method on it

graph LR
    App[Application] --> Renderer["Renderer<br/>(sprites, graphics, masks, …)"]
    App --> Flexible["FlexibleTextRenderer<br/>(glyphon — opt-in)"]

Opt-in

FlexibleTextRenderer is not automatically constructed by Renderer. Callers wire it explicitly when they need flexible text. Apps that don't (the storybook smoke harness, simple sprite scenes) don't pay the glyphon atlas + pipeline cost.

Two reasons:

  1. Cost. Glyphon allocates its own atlas + pipeline (~few MB of GPU memory + shader compilation). Apps that never render flexible text (e.g. the storybook smoke harness) shouldn't pay it.
  2. Lifecycle. The renderer needs an Arc<Mutex<FontSystem>> handle from the engine so layout-time and rasterization-time glyph metrics agree. Wiring this through Renderer::new would couple two opt-in subsystems.

The cost: callers wire the engine + renderer explicitly. The benefit: zero footprint when unused.

Lifecycle

#![allow(unused)]
fn main() {
use wisp::application::{AppConfig, Application};
use wisp::text::{FlexibleTextEngine, FlexibleTextRenderer, WispText};
use wisp::color::Color;
use glam::Vec2;

let app = pollster::block_on(Application::new(AppConfig::default()))?;

let engine = FlexibleTextEngine::new();
let mut renderer = FlexibleTextRenderer::new(
    &app,
    wgpu::TextureFormat::Rgba8Unorm,
    engine.font_system_handle(), // shared FontSystem
);
renderer.set_resolution(width_px, height_px);

let layout = engine.layout_concrete(&WispText::new("Hello"));
renderer.draw(
    target_view,
    &[(&layout, Vec2::new(-0.5, 0.5), Color::WHITE)],
    /* clear = */ true,
);
}

set_resolution is sticky — call it once per resize, not per frame. draw accepts a slice of (layout, position_ndc, color) so multiple text spans share one glyphon prepare + draw call.

NDC ↔ pixel conversion

The engine shapes at a fixed REFERENCE_PX = 1000 basis (see FlexibleText layout). At draw time the renderer rescales:

left_px = (pos_ndc.x * 0.5 + 0.5) * target_width_px
top_px  = (0.5 - pos_ndc.y * 0.5) * target_height_px   // +y flip
scale   = target_height_px / REFERENCE_PX

Same FlexibleTextLayout can be drawn into any-size target without re-shaping — only the per-draw rescale changes.

Atlas hygiene

renderer.trim_atlas() evicts LRU glyph cells that weren't touched since the previous frame. Call between frames for long-running scenes (editor / playback); short-lived contexts (export burn-in) can skip it.

Blend mode

Glyphon's pipeline ships with normal alpha blending (pre-multiplied alpha → OneMinusSrcAlpha over destination). That satisfies AUT-77's "supports at least Normal blend mode" requirement. Additive / multiply / etc. modes are M-TEXT.13 territory and need either a glyphon fork or a render-to-texture detour (M-TEXT.5).

Tests

TestAsserts
renderer_constructs_against_default_appnew() returns a renderer wired against the default Application.
empty_draw_does_not_panicEmpty layouts list is a no-op (no glyphon prepare failure on zero areas).
draw_hello_paints_some_non_zero_pixelsLayout "Hello" → glyphon → RenderTexture → read pixels → at least one non-zero-alpha pixel. Catches the case where glyphon silently produces empty output (e.g. font system has no glyphs).

The smoke test is a pixel test, not a snapshot test, on purpose: system fonts vary by host (CI runners pick up Liberation Sans / DejaVu / Helvetica depending on platform), so a byte-for-byte snapshot would churn on every CI bump. "Some pixels are non-zero" catches the genuine regressions (glyphon broken, font system empty, atlas allocation failed) without flapping on cosmetic font swaps.

Known gaps (intentional)

  • Container transform + alpha. AUT-77 calls for "respects container transform and alpha." Today the renderer takes position_ndc + per-default Color; the transform / alpha inheritance flows through scene composition, which the renderer doesn't yet participate in. M-TEXT.5 (RT cache integration) brings this in by drawing into an intermediate RenderTexture and composing through the existing sprite pipeline — at which point transform + alpha are free.
  • render_stage participation. Same story — the renderer can be invoked between Renderer::render_stage calls today, but isn't a pass inside it. M-TEXT.5 makes the RT path the natural integration point.
  • WispTextRenderer trait impl. The crate-level trait was designed before glyphon's "give me a target view + resolution" shape was understood. Implementing it would require widening the trait (target view, resolution, batch mode) — deferred until a second backend exists that would benefit from a uniform trait surface.

Text render-to-texture path

Linear: AUT-79

TextTexturePipeline packages FlexibleTextEngine (layout, M-TEXT.2), FlexibleTextRenderer (rasterization, M-TEXT.3), and a FIFO-bounded TextTextureCache into one type that turns WispText into a sampled RenderTexture.

The story above is at crates/wisp-storybook/src/stories/s_text_texture.rs. It renders two pieces of text into separate RenderTextures and attaches each as a Sprite to the scene graph — the standard sprite pipeline batches the draws.

api

Why this chunk exists

M-TEXT.3 deferred container transform + alpha inheritance and render_stage participation to M-TEXT.5. With text now a sampled texture, those concerns become "what every other sprite already does":

Concern (deferred from M-TEXT.3)M-TEXT.5 resolution
Container transformSprite already inherits Container::transform
Alpha inheritanceSprite already multiplies tint × container alpha
render_stage participationSprite is part of the sprite pipeline, which render_stage already drives
Blend modes beyond NormalSprite-side blend modes apply to the text texture verbatim

Text-as-texture is also the prerequisite for M-TEXT.6 (text composes through masks, filters, blends, and export) — every primitive that accepts a RenderTexture (filter, mask, blend, export-frame) now accepts text.

Cache shape

TextTextureKey hashes everything that affects the rendered output:

  • content (the literal string)
  • font_family (Option<String>)
  • Style: size_ndc, color (per-channel bits), line_height, letter_spacing_ndc, weight, italic, align
  • wrap_width_ndc (Option<f32>)
  • Output dimensions: width_px, height_px

f32 fields hash by to_bits() so equality is exact. Cache is FIFO at MAX_ENTRIES = 64 entries — 64 × 512 × 256 × 4 bytes ≈ 32 MB upper bound. clear_cache() drops all entries; stats() reports (hits, misses) since construction.

NDC-coordinate vs sprite-UV conventions

Gotcha — +y flip

Glyphon writes the texture with +y down (top of the texture is row 0). The wisp sprite pipeline samples with +y up (NDC convention). To display a text texture upright through a sprite, set scale.y negative — standard render-target-as-texture idiom. Without this, the text renders upside-down.

#![allow(unused)]
fn main() {
let mut sprite = Sprite::from_texture(rt.as_texture());
sprite.container.transform.scale = Vec2::new(width_ndc, -height_ndc);
}

API surface

TypePurpose
TextTexturePipeline::new(app, format)system-fonts pipeline
TextTexturePipeline::from_font_paths(app, format, paths)deterministic-fonts pipeline
pipeline.render(app, &text, w_px, h_px) -> Arc<RenderTexture>render-or-fetch-cached
pipeline.stats() -> (hits, misses)cache instrumentation
pipeline.cache_len() -> usizeresident entries
pipeline.clear_cache()drop all entries
RenderTexture::as_texture() -> Texturewrap as sprite-friendly view

Tests

TestAsserts
first_render_records_a_miss_and_a_cache_entrycache miss + 1 entry
second_render_with_same_inputs_is_a_cache_hitsame Arc returned
changing_content_invalidates_cachenew entry
changing_style_invalidates_cachenew entry
changing_color_invalidates_cachenew entry
changing_wrap_width_invalidates_cachenew entry
changing_dimensions_invalidates_cachenew entry
changing_font_family_invalidates_cachenew entry
cache_evicts_at_capacityFIFO eviction at MAX_ENTRIES
clear_cache_drops_entries_and_refills_on_next_renderclear + recount
rendered_texture_has_non_zero_glyph_pixelssmoke: pixels exist

Eleven tests in crates/wisp/src/text/texture.rs.

Text composition — mask, filter, blend, export

Linear: AUT-80

With M-TEXT.5's TextTexturePipeline in hand, text becomes a RenderTexture — and that means it inherits the full composition surface of every other texture in the renderer.

Three text sprites over a warm backdrop:

  • Normal — baseline alpha composition through render_stage.
  • SubtractContainer::blend_mode = BlendMode::Subtract. The glyphs punch the backdrop out toward black.
  • Filtered — orange source text routed through ColorMatrixFilter::grayscale via Renderer::apply_filter before being attached as a sprite. The hue is gone in the output.

api

Composition surfaces

SurfacePathTest
render_stage participationSprite-of-text is a regular sprite.text_renders_through_render_stage
Offscreen renderingpipeline.render(app, &text, w, h) writes into a RenderTexture.(every test boots through this)
Non-Normal blend modessprite.container.blend_mode = ….text_with_multiply_blend_differs_from_normal
Filter chainsrenderer.apply_filter(&filter, &input_rt, &output_rt).text_filtered_through_color_matrix_grayscale_produces_gray_pixels
Headless / copy-frame exportrender_stageRenderTexture::read_pixels.text_present_in_headless_export_pixels
Mask clippingRenderer::apply_clip consumes a RenderTexture source — the text RT plugs in.(covered by M-MASK suite at the RT level)

The integration tests live in crates/wisp/tests/text_composition.rs.

What's not in this chunk

  • Glyph-level filter authoring (per-glyph blur radius, per-glyph stroke). That's M-TEXT.7 (stroke / outline) and M-TEXT.8 (drop shadow / glow). Both will reuse the texture-then-filter pattern shown here.
  • Advanced blend modes (Overlay, HardLight, SoftLight, …) that need the offscreen-pass slow path. Those work for sprites today; text sprites participate the same way. No new wiring needed.
  • Animated text reveal. M-TEXT.16 territory — orthogonal to the composition path.

Cache interaction

TextTexturePipeline::render caches by content + style + dims, so when a caption is unchanged across frames the GPU work for the text layer collapses to a single sprite draw + filter dispatch. The cache key picks up font_family so swapping a face invalidates correctly.

Stroked / outlined text

Linear: AUT-81

Text in a screen recorder lives over whatever the user is recording — chaotic gradients, colored windows, video. A solid-color glyph disappears against any patch that's the same brightness. Stroking the text rescues it.

The "READ ME" caption stays readable across the pink + yellow split; the unstyled "no stroke" line below is only legible because the backdrop is dark.

api

How it works

sequenceDiagram
    participant Pipe as TextTexturePipeline
    participant Tex as Arc&lt;RenderTexture&gt;
    participant Stroke as stroked_text_sprites
    participant Scene as Stage

    Pipe->>Tex: render(text, w_px, h_px) → cached RT
    Stroke->>Scene: 8 sprites, tinted stroke color, offset on a ring
    Stroke->>Scene: 1 sprite, tinted fill color, centered
    Scene->>Scene: render_stage draws sprite by sprite (scene order)

A single rendered text texture is stamped eight times in the stroke color at small offsets, then once more in the fill color at the center. No new shader, no glyph-outline path — the technique is the same one CSS uses for text-stroke.

Local NDC, not screen pixels

stroke_width_ndc is in the container's local NDC, before the container's transform applies. A 0.04 stroke radius inside a container scaled by (1.6, -0.4) reads as roughly 0.064 × 0.016 NDC at the viewport — small but visible at storybook export size. This is how typography work usually wants it: stroke thickness follows the text size.

API

#![allow(unused)]
fn main() {
use wisp::text::{stroked_text_sprites, StrokedTextLayer,
                 TextTexturePipeline, WispText, WispTextStyle};
use wisp::{Color, Container, WispFontWeight};

let pipeline = TextTexturePipeline::new(app, format);
let text = WispText::new("READ ME").with_style(
    WispTextStyle::default()
        .with_size(0.22)
        .with_weight(WispFontWeight::Bold)
        .with_color(Color::WHITE),
);
let rt = pipeline.render(app, &text, 1024, 256);

let layer = StrokedTextLayer {
    fill: Color::WHITE,
    stroke: Color::rgba_u8(10, 10, 18, 255),
    stroke_width_ndc: 0.04,
};

let mut container = Container::new();
container.transform.scale = glam::Vec2::new(1.6, -0.4);
let parent = stage.add_child(stage.root(), container).unwrap();
for sprite in stroked_text_sprites(&rt, &layer) {
    stage.add_child(parent, sprite);
}
}

A stroke_width_ndc of 0.0 skips the stroke and returns a single fill sprite — same call site for "no stroke" and "with stroke".

Geometry

Eight offsets, √2/2 increments

The stroke ring uses 8 offsets at 45° spacing — (1,0), (√2/2, √2/2), (0,1), …. At large stroke widths the ring's discreteness shows as faint corners; raise the offset count (e.g. 12 directions) or render at higher resolution to smooth it. For most caption / overlay use the 8-direction default is invisible.

Sprite/scene gotcha

Graphics renders after Sprites

The backdrop in the story PNG is a Graphics pipeline call, which the renderer batches after the sprite pipeline. A bare Graphics-only backdrop in the same stage will paint over the text. The story works around this by pre-rendering the backdrop into its own RenderTexture and attaching that as a Sprite — sprites respect scene-tree order, so the backdrop sprite (added first) stays under the text.

Text style presets

Linear: AUT-86

Seven curated WispTextStyle values for the recurring captions in a screen recording — picked once here so the editor, renderer, and export pipeline never argue about what a caption "is".

api

Preset reading order

Top to bottom: Section title, Caption, Callout, Keyboard shortcut, Step badge, Warning / privacy label, Watermark. Each row shows the preset's own style applied to its own name.

The seven presets

PresetWhen to useNotable knobs
SectionTitleHero / chapter headingsize 0.18, Bold, centered
CaptionBody copy under a clipsize 0.075, line-height 1.30
CalloutPull quote / asidesize 0.085, Medium, italic
KeyboardShortcutInline ⌘C / ⌃-space chipsize 0.055, slight letter-spacing
StepBadge"Step 3 of 7" labelsize 0.05, Bold, wide letter-spacing
WarningPrivacyLabelMask + redaction overlayssignal-red, Bold
WatermarkExport footersize 0.04, italic, alpha 0.63

API

#![allow(unused)]
fn main() {
use wisp::text::{TextPreset, WispText};

let style = TextPreset::Caption.style(); // -> WispTextStyle
let text  = WispText::new("Hello").with_style(style);
}

Or call a named accessor directly:

#![allow(unused)]
fn main() {
use wisp::text::presets;

let warning = presets::warning_privacy_label();
}

TextPreset::all() returns every preset in display order — used by the storybook gallery + tests so adding a new preset automatically shows up.

Why they're pure data

No allocation, no GPU dep

Each preset is a pub fn -> WispTextStyle — a Copy value-type with no runtime cost. The editor uses them to apply styles in O(1); the renderer reads the same struct for layout + rasterization. There's no "presets-as-strings" detour through a config file.

Test invariants

The test module enforces a few non-obvious contracts:

  • Every preset has positive size_ndc and line_height.
  • No two presets are byte-identical (every row in the gallery looks visibly different).
  • WarningPrivacyLabel has a red-dominant color (R > 0.8, G/B < 0.5) so a future tweak doesn't quietly desaturate the privacy signal.
  • Watermark carries alpha < 1.0 so an export accidentally rendering it at full opacity stays visible as a test failure.

Word-wrapped caption block

Linear: AUT-83

A composed scene fragment — wrapped text on top of a rounded background. The block measures the wrapped text at layout time and sizes the background to fit, so a one-line "Recording" and a three-line description both produce a clean rectangle with the same padding.

api

How the layout works

sequenceDiagram
    participant Caller as caller
    participant Block as CaptionBlock
    participant Engine as TextTexturePipeline.engine
    participant Out as CaptionLayout

    Caller->>Block: text, width, padding, radius
    Block->>Block: wrap text to (width − 2 × padding)
    Block->>Engine: layout_concrete(text).metrics()
    Engine-->>Block: total_height_ndc
    Block->>Out: Graphics rounded-rect (width × (text_h + 2×pad))
    Block->>Out: Sprite carrying rendered text-RT, inset by padding

Composition over inheritance

CaptionBlock isn't a new node type — it returns a Graphics + Sprite pair that the caller attaches to a Container. Position the container, and both background + text move together. Apply a Container::clip and the whole caption clips. No special-case code in the renderer.

API

#![allow(unused)]
fn main() {
use wisp::text::{CaptionBlock, TextPreset, TextTexturePipeline, WispText};
use wisp::Container;

let pipeline = TextTexturePipeline::new(app, format);

let block = CaptionBlock::from_text(
    WispText::new("Wraps inside a fixed width and pads cleanly.")
        .with_style(TextPreset::Caption.style()),
)
.with_width(0.85)
.with_padding(0.05)
.with_radius(0.06);

let layout = block.layout(app, &pipeline);

let mut container = Container::new();
container.transform.position = Vec2::new(-0.425, 0.4);
let id = stage.add_child(stage.root(), container).unwrap();
stage.add_child(id, layout.background);
stage.add_child(id, layout.text_sprite);
}

layout.height_ndc gives the actual block height so the caller can stack multiple blocks without overlapping.

Wrap behavior

Caller-set wrap wins

If the WispText already has .with_wrap(...) set, the block respects that width instead of width − 2×padding. Useful for cases where the caller wants the text to wrap tighter than the block's visual width (e.g., a tooltip with extra horizontal padding).

Alignment

WispTextStyle::align flows through unchanged — Center produces a centered caption, Left is left-aligned within the padded box. The text presets chapter has examples of each.

Snapshot determinism

Captions are pure data + cosmic-text layout + sprite render, with no randomness. The story is covered by story_smoke (no validation errors + visible pixels) and story_fingerprints (quadrant snapshot). Headless export reproduces the on-screen layout byte-for-byte at the same target format.

Drop shadow + glow on text

Linear: AUT-82

Text rendered to a RenderTexture, then run through wisp's existing DropShadowFilter. A glow is a drop shadow with offset = (0, 0) and a bright color — same pipeline, two parameter sets.

Left: drop shadow (offset 6 px, blur 5, dark 60% alpha). Right: glow (zero offset, blur 8, warm amber). The paper-white backdrop is a sprite so the shadow + halo are visible.

Pipeline

sequenceDiagram
    participant Pipe as TextTexturePipeline
    participant Stage as staging Stage
    participant InputRT as input_rt (linear)
    participant Filter as DropShadowFilter
    participant OutputRT as shadow_rt / glow_rt
    participant Scene as scene Stage

    Pipe->>Stage: text_rt bytes (glyphon, +y down)
    Stage->>InputRT: render_stage flip → +y up
    InputRT->>Filter: apply_filter(shadow params)
    Filter->>OutputRT: alpha-extract → blur → composite
    OutputRT->>Scene: Sprite (final composition)

The intermediate staging step exists because the text texture pipeline returns Rgba8UnormSrgb (display gamma), but the filter math is correct in linear (Rgba8Unorm) space. Rendering the glyph-RT into a linear input_rt once handles both the format swap and the +y flip cosmic-text needs.

Glow is just shadow with offset = 0

The DropShadowFilter does alpha-extract → blur → offset → composite-under. With offset = (0, 0), the blurred alpha falls directly under the source, producing a halo. Pick the color (a warm amber for highlight; a saturated red for danger; bright cyan for cyberpunk vibes), pick the blur, done. No second filter.

API

#![allow(unused)]
fn main() {
use wisp::DropShadowFilter;
use glam::Vec2;
use wisp::Color;

let shadow = DropShadowFilter {
    offset: Vec2::new(6.0, 6.0),
    blur: 5.0,
    color: Color::rgba(0.0, 0.0, 0.0, 0.6),
};
renderer.apply_filter(app, &shadow, &text_input_rt, &shadow_output_rt);

let glow = DropShadowFilter {
    offset: Vec2::new(0.0, 0.0),
    blur: 8.0,
    color: Color::rgba(1.0, 0.80, 0.30, 1.0),
};
renderer.apply_filter(app, &glow, &text_input_rt, &glow_output_rt);
}

Both output RTs become sprites; the caller composes them into the final scene.

Backdrop must be a sprite

The story renders the paper-white backdrop into its own RT and attaches it as a Sprite. A direct Graphics::draw_rect for the backdrop in the same stage would paint after the sprite text + shadow (Graphics renders after Sprites in render_stage), overwriting the entire shadow effect. The CLAUDE.md "Renderer batching / draw order" entry captures the rule.

When to use which

Lookoffsetblurcolor
Subtle drop shadow(2, 2)..(4, 4)2–4Color::rgba(0, 0, 0, 0.4)
Heavy drop shadow(6, 6)..(10, 10)5–10Color::rgba(0, 0, 0, 0.7)
Soft glow(0, 0)6–10warm bright RGB, alpha 1.0
Hard glow / outline-feel(0, 0)1–3saturated RGB, alpha 1.0
Privacy / danger pulse(0, 0)5–8red RGB, alpha 0.9

Drop shadow + glow combine: run two filter passes, attach both output sprites under a common Container with the shadow's sprite inserted first (so it renders below).

Callouts, badges, arrows

Linear: AUT-84

Five callout shapes — composed from existing Graphics primitives

  • CaptionBlock + text sprites. No new wisp types; the vocabulary is draw_rounded_rect, draw_ellipse, draw_line, plus the text-texture pipeline.

Caption pill (top), number badge (left), label box (center), pointer + target dot (right), arrow + "click" label (bottom).

api

Recipes

flowchart LR
    A[Caption pill] --> A1[CaptionBlock + large radius + warm fill]
    B[Number badge] --> B1[draw_ellipse + centered text sprite]
    C[Label box] --> C1[CaptionBlock + dark fill + small radius]
    D[Pointer + label] --> D1[CaptionBlock + draw_line + filled target ellipse]
    E[Arrow + label] --> E1[draw_line + two fan lines for arrowhead + text sprite]

Caption pill

#![allow(unused)]
fn main() {
let pill = CaptionBlock::from_text(
        WispText::new("Now recording")
            .with_style(TextPreset::Caption.style()),
    )
    .with_width(0.7).with_padding(0.04).with_radius(0.10)
    .with_background(Color::rgba_u8(220, 60, 80, 240));
}

Pill vs label

Large corner radius (≥ half height) on a CaptionBlock reads as a pill. Small radius (≤ 0.04) reads as a card. Same primitive, two silhouettes.

Number badge

#![allow(unused)]
fn main() {
let mut bg = Graphics::new();
bg.fill(Fill::Solid(Color::rgba_u8(45, 130, 220, 255)));
bg.draw_ellipse(center, Vec2::splat(0.08));
// Text on top, anchor at center.
let rt = pipeline.render(app, &n_text, 192, 192);
let mut sprite = Sprite::from_texture(rt.as_texture()).with_anchor(Vec2::splat(0.5));
sprite.container.transform.position = center;
sprite.container.transform.scale = Vec2::new(0.12, -0.12);
}

Arrow + label

No general path primitive yet

Wisp's Graphics exposes draw_rect, draw_rounded_rect, draw_ellipse, and draw_line — but no general draw_path. An arrowhead approximates with three short draw_line calls fanning from the tip. For richer geometry — bezier curves, complex arrowheads — see M-VEC.13 SVG path import or wait for a future Graphics::draw_path.

Blend + opacity

Every callout's Container::blend_mode and Container::alpha work unchanged — set them on the callout's container and the entire composition (background + text + arrow) participates. A faded callout (alpha = 0.6) reads as "hint" vs "primary".

What this unlocks

Callouts are the recording-overlay vocabulary — the artifacts the editor adds on top of captured video to teach what to look at. The recorder will use these shapes for cursor click pulses, keyboard chips, redaction labels, and step-by-step instructions.

Text as mask — fill, blur, spotlight

Linear: AUT-85

Text becomes a stencil: render glyphs to an alpha-coverage texture, feed any other render-texture as the foreground, and Renderer::apply_mask_to_texture clips the foreground to the glyph shape. The story below shows the same "WISP" mask with three foregrounds — a saturated color-band fill, a blurred backdrop, and a warm spotlight.

Top: gradient fill through text. Middle: blurred circles. Bottom: warm spotlight. One mask, three foregrounds.

Composition

sequenceDiagram
    participant Pipe as TextTexturePipeline
    participant Mask as mask_rt (RGBA, alpha = glyph coverage)
    participant Fg as foreground_rt (gradient | blur | spotlight)
    participant Compose as Renderer.apply_mask_to_texture
    participant Out as output_rt

    Pipe->>Mask: text → RT with alpha = coverage
    Fg->>Compose: pass through unchanged
    Mask->>Compose: clip with alpha
    Compose->>Out: foreground × mask.a

apply_mask_to_texture is the load-bearing primitive

This is the same function M-VEC.4..6 (privacy blur / redaction / spotlight composition) and M-MASK.2..4 (clip + path mask + mask combine) call. Text joins the list of valid coverage sources alongside analytic SDFs (RoundedRect, Ellipse), vector paths, and procedural masks. The renderer doesn't care where the alpha came from.

API

#![allow(unused)]
fn main() {
use wisp::text::{TextTexturePipeline, WispText, WispTextStyle};
use wisp::{RenderTexture, Texture, Color};

// 1. Render text to a coverage texture.
let text = WispText::new("WISP").with_style(
    WispTextStyle::default().with_size(0.95).with_color(Color::WHITE),
);
let text_rt = pipeline.render(app, &text, 256, 256);

// 2. Stage the text-RT into the renderer's format (linear, +y up).
let mask_rt = RenderTexture::with_format(app, 256, 256, format);
// (render text_rt onto mask_rt via a sprite with scale.y = -1)

// 3. Apply the mask to any foreground RT.
let output = RenderTexture::with_format(app, 256, 256, format);
renderer.apply_mask_to_texture(app, &foreground_rt, &mask_rt, &output);
}

Three pre-made foregrounds

Pattern: separable foreground + universal mask

The three foregrounds in the story are independent of the mask. Swap the gradient for a screen-grab, the blur for a stock photo, the spotlight for a vignette — and the same apply_mask_to_texture call just works. Caching the mask RT across frames (text is rarely re-rendered per frame) and only regenerating the foreground keeps the GPU cost in line with a regular textured sprite.

  • Fill — A Graphics of horizontal color bands rendered into a plain RT. Punchy, poster-style.
  • Blur — Three overlapping draw_ellipse circles in saturated colors, run through BlurFilter::new(radius: 8.0). Soft-focus reveal.
  • Spotlight — Solid warm field + a yellow ellipse + a slight dimming layer, finished with a zero-offset DropShadowFilter to soften. Glowy halo through the glyphs.

Lavapipe / CI guard

Blur filter loses the device on lavapipe

The blur + drop-shadow paths use the multi-bind-group filter pipeline that lavapipe (Linux CI's software Vulkan) loses the device on. The story checks WISP_SKIP_GPU_FILTER_TESTS and substitutes a sharp / no-filter foreground when it's set, so the smoke + snapshot tests still pass on the Ubuntu runner. macOS runners exercise the real filter path.

Pixel tests

Inside-/outside-glyph alpha is enforced upstream by the mask-compose pipeline's own tests (crates/wisp/tests/mask_compose_*.rs). This chunk verifies that text-driven masks reach the same primitive unchanged, by way of the storybook story's story_smoke (no wgpu validation errors, visible pixels) and story_fingerprints (quadrant snapshot — text shape must be stable per frame).

API reference

Generated rustdoc for the wisp crate is published alongside this book under ./api/. It includes every public type, trait, function, and constant in the renderer.

The rustdoc and this book share a deployment pipeline: every push to main builds both and pushes them to the same GitHub Pages site, so the link above is always in sync with the rendered chapters.

Searching the API

The rustdoc search bar (press S in the rustdoc UI) is faster than scrolling. Bookmark ./api/wisp/index.html if you find yourself there often.