Skip to content

Make Liquify redraw the brush, not the layer - #9

Merged
IAmJSD merged 1 commit into
mainfrom
liquify-incremental-warp
Aug 24, 2026
Merged

Make Liquify redraw the brush, not the layer#9
IAmJSD merged 1 commit into
mainfrom
liquify-incremental-warp

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Aug 24, 2026

Copy link
Copy Markdown
Member

Liquify felt laggy because every pointer move re-warped the whole layer: flatten the entire snapshot to f32, resample all of it, clone the tile map, write every tile back, and damage the layer rect so the compositor recomposited the canvas. On a 12-megapixel layer that was ~1 s per mouse move, and Enter cost another 1.3 s.

What changed

  • Render the dab's footprint, not the layer. A dab only moves the mesh vertices under the brush, and a destination pixel interpolates the four vertices around it, so nothing outside radius + one cell can change. Mesh::dab_rect returns that rect; warp_into resamples into the layer's own tiles instead of rebuilding them from a clone; damage shrinks to the same rect, so the compositor invalidates a handful of tiles.
  • Crop what the kernel is handed. src_token != 0 still means "the whole snapshot, keep it resident" — Puppet Warp's case, and the one an accelerated backend can use. No token now also means the plane is cropped to what the displacement over the region can reach. Mesh::subgrid does the same for the offsets, which were a megabyte and a half copied per pointer move.
  • Commit stops re-sweeping. The layer already holds the finished warp, so Enter moves the tiles out and puts the snapshot back. Tiles no dab reached are still Arc-shared with the snapshot and replace_layer_tiles diffs by pointer, so undo history carries only what moved.
  • The conversions either side of the kernel, which dominate a big dab once the sweep is gone: flatten goes tile-wise and parallel instead of a map lookup per pixel, warp_cpu puts its rows across the cores (both also help Puppet Warp and any GPU-declined fallback), and picking the tool up takes tile-granular bounds rather than scanning twelve megapixels of alpha.

Numbers

cargo run --release -p schist-tools-warp --example liquifybench (new):

layer brush pick up pointer move commit
1024×768 100 3.8 → 0.0 ms 50.7 → 0.9 ms 52.4 → 0.2 ms
4000×3000 100 51 → 0.1 ms 1045 → 0.8 ms 1356 → 1.0 ms
4000×3000 1000 97 → 0.3 ms 1318 → 21.5 ms 1320 → 0.3 ms

Testing

tests/incremental_warp.rs holds the invariant the speedup rests on: a stroke rendered dab by dab is pixel-identical to one sweep of the final mesh. Plus a dab touching nothing outside its footprint, a cropped plane reaching as far as the displacement does, and tool-level tests for damage size, commit/undo and Escape. Workspace tests and clippy are clean, and the GPU warp-parity tests still pass.

Also driven end to end through the headless MCP server on a real image — smooth warp, no seams or stale patches, undo restored the file byte-identically.

Notes

  • Liquify no longer goes near the GPU warp; a dab is far below what a round trip pays for. The comments naming it as the resident-source client now name Puppet Warp, which still is.
  • Pre-existing edge case, left alone: if another edit changes the layer while a Liquify session is open but idle, the snapshot goes stale. Before, the next dab reverted the whole layer; now it reverts only the dab's footprint.

🤖 Generated with Claude Code

Every pointer move re-warped the whole layer: flatten the entire
snapshot to f32, resample all of it, clone the tile map, write every
tile back, and damage the layer rect so the compositor recomposited the
canvas. On a 12-megapixel layer that was a second per mouse move, and
Enter cost another one and a third.

A dab only moves the mesh vertices under the brush, and a destination
pixel interpolates the four vertices around it, so nothing outside the
brush plus a cell can change. Render that rect and no more: `dab_rect`
gives it, `warp_into` resamples into the layer's own tiles rather than
rebuilding them from a clone, and the damage shrinks with it.

The source the kernel gets shrinks too. A token still means "the whole
snapshot, keep it resident" — Puppet Warp's case, and the one an
accelerated backend can use — while no token now also means the plane is
cropped to what the displacement over this region can reach. `subgrid`
does the same for the offsets, which were a megabyte and a half copied
per pointer move.

Commit then has nothing left to sweep: the layer already holds the
finished warp, so it moves the tiles out and puts the snapshot back.
Tiles no dab reached are still shared with it and the edit compares by
pointer, so history carries only what moved.

The rest is the two conversions either side of the kernel, which now
dominate a big dab: `flatten` goes tile-wise and parallel instead of a
map lookup per pixel, `warp_cpu` puts its rows across the cores, and
picking the tool up takes tile-granular bounds rather than scanning
twelve megapixels of alpha for a mesh fringe nobody warps.

Per pointer move, from `examples/liquifybench`:

    4000x3000, 100px brush    1045 ms -> 0.8 ms
    4000x3000, 1000px brush   1318 ms -> 21.5 ms
    1024x768,  100px brush    50.7 ms -> 0.9 ms

with Enter down from 1356 ms to 1.0 ms and picking the tool up from
97 ms to 0.1 ms.

Liquify no longer goes near the GPU warp — a dab is far below what a
round trip pays for — so the comments naming it as the resident-source
client now name Puppet Warp, which still is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@IAmJSD
IAmJSD merged commit c26eb12 into main Aug 24, 2026
3 checks passed
@IAmJSD
IAmJSD deleted the liquify-incremental-warp branch August 24, 2026 02:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant