Slider latency + WebGPU efficiency plan #
Status: implemented (2026-07-31) — Phases B–E landed in one pass; Phase A (baseline measurement) was skipped per the user's call (they smoke-test by hand). See the diff for details.
1. Diagnosis: why the slider effect "isn't applied correctly" #
The math is fine — the problem is latency and backlog in the render path. Every
OnInput slider event fires UpdatedDraftParam/UpdatedLayerParam →
renderNow → a RenderChain command. There is no coalescing: a fast drag
enqueues one full render per pointermove, and each render costs a full
GPU→CPU→GPU round trip:
Per GpuBackend.execute() tick (packages/frontend/src/gpu/backend.ts):
copyExternalImageToTexture— re-uploads the entire source image, which never changes during a drag.- Fresh allocations: srcTex, dstTex, resolution/frame/params buffers, bind group — destroyed at the end of the same tick.
readBack—copyTextureToBuffer+mapAsync(a hard GPU sync point), row unpadding on CPU,new ImageData,createImageBitmap(CPU).RenderedFrame→PaintCanvas→ 2DdrawImage— re-uploads the full image back to the GPU for display.
Net per tick: 2 full-image GPU transfers, 1 main-thread stall, 2 CPU pixel copies, ~8 GPU allocations, and a second full-image upload for presentation.
Because renders take longer than the event interval, work piles up in the
command queue. RenderedFrame's stale-stamp guard (update.ts) drops late
results, but the GPU work still ran — so the displayed image trails the slider
and the backlog persists after the drag ends. That backlog, not a shader bug, is
what reads as "the effect is not applied correctly."
2. Where the wins are #
| Lever | Est. impact | Cost |
|---|---|---|
| Kill per-tick readback; present via WebGPU canvas blit | ~10× on large images | Medium |
| Persistent textures/buffers (no per-tick allocation/re-upload) | slider tick → sub-ms | Medium |
| Coalesce in-flight renders (latest-wins) | fixes visible lag/backlog | Small |
| WGSL micro-opts (vectorized sRGB, integer hash, workgroup size) | ~1.2–1.5× shader | Small |
The WGSL from the SkSL port is mostly fine: single merged compute shader,
textureLoad/textureStore, one linear round trip — all correct. The
structural inefficiencies above dominate; the shader-text tweaks are the tail.
3. Phases #
All phases below are implemented. The key deliverables: no readback on the display path (compute + blit to a WebGPU canvas), image-scoped persistent resources (one source upload per image), one-render-in-flight coalescing (
renderPending+onSubmittedWorkDone), and the WGSL tweaks (vectorized sRGB, integer grain hash, 16×16 workgroups). The engine API changed:render/GpuBackendservice are gone —createRenderRequest(pure) + frontend-ownedGpuBackendwithexecute/snapshotreplace them.
Phase A — Measure (small, do first) #
- Dev-only timing overlay (or
performance.now()logs): per-renderexecute()time, renders/sec during a drag, main-thread stall time frommapAsync. - Baseline numbers on a 12MP image; keep them to compare after each phase.
Phase B — Kill the display readback (the big win) #
- The center canvas becomes a WebGPU canvas (
getContext('webgpu'),configure({ alphaMode: 'opaque', size: imageSize })). CSS transform pan/zoom keeps working unchanged. - Backend gains a blit render pipeline: fullscreen triangle (positions from
vertex_index, no vertex buffer), fragment doestextureSampleof the processed texture (free bilinear) → swapchain texture. execute()= compute pass into dstTex + blit pass dstTex → canvas texture in one encoder, one submit, no readback, no CPU pixels, no 2D canvas.- Empty-chain display: stop special-casing it.
render()currently fails withEmptyChainErroron an empty chain, butgenerateChainSource([])already emits a passthrough compute shader. Allow empty chains through the render path; deletePaintInitial,paintBitmap, and theImageDecodedempty-chain branch. - Export keeps a readback path (PNG needs CPU pixels), but it runs only on
the export button click — one-time cost. Drop
renderedBitmapfrom the model in favor of ahasRenderedflag;ExportImagetriggers render + readback.
Phase C — Persistent resources #
- Upload
srcTexonce per image; keep it for the image's lifetime. - Keep
dstTex+ blit resources per image size; recreate only on image change. - Cache params/resolution buffers and the bind group alongside the existing pipeline cache (keyed by shader source; resolution buffer per image).
- A slider tick becomes:
writeBuffer(params)+writeBuffer(frame)+ 1 compute dispatch + 1 blit dispatch + submit. Zero allocations, no sync.
Phase D — Coalesce renders (fixes the visible lag) #
- Model gains a
renderPendingflag.renderNowskips dispatch when a render is already in flight and just marks pending (revision still bumps). RenderedFramehandler: instead of dropping a stale result outright, ifstamp < model.revisionand a render is pending, re-dispatch with the latest chain+draft.- Result: at most one render in flight, always converging on the newest slider
value.
u_frame= stamp still increments, so grain keeps animating. - This bounds GPU queue depth to ~1–2 regardless of pointer event rate.
Phase E — WGSL micro-optimizations #
- Vectorize sRGB conversion (colorspace.ts): one
select+ onepowonvec3<f32>instead of three per-channel branches. Same math, lets the driver vectorize. - Replace the
sin()grain hash (grain.ts) with an integer hash (e.g. pcg/xorshift-derived) — no transcendentals. Keep the density weighting; A/B the noise character (it will change slightly). @workgroup_size(16, 16)(256 invocations) instead of(8, 8); make it a named constant, measure on discrete vs integrated GPUs, pick the winner.- Leave the uniform-alias lets,
u_resolutionuniform, and the single linear round trip as-is — negligible or inherent.
Phase F — Verify & close #
- Update
chain-source.test.tsfor the workgroup-size and empty-chain changes. - Run
@lutra/engineand@lutra/frontendtest suites. - Manual pass: drag a slider on a 12MP image; confirm no lag, low CPU during drag, export still correct.
- Update ADR
0001-compute-shader-pipeline.md(display path = compute + blit) and context.md (pipeline, coalescing, persistent resources).
4. Risks / notes #
- WebGPU canvas support tracks the existing device-acquisition gate; no new fallback surface.
- Blit
textureSamplegives free bilinear downscaling for zoom-out; zoom-in stays CSS-scaled (same as today's 2D canvas). bytesPerRow256-alignment requirement only matters for the export readback now.- happy-dom tests don't cover the backend; the backend stays unit-test-free, as today.