- Introduced `SharedCompositePixels` for stable full-canvas pixel storage, enabling efficient pipeline creation and recovery. - Added `TextureUpdate` struct for tightly packed dirty rectangle uploads, reducing unnecessary data copying. - Refactored `upload_dirty_region` to utilize `TextureUpdate`, improving performance by eliminating full buffer scans. - Implemented `encode_selection_texture` to generate an encoded R8 selection mask, optimizing selection rendering. - Updated shader to sample selection texture only once, reducing GPU workload. - Added comprehensive tests for texture updates, selection encoding, and performance diagnostics. - Documented design decisions and validation sequences to ensure future performance stability.
16 KiB
Iced Drawing Performance Regression Plan
Goal
Restore low-latency 4K drawing while preserving the protected tiled compositor, persistent wgpu texture, dirty-bound RefCell union, viewport mapping, and nearest-neighbor sampler. Add deterministic transfer/work-budget tests and a repeatable latency diagnostic so future UI changes cannot silently restore full-canvas work.
Historical Root Cause
The history contains three cumulative causes, not one isolated regression:
| Commit | Date | Finding | Scope |
|---|---|---|---|
266183a |
2026-07-12 | Introduced the persistent wgpu texture, but also introduced Arc::get_mut for the shader-visible full composite. During iced update(), the existing widget/primitive still owns another Arc, so uniqueness fails and the fallback clones the full 4K RGBA canvas. The GPU upload is partial, but CPU staging can still copy 33,177,600 bytes per refresh. |
All raster drawing; latent flaw in the original optimization |
b2c88e5 |
2026-07-17 | Added GPU selection rendering and retained/reworked the full-canvas Arc fallback. Every composite refresh clones and rescans the unchanged 8,294,400-byte 4K selection mask, marks it GPU-dirty, and uploads it again. The fragment shader also performs one center plus eight neighbor mask samples for every selected fragment, about 75 million R8 samples per frame for a full 4K selection. | Severe when drawing with an active selection |
7b4073e |
2026-07-21 | Changed watercolor from one raster dab per sample to roughly 7-37 dabs (core, satellites, splatter, blooms). At common 24-64 px sizes it usually performs about 28-37 dabs per interpolated sample. | Watercolor brush |
Supporting evidence:
git log -G 'Arc::get_mut'identifies266183a,b2c88e5, and later transform work as the ownership-sensitive composite path history.git blameattributes all nineselection_texturesamples incanvas.wgsland unconditional selection dirty marking tob2c88e5.git diff 7b4073e^ 7b4073e -- hcie-brush-engine/src/lib.rsshows the one-dab to multi-dab watercolor expansion.hcie-engine-api/tests/performance_stroke_4k.rsonly exercises a round brush and engine compositing. It is explicitly non-gating and excludes iced staging, selection synchronization, fragment cost, GPU payloads, and input-to-present delay.- The recorded engine baseline is approximately 18.8 ms average composite and 53 segments/s. It is useful as an engine reference, not as an iced end-to-end latency baseline.
- Preserve the unrelated current worktree edit that exposes
stroke_brush; never overwrite or revert it.
Concrete Transfer Design
Add hcie-iced-app/crates/hcie-iced-gui/src/canvas/texture_update.rs with two focused types:
SharedCompositePixels: stableArc<RwLock<Vec<u8>>>full-canvas backing storage used for pipeline creation/recreation and full uploads.TextureUpdate: a tightly packed immutable partial payload containing[x0, y0, x1, y1],bytes_per_row, andArc<[u8]>pixels.
The data flow will be:
refresh_composite_if_needed()callsrender_composite_region()exactly as today.- For a normal dirty rectangle, it copies only dirty rows into both existing
composite_rawandSharedCompositePixels; no copy-on-write and no full-canvas allocation occurs. - The existing
dirty_region: RefCell<Option<[u32; 4]>>continues unioning every update received beforeview(). canvas::view()consumes the union once and packs that final rectangle fromcomposite_rawinto oneTextureUpdate. This costs one additional dirty-area copy and avoids stale payloads when several Elm updates occur in one frame.CanvasShaderPrimitive::prepare()sendsTextureUpdatedirectly toqueue.write_texture()with no full-buffer scan, row extraction, or per-prepare allocation.- If shader storage is absent, dimensions changed, or
full_uploadis set,prepare()takes a short read lock onSharedCompositePixelsand creates/replaces the full texture. It does not also apply the partial payload because the shared backing already contains the latest pixels. - Normal
prepare()calls never lock the full buffer. The lock is held only for small row writes inupdate()or rare full texture creation/replacement, never across engine compositing and never longer than the correspondingqueue.write_texture()input borrow requires.
Expected ordinary 48x48 update budget:
- Dirty RGBA payload: 9,216 bytes.
- CPU copies: at most 27,648 bytes (engine scratch to
composite_raw, engine scratch to shared recovery backing,composite_rawto packed upload). - GPU upload: exactly 9,216 bytes.
- Forbidden ordinary-frame behavior: a 33,177,600-byte clone, texture recreation, or full upload.
Selection Design
Make selection rendering event-driven and reduce the shader to one mask sample:
- Keep the GUI's raw selection mask available for move/transform flows, but store it as
Option<Arc<Vec<u8>>>so shader/view construction is O(1). - Add a separate
Option<Arc<Vec<u8>>>encoded selection texture and aselection_model_dirtymarker. Continue usingselection_mask_dirtyonly for “encoded texture requires GPU upload.” - Add one synchronization helper that runs only after a selection mutation. It clones the engine/local raw mask once, computes exact bounds, and builds one R8 encoded texture:
0: unselected128: selected interior255: selected border using the existing eight-neighbor, alpha-greater-than-127 semantics
- Preserve current edge behavior at canvas boundaries by treating out-of-bounds neighbors as clamped/selected, matching the current
ClampToEdgeshader rather than introducing a new outer border. - Update
canvas.wgslto sampleselection_textureonce. Values above0.75receive animated black/white border color; values above0.25receive normal/quick-mask fill. - Animation remains uniform-only. Marching-ants ticks do not rebuild, clone, fingerprint, or upload selection data, and remain disabled while drawing.
- Remove obsolete CPU structures that became dead when
b2c88e5moved selection rendering to the shader:SelectionEdgeCache,marching_ants_edges,selection_fill_spans, their underscored overlay fields, andselected_spans(). Deletecanvas/edge_cache.rsafter confirming no remaining consumers. - Mark selection state dirty at every engine/local mutation family: rectangle, magic wand, lasso, polygon, select-all, combine mode, invert, grow/shrink/feather/border/smooth, load, clear/deselect, selection undo, move-start engine clear with local retained mask, transform apply/cancel, and menu/script paths.
- Keep conditional synchronization at the end of
refresh_composite_if_needed()so selection-only changes still reach the GPU even when the document composite is clean. The condition is a cheap boolean on normal brush refreshes.
Watercolor Design
Retain the richer watercolor appearance but impose a hard per-sample budget:
- Unlock
hcie-brush-enginewithunlock.sh hcie-brush-engine, make the change, and relock it withlock.sh hcie-brush-engine. - Refactor watercolor planning into an RNG-parameterized private helper so a seeded unit test can verify its maximum work and footprint.
- Budget at most eight raster dabs per interpolated sample: one irregular full-size core, up to two smaller satellites, up to four tiny splatters, and at most one probabilistic faint bloom.
- Keep all non-core dabs materially smaller than the core; do not permit multiple near-full-size core/bloom dabs per sample.
- Preserve pressure, mask, eraser, opacity, and hardness behavior.
- If the measured p95 still exceeds the budget after this cap, restore the pre-
7b4073eone-dab implementation. Do not weaken GPU/tile/composite protections to compensate for brush simulation cost.
Diagnostic And Regression Tests
1. Deterministic Iced Transfer Tests
Place unit tests beside texture_update.rs so private binary canvas code remains testable without expanding the public library API.
- Hold shader-side clones of
SharedCompositePixels, apply 120 48x48 updates to a 3840x2160 buffer, and assert the shared allocation identity remains stable. - Assert each packed payload length is
dirty_width * dirty_height * 4and no ordinary update is classified as full. - Assert cumulative CPU copy accounting is no more than three times cumulative dirty bytes and cumulative GPU bytes equal cumulative dirty bytes.
- Include a test-only reproduction of the old COW branch: hold a second
Arc, show thatArc::get_mutfails, and account for a 33,177,600-byte clone. This consistently demonstrates the regression without retaining legacy behavior in production. - Verify two or more dirty rectangles accumulated before
view()produce the exact union payload containing final pixels from all updates. - Verify first frame, resize, document replacement, and file load still select the full-upload path and contain valid complete pixels.
- Verify malformed/out-of-bounds rectangles are rejected or clamped safely without pointer arithmetic underflow.
2. Deterministic Selection Tests
- Encode empty, rectangular, irregular, feathered, and edge-touching masks; assert exact
0/128/255classification and bounds. - Synchronize one 4K mask, then process 120 normal drawing refreshes with
selection_model_dirty == false; assert zero additional raw clones, encodes, bounds scans, and selection texture uploads. - Replace and clear a selection; assert exactly one upload per mutation and no stale
has_selectionstate. - Add a shader contract test using
include_str!("canvas.wgsl")that asserts exactly onetextureSample(selection_texture, ...)occurrence. This deterministic gate catches reintroduction of per-fragment neighbor sampling.
3. Watercolor Work And Timing Tests
- Seed the private watercolor planner and assert every generated sample contains one core and no more than eight total dabs across small, normal, and large sizes.
- Assert planned satellite/splatter/bloom extents remain within the dirty-radius contract used by
stroke_brush.rs. - Add an ignored diagnostic benchmark for Round and Watercolor at 24, 64, and 128 px, warming up before collecting 100 samples and printing p50/p95/max.
- Gate only the deterministic maximum dab count and a generous catastrophic timing ratio; do not use a tight absolute wall-clock threshold in CI.
4. End-To-End Diagnostic Harness
Add an ignored iced drawing-pipeline unit test, runnable with --ignored --nocapture, that creates a 3840x2160 10-layer engine document and replays 120 pointer samples in four scenarios:
- Round, no selection.
- Round, full-canvas selection.
- Watercolor, no selection.
- Watercolor, full-canvas selection.
Discard the first 10 samples as warm-up. Report p50/p95/max separately for stroke, engine composite, GUI row copy, upload packing, selection synchronization, and total event processing, plus dirty bytes, CPU copied bytes, GPU payload bytes, full-upload count, and estimated sustainable FPS. This benchmark is diagnostic; deterministic byte/work tests are the CI gate.
For historical confirmation, run the standalone/reproducible portions in isolated temporary worktrees at 266183a^, 266183a, b2c88e5^, b2c88e5, 7b4073e^, and 7b4073e where those revisions build. Do not check out commits in the user's dirty main worktree. Static git diff/blame evidence remains authoritative if an old revision no longer builds.
File-Level Change Map
- Add
hcie-iced-app/crates/hcie-iced-gui/src/canvas/texture_update.rs: shared recovery backing, packed update type, byte accounting, deterministic tests, diagnostic test. - Update
hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs: register the module, consume dirty union, build packed payload, pass encoded selection texture, remove dead edge/span overlay fields. - Update
hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs: accept shared full backing plus optional packed payload; direct partial upload; rare full recovery upload; remove current region extraction allocation. - Update
hcie-iced-app/crates/hcie-iced-gui/src/canvas/canvas.wgsl: one-sample encoded selection branch. - Update
hcie-iced-app/crates/hcie-iced-gui/src/app.rs: document fields/constructors, partial row writes, full replacement paths, conditional selection synchronization and mutation markers. - Update
hcie-iced-app/crates/hcie-iced-gui/src/selection/state.rs: encoded overlay generation and tests; remove unused span extraction. - Delete
hcie-iced-app/crates/hcie-iced-gui/src/canvas/edge_cache.rsafter removing all consumers. - Update
hcie-brush-engine/src/lib.rs: bounded watercolor planner/renderer and internal deterministic tests. - Extend or add a focused engine benchmark without changing protected engine caching behavior.
Validation Sequence
- Run focused unit tests for transfer packing, dirty union, selection encoding, shader sample contract, and watercolor work budget.
- Run the ignored four-scenario diagnostic with
--nocaptureand save before/after numbers in the final report. - Run
cargo test -p hcie-engine-api --test visual_regression. - Run
cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture. - Run all
hcie-iced-guitests,cargo check -p hcie-iced-gui, andcargo clippy -p hcie-iced-gui. - Run focused brush-engine tests/checks, then confirm the crate is relocked.
- Launch iced in release mode with a 4K 10-layer document. Replay Round and Watercolor strokes with no selection and a full-canvas selection. Confirm no ordinary full upload, no repeated selection upload, correct dirty payload sizes, and materially improved p95 input processing.
- Capture and inspect an iced screenshot after the GUI change. Verify canvas alignment, nearest-neighbor output, selection tint/ants, brush cursor, and drawn pixels. Screenshot correctness complements, but does not replace, latency telemetry.
Acceptance Criteria
- A normal 48x48 4K drawing refresh uploads 9,216 bytes and performs at most 27,648 bytes of GUI CPU copies; it never clones/uploads 33,177,600 bytes.
- Multiple updates before one
view()preserve the complete dirty union and final pixels. - An unchanged active 4K selection causes zero mask clone/encode/upload work during drawing after initial synchronization.
- The selection shader contains one selection texture sample, not nine, while preserving tint, quick-mask color, and animated border appearance.
- Watercolor performs at most eight planned raster dabs per interpolated sample and shows no catastrophic p95 regression against
7b4073e^. - Round-brush output and the protected tile/composite/GPU mechanisms remain unchanged.
- Visual regression, focused tests, compile/lint checks, manual 4K replay, and screenshot inspection pass without missing tiles, coordinate drift, seams, stale selection state, or first-frame failure.
Risks And Controls
- Renderer-thread lock contention: normal partial uploads use immutable packed payloads and do not lock; full backing locks are limited to short row writes or rare recreation.
- Lost dirty regions: retain the existing
RefCellunion and consume only inview(); test multiple updates per frame. - Pipeline recreation with only a partial payload: initialize from
SharedCompositePixels, which always contains the latest full image. - Selection mutation coverage: centralize marking/synchronization and audit all 32 current selection-engine call sites found in
app.rs. - GPU timing variability: gate shader sample count and transfer volume deterministically; use runtime timings only for trend diagnosis.
- Watercolor visual change: use seeded planner tests and screenshot/visual regression checks; fall back to the known pre-regression renderer if the bounded richer version misses the latency target.