Files

196 lines
9.6 KiB
Markdown
Raw Permalink Normal View History

# Iced GUI 4K Canvas GPU Acceleration Plan
## Problem Analysis
The Iced GUI draws at **~10 FPS on 4K** (3840×2160) vs **~50 FPS on 800×600**. Your logs show:
| Phase | Cost per 4K frame |
|-------|-------------------|
| `render_composite_region` (engine) | 0.1ms ✅ |
| `composite_raw` dirty-region copy | 0.0ms ✅ |
| `ImageHandle::from_rgba` (33MB `Bytes::copy_from_slice`) | 3.85.2ms 🟡 |
| `view()` widget tree | 1012ms 🟡 |
| Iced wgpu layout + tessellation + present | **~7090ms** 🔴 |
| **Total inter-frame** | **80100ms** |
The engine itself is blazing fast (sub-1ms). The bottleneck is three-fold:
1. **Full 33MB CPU→GPU upload every frame**`ImageHandle::from_rgba(Bytes::copy_from_slice(&doc.composite_raw))` copies all 33MB even when only a 41×51 region changed
2. **Iced's `Canvas` widget re-tessellates** — the `draw_image` path goes through Iced's image pipeline which re-uploads the entire texture
3. **No partial GPU texture update** — unlike egui's `tex.set_partial()`, Iced's `ImageHandle` has no sub-region update API
### How egui solves this (reference)
egui uses `tex.set_partial([x0, y0], region_image, options)` in [render.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs#L211) — uploading only the dirty region (e.g., 41×51 = 8KB instead of 33MB). This is **~4000x less data** per stroke event.
## Proposed Changes
### Phase 1: `iced::widget::shader` — Direct wgpu Texture Pipeline (Primary Fix)
Replace the `Canvas` widget's `draw_image(ImageHandle)` approach with a custom `iced::widget::shader::Program` that manages its own `wgpu::Texture` and uses `queue.write_texture()` for partial sub-region updates.
> [!IMPORTANT]
> This is the **critical path** — it eliminates the 33MB CPU→GPU copy bottleneck and the Iced tessellation overhead for the canvas area. Expected improvement: **~70ms → ~1ms** per frame for dirty-region updates.
#### [NEW] [shader_canvas.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs)
Custom `iced::widget::shader::Program` implementation:
- **`CompositeShaderPrimitive`** — carries dirty region `[x0, y0, x1, y1]` + pixel data for that region only
- **`CompositeShaderPipeline`** — holds:
- `wgpu::Texture` (RGBA8, 3840×2160, created once, resized only on canvas resize)
- `wgpu::BindGroup` for the texture + sampler
- `wgpu::RenderPipeline` with a simple textured-quad vertex/fragment shader
- Uniform buffer for zoom/pan transform matrix
- **`prepare()`** — calls `queue.write_texture()` with only the dirty sub-region (e.g., 41×51×4 = 8KB)
- **`render()`** — single draw call: 6 vertices (textured quad), no tessellation
```
Frame cost estimate:
queue.write_texture(41×51 region) ≈ 0.01ms
Draw 1 quad ≈ 0.01ms
Total ≈ 0.02ms (vs current 75ms)
```
#### [NEW] [canvas.wgsl](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/canvas/canvas.wgsl)
Simple WGSL shader:
- Vertex shader: transforms quad vertices by zoom/pan uniforms
- Fragment shader: samples the composite texture with nearest-neighbor filtering
#### [MODIFY] [mod.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs)
- Replace `CanvasProgram` (which uses `canvas::Program``draw_image(ImageHandle)`) with the new `ShaderCanvasProgram` (which uses `shader::Program``queue.write_texture`)
- The `view()` function returns `iced::widget::shader(program)` instead of `iced::widget::canvas(program)`
- Keep the overlay (selection rect, vector preview, crosshair) as a small transparent `Canvas` widget layered on top via `iced::widget::Stack`
- Keep all mouse event handling unchanged
#### [MODIFY] [app.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/app.rs)
- `IcedDocument` changes:
- Remove `composite_handle_cache: RefCell<Option<(u64, ImageHandle)>>` — no longer needed
- Add `dirty_region: Option<[u32; 4]>` — stores the last dirty rect from `render_composite_region()`
- `refresh_composite_if_needed()` changes:
- Instead of bumping `render_generation` and letting `view()` rebuild `ImageHandle`, store the dirty region + the partial pixel data directly
- The shader primitive reads this data in `prepare()` and uploads only the dirty sub-region
- Remove `Bytes::copy_from_slice` call — the shader reads from `composite_raw` directly
---
### Phase 2: Checkerboard as Shader (Eliminate ~20K rects)
#### [MODIFY] [shader_canvas.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs)
Move the checkerboard from CPU-tessellated rectangles to the fragment shader:
```wgsl
// In fragment shader: procedural checkerboard
let checker = floor(uv / checker_size);
let is_light = (checker.x + checker.y) % 2.0 == 0.0;
let bg = select(dark_color, light_color, is_light);
let composite = textureSample(composite_tex, sampler, uv);
let final_color = mix(bg, composite, composite.a);
```
This replaces ~20K CPU-generated rectangles with zero geometry cost.
---
### Phase 3: Multi-Layer Performance (Already Handled)
> [!NOTE]
> The engine already handles multi-layer compositing efficiently via `below_cache` (composites layers below the active layer once at stroke start). The `render_composite_region()` returns a flat composite — the GPU only sees one texture regardless of layer count. No additional GPU work needed for multi-layer.
The primary bottleneck for multi-layer 4K documents is the engine-side `below_cache` computation at stroke start (~5-10ms for 10 layers at 4K), which is a one-time cost per stroke. During the stroke, only the active layer's dirty region is re-composited (~0.1ms).
---
### Phase 4: Additional Optimizations
#### [MODIFY] [Cargo.toml](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/Cargo.toml)
Add `wgpu` feature to Iced:
```toml
iced = { version = "0.13", features = ["tokio", "image", "svg", "canvas", "wgpu"] }
```
#### [MODIFY] [main.rs](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/main.rs)
Configure Iced wgpu settings for performance:
- Set `present_mode` to `Mailbox` (non-blocking vsync) instead of `Fifo` (blocking vsync)
- This eliminates the vsync wait that contributes ~16ms to the inter-frame time
#### [MODIFY] [Cargo.toml](file:///mnt/extra/00_PROJECTS/hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml)
Add `wgpu` and `bytemuck` dependencies for the shader pipeline:
```toml
wgpu = "23" # Match Iced 0.13's wgpu version
bytemuck = { version = "1", features = ["derive"] }
```
---
## Architecture Diagram
```mermaid
graph TD
A["Engine: stroke_to()"] -->|"dirty_bounds [x0,y0,x1,y1]"| B["render_composite_region()"]
B -->|"0.1ms"| C["composite_raw partial copy"]
C -->|"dirty region pixels only"| D["ShaderCanvasProgram::prepare()"]
D -->|"queue.write_texture(sub-region)"| E["wgpu::Texture<br/>(persistent, 3840×2160)"]
E -->|"1 draw call"| F["Fragment Shader:<br/>checkerboard + composite"]
F --> G["GPU Present"]
style A fill:#2d5a2d
style E fill:#2d2d5a
style F fill:#2d2d5a
style G fill:#5a2d2d
```
## Expected Performance
| Metric | Current (Canvas+ImageHandle) | After (Shader+write_texture) |
|--------|------------------------------|------------------------------|
| CPU→GPU upload | 33MB full copy (~4ms) | 8KB dirty region (~0.01ms) |
| Tessellation | ~20K checker rects (~5ms) | 0 (procedural shader) |
| Iced layout overhead | ~70ms (widget tree + image pipeline) | ~2ms (shader widget) |
| **Total frame** | **~80-100ms (~10 FPS)** | **~5-10ms (~100+ FPS)** |
## User Review Required
> [!IMPORTANT]
> **Iced `wgpu` feature flag**: The `iced::widget::shader` module requires the `"wgpu"` feature in the Iced dependency. This is already the rendering backend Iced uses internally, but the feature flag exposes the `shader` widget API. This should not change binary size significantly.
> [!WARNING]
> **Phase 1 is large**: The shader canvas is ~400 lines of new code (shader primitive + wgsl + pipeline setup). The existing `CanvasProgram` (725 lines) will be simplified but the mouse event handling stays identical.
## Open Questions
1. **Overlay rendering**: Should selection rects, vector previews, and crosshair cursor be rendered:
- **(a)** In the same WGSL shader (pass selection rect uniforms → draw in fragment shader) — simpler, all in one draw call
- **(b)** As a separate transparent `Canvas` widget layered on top via `Stack` — keeps current code, two render passes
I recommend **(b)** for now because overlays are cheap (few lines/rects) and it keeps the shader simple. We can merge later.
2. **Canvas resize**: When the document size changes (e.g., "New Image" → 3840×2160), the wgpu texture must be recreated. Should we:
- **(a)** Pre-allocate the max texture size (e.g., 8192×8192 = 256MB VRAM)
- **(b)** Recreate the texture on resize (one-time ~1ms cost)
I recommend **(b)** — 256MB VRAM reservation is wasteful.
## Verification Plan
### Automated Tests
```bash
cargo build -p hcie-iced-gui 2>&1 | head -50
cargo test -p hcie-engine-api --test visual_regression
```
### Manual Verification
1. Launch `hcie-iced` with a 4K canvas (3840×2160)
2. Draw brush strokes — verify smooth visual feedback (target: 30+ FPS)
3. Zoom in/out — verify checkerboard + composite update correctly
4. Pan — verify smooth scrolling
5. Multi-layer test: 5+ layers at 4K, draw on top layer — verify no stuttering
6. Compare perf logs: `[perf] inter-frame` should show <20ms consistently