From a2264b130d47fbbd565226040bc28fd5b9626177 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 23 Jul 2026 23:57:03 +0300 Subject: [PATCH] 4K MULTI LAYEPERFORMANCE OK : SOL 5.6 HIGH feat(perf): add real-time canvas performance measurements - Introduced a new performance module for the iced canvas to measure input queueing, engine drawing, CPU compositing/staging, GPU upload, and renderer preparation costs. - Implemented a performance probe in the egui reference renderer for comparative diagnostics. - Added methods to record durations, byte transfers, and input timestamps, enabling detailed performance analysis. - Enhanced the CanvasShaderPipeline with a new method to upload full textures without rebuilding GPU resources. - Updated the main application to conditionally log performance diagnostics based on an environment variable. - Created a performance plan document outlining steps to measure and optimize drawing performance in the iced application. --- ...14204-iced-realtime-drawing-performance.md | 54 ++++ .../crates/hcie-gui-egui/src/canvas/mod.rs | 18 ++ .../crates/hcie-gui-egui/src/canvas/perf.rs | 181 +++++++++++ .../crates/hcie-gui-egui/src/canvas/render.rs | 14 +- hcie-iced-app/crates/hcie-iced-gui/src/app.rs | 176 ++++++++--- .../crates/hcie-iced-gui/src/canvas/mod.rs | 1 + .../crates/hcie-iced-gui/src/canvas/perf.rs | 280 ++++++++++++++++++ .../hcie-iced-gui/src/canvas/shader_canvas.rs | 84 +++++- .../src/canvas/texture_update.rs | 10 +- .../crates/hcie-iced-gui/src/main.rs | 9 + 10 files changed, 781 insertions(+), 46 deletions(-) create mode 100644 .kilo/plans/1784827014204-iced-realtime-drawing-performance.md create mode 100644 hcie-egui-app/crates/hcie-gui-egui/src/canvas/perf.rs create mode 100644 hcie-iced-app/crates/hcie-iced-gui/src/canvas/perf.rs diff --git a/.kilo/plans/1784827014204-iced-realtime-drawing-performance.md b/.kilo/plans/1784827014204-iced-realtime-drawing-performance.md new file mode 100644 index 0000000..6fa21ea --- /dev/null +++ b/.kilo/plans/1784827014204-iced-realtime-drawing-performance.md @@ -0,0 +1,54 @@ +# Iced Gercek Zamanli Cizim Performansi Plani + +## Hedef + +Aktif iced uygulamasini egui referansiyla ayni 3840x2160, 10 katman, 100 segment firca senaryosunda olcmek; input, motor, CPU staging, GPU upload ve frame pacing maliyetlerini ayirmak; kanitlanan iced darboğazlarini GUI katmaninda gidermek. + +## Mevcut Mimari ve Kararlar + +- egui immediate-mode akista pointer durumunu ve motor cagrilarini ayni UI gecisinde isler; aktif cizimde acikca `request_repaint()` kullanir. Dusuk ek yuku olan surekli frame yenilemesine dogal olarak uygundur. +- iced Elm/declarative akista her pointer olayi `Message -> update -> view -> diff/layout -> prepare/render` zincirinden gecer. Mevcut kod her hareket mesajinda senkron motor isi yapar ve gorunur kompoziti yeni pointer mesajinin gelmesine bagli 16 ms esigiyle yeniler. +- Aktif iced canvas GPU hizlandirmalidir: `canvas/shader_canvas.rs` kalici wgpu texture, tek quad draw call ve dirty-region `queue.write_texture()` kullanir. Eski tam-frame `ImageHandle` yolu `canvas/viewport.rs` icinde devre disidir. +- Her iki GUI ayni engine-side sparse tile ve dirty-region kompozit yolunu kullanir. GPU tile atlas yoktur, fakat normal firca guncellemesinde buna ihtiyac olmadan kismi texture upload yapilir. Olcum buyuk/full dirty region gostermedikce yeni tiled GPU renderer yapilmayacak. +- iced normal dirty update'te yalnizca kirli satirlari tasir. egui ise kismi GPU upload'dan once motorun tam 4K tamponunu yaklasik 33 MB kopyalar (`hcie-egui-app/.../canvas/render.rs:169-178`). Bu nedenle mevcut bulgular iced'deki sorunu salt GPU bant genisligiyle aciklamiyor. +- Mevcut makinede Intel Iris Xe, `i915`, donanim hizlandirmali OpenGL ve Intel Vulkan vardir; Vulkan ayrica llvmpipe listeler. Runtime adapter secimi olculmeden GPU varsayimi yapilmayacak. +- Engine ve korumali performans mekanizmalari degistirilmeyecek. Calisma `hcie-iced-app/crates/hcie-iced-gui` ile gerekli karsilastirma olcumleri icin `hcie-egui-app/crates/hcie-gui-egui` sinirinda kalacak. + +## Uygulama Adimlari + +1. **Tekrarlanabilir performans gozlemi ekle.** Her iki GUI'de varsayilan olarak kapali, ortam degiskeniyle etkinlesen hafif bir canvas performans probu ekle. Ayni adlarla su evreleri p50/p95/max ve sayac olarak raporla: pointer-event-to-update, brush/pen engine call, `render_composite_region`, dirty CPU row copy/pack, GPU prepare/upload, prepare araligi, dirty piksel/byte orani, full-upload sayisi ve stroke basina gorunur update sayisi. Iced `TextureUpdate` ile son input zamanini/generation'i shader `prepare` asamasina tasiyarak input-to-GPU-prepare gecikmesini olc; bunu gercek present gecikmesi olarak adlandirma. Raporu stroke sonunda tek satir/ozet halinde yaz ve hot path'te her olay icin `info` log uretme. + +2. **GPU backend'i kanitla.** Performans calistirmasinda wgpu adapter/backend adini kaydet veya wgpu baslangic loglarindan dogrula. Intel Vulkan yerine llvmpipe/software adapter secilmisse once backend/adapter tercihini donanim Vulkan'a sabitleyecek en kucuk iced baslangic ayarini uygula; donanim adapter zaten seciliyse renderer secimini degistirme. + +3. **Baseline'i ayristir.** 4K, 10 katman ve 100 segmentte uc isinmali tekrar al. Once engine'in mevcut `performance_stroke_4k` sonucunu kaydet, sonra egui ve iced GUI problarini ayni firca boyutu, spacing, zoom ve efekt/selection kapali durumda karsilastir. Ikinci matris olarak selection acik ve efektli katman senaryosunu ayri raporla. Darbogazi su sirayla siniflandir: software GPU, engine/composite, iced update/view scheduling, CPU staging, GPU upload. Optimizasyon oncesi metrik olmadan pipeline yeniden yazma. + +4. **Iced cizim ve frame zamanlamasini ayir.** `CanvasPointerMoved` hot path'inde brush/pen noktalarini motora isle, fakat her hareket mesajinda kompozit alma. Cizim surerken yaklasik 16 ms'lik gercek `iced::time::every` subscription'i ile ayri bir canvas-frame mesaji uret; bu mesaj dirty ise yalnizca canvas kompozit/staging yapar. Ilk dab ve release son kompoziti hemen yapmaya devam et. Mevcut olay-gelmesine-bagli `last_composite_refresh` esigini kaldir veya yalnizca duplicate tick korumasi olarak kullan. Timer sadece aktif stroke boyunca calissin; daha once pointer starvation olusturan hemen-hazir async donguyu geri getirme. + +5. **Kompozit hot path'ini panel cache isinden ayir.** `refresh_composite_if_needed` sorumluluklarini minimum iki kapsama bol: canvas-only ve document-metadata. Canvas-frame tick yalnizca engine dirty region, kismi CPU copy, dirty union ve gerekli selection senkronunu yapsin. `layer_infos`, layer thumbnail, tum history aciklamalari ve `thumb_gen` yenilemesini stroke release/undo/redo/layer islemleri veya kisa idle sonrasina ertele. Mevcut dirty flag temizleme sirasi nedeniyle thumbnail'larin stale kalmamasini saglamak icin render oncesi kirli/aktif layer kimligini yakala ve deferred metadata yenilemesinde kullan; cizim sirasinda tam layer pixel clone yapma. + +6. **Nadir full upload yolunu ucuzlat.** `CanvasShaderPrimitive::prepare` icinde ayni boyutlu `full_upload` talebinde texture, selection texture ve bind group'u yeniden yaratma. Boyut degismediyse mevcut texture'a tam `queue.write_texture()` uygula; `resize_texture` yalnizca gercek boyut/format degisiminde calissin. Normal partial-upload, `RefCell` dirty union, viewport mapping ve nearest sampler kodunu aynen koru. + +7. **Yalniz metrik gerekirse ikinci kademe optimizasyon yap.** Ilk duzeltmelerden sonra `view/diff/layout` p95 hala frame butcesini asiyorsa shader widget'inin yayinladigi hareket mesajlarini guvenli bir ust frekansta birlestir veya sinirla; son koordinati ve release olayini kaybetme, engine interpolasyon/spacing davranisini koru. Overlay `Canvas` geometri olusturma maliyeti baskinsa yalniz aktif preview/cursor durumlarini cache'le. Dirty pack allocation veya lock maliyeti anlamli degilse staging buffer havuzu ya da GPU tile atlas ekleme. + +8. **Karsilastirma raporunu sonuclandir.** Once/sonra tablosunda engine zamani, iced update/composite/staging/upload p95, input-to-GPU-prepare p95, gorunur update hizi, upload byte miktari ve full-upload sayisini ver. Sonucu paradigmayla dogru bagla: immediate mode tek basina hiz garantisi degildir; bu uygulamada egui'nin acik repaint davranisi ile iced'in event-driven Elm zinciri ve hot-path yan isleri fark yaratir. GPU/tile hipotezinin hangi metriklerle elendigi veya dogrulandigini belirt. + +## Dogrulama + +- `cargo test -p hcie-engine-api --test visual_regression` +- `cargo test -p hcie-engine-api --test performance_stroke_4k -- --nocapture` +- `cargo test -p hcie-iced-gui` +- `cargo test -p hcie-iced-gui diagnostic_4k_dirty_staging_latency -- --ignored --nocapture` +- `cargo test -p hcie-gui-egui` +- Debug profile yerine mevcut optimize edilmis dev profile ve release'te ayri baseline al; sonuclari profil adi ve secilen GPU adapter ile etiketle. +- 4K/10 katman/100 segment senaryosunda normal stroke boyunca full upload sifir, upload byte miktari dirty rectangle ile orantili ve software renderer kullanimi yok olmalidir. +- Hedef: steady-state gorunur canvas yenilemesi yaklasik 60 Hz, input-to-GPU-prepare p95 en fazla 16.7 ms veya ayni makinedeki egui referansina esit/daha iyi; engine tek basina 16.7 ms'yi asiyorsa GUI hedefini engine maliyetinden ayri raporla ve locked engine'i bu calismada degistirme. +- Brush, pen, eraser ve spray icin ilk dab, surekli stroke, kisa duraklama ve release son pikselinin gorunmesi; hizli stroke sirasinda eksik dirty bolge olmamasi; pan/zoom koordinat hizasi ve selection shader goruntusu regresyon testinden gecmelidir. +- Her GUI degisikliginden sonra proje talimatina uygun screenshot al. Performans goruntuden anlasilamayacagi icin screenshot'i metriklerin yerine kullanma; ilk frame, canvas hizasi ve overlay regresyonunu kontrol etmek icin kullan. + +## Riskler ve Sinirlar + +- `iced::time::every` cok sik veya stroke disinda calistirilirsa pointer starvation geri gelebilir; subscription aktif stroke ile sinirli ve 16 ms paced olmalidir. +- Pointer mesajlarini birlestirmek stroke noktalari kaybettirebilir; bu adim kosullu olacak ve release/son nokta zorunlu korunacaktir. +- `full_upload` ayni boyutta texture yenilemeden yapilirken pipeline recovery ve document switch davranisi korunmalidir. +- Gercek display present zamani iced shader API'sinden dogrudan elde edilmiyorsa input-to-prepare metriği acikca proxy olarak raporlanmalidir. +- GPU tile atlas bu planin parcasi degildir. Dirty upload'larin beklenmedik bicimde full-canvas oldugu kanitlanirsa once dirty bounds kaynagi duzeltilir; atlas ancak ayri bir tasarim karari olarak ele alinir. diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs index ad0e871..7d85de1 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs @@ -6,6 +6,7 @@ use eframe::egui; use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN}; use std::hash::{Hash, Hasher}; +pub mod perf; pub mod render; pub mod text_overlay; @@ -299,6 +300,9 @@ impl<'a> egui::Widget for CanvasWidget<'a> { )); return response; } + let input_at = std::time::Instant::now(); + perf::begin_stroke(input_at); + perf::record_input(input_at); // Send current brush state to engine before stroke self.doc.engine.set_color(self.state.primary_color); let mut tip = hcie_engine_api::BrushTip::default(); @@ -487,6 +491,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> { self.state.brush_accumulated_dist = 0.0; let layer_id = self.doc.engine.active_layer_id(); if self.state.active_tool != Tool::Pen { + let engine_start = std::time::Instant::now(); self.doc.engine.begin_stroke(layer_id, ux as f32, uy as f32); // Draw single dab on click (even without movement). // The spacing check in the drag handler prevents @@ -497,6 +502,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> { uy as f32, self.state.current_pressure, ); + perf::record_duration("engine_draw", engine_start.elapsed()); } } @@ -915,6 +921,9 @@ impl<'a> egui::Widget for CanvasWidget<'a> { match self.state.active_tool { Tool::Pen => { if let Some((lx, ly)) = self.state.last_draw_pos { + let input_at = std::time::Instant::now(); + perf::record_input(input_at); + let engine_start = std::time::Instant::now(); let layer_id = self.doc.engine.active_layer_id(); if !self.doc.engine.is_stroke_active() { self.doc @@ -929,6 +938,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> { uy as f32, self.state.current_pressure, ); + perf::record_duration("engine_draw", engine_start.elapsed()); self.state.last_draw_pos = Some((ux, uy)); } } @@ -947,6 +957,9 @@ impl<'a> egui::Widget for CanvasWidget<'a> { } .max(1.0); if dx >= spacing { + let input_at = std::time::Instant::now(); + perf::record_input(input_at); + let engine_start = std::time::Instant::now(); let layer_id = self.doc.engine.active_layer_id(); self.doc.engine.stroke_to( layer_id, @@ -954,6 +967,10 @@ impl<'a> egui::Widget for CanvasWidget<'a> { uy as f32, self.state.current_pressure, ); + perf::record_duration( + "engine_draw", + engine_start.elapsed(), + ); self.state.last_draw_pos = Some((ux, uy)); } } @@ -1223,6 +1240,7 @@ impl<'a> egui::Widget for CanvasWidget<'a> { self.event_bus.push(AppEvent::RenderRequested); self.event_bus.push(AppEvent::DrawingFinished); + perf::request_finish(); } Tool::Select => { if let Some(rect) = self.state.selection_rect { diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/perf.rs b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/perf.rs new file mode 100644 index 0000000..5ba70b3 --- /dev/null +++ b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/perf.rs @@ -0,0 +1,181 @@ +//! Opt-in canvas performance probe for the egui reference renderer. +//! +//! **Purpose:** Produces the same stage names as the iced canvas diagnostics so +//! immediate-mode and reactive pipelines can be compared on one machine. +//! **Logic & Workflow:** `HCIE_CANVAS_PERF=1` enables per-stroke duration and byte +//! collection; the first texture update after release emits p50/p95/max values. +//! **Side Effects / Dependencies:** Uses a process-wide mutex and the `log` crate. + +use std::collections::BTreeMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// Mutable measurements for one egui stroke. +#[derive(Default)] +struct PerfState { + stroke: u64, + active: bool, + finish_pending: bool, + latest_input: Option, + samples: BTreeMap<&'static str, Vec>, + copied_bytes: u64, + uploaded_bytes: u64, + visible_updates: u64, +} + +/// Returns whether `HCIE_CANVAS_PERF` enables diagnostics. +pub fn enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + std::env::var("HCIE_CANVAS_PERF") + .map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) + }) +} + +/// Starts a new per-stroke sample window. +pub fn begin_stroke(input_at: Instant) { + if !enabled() { + return; + } + with_state(|state| { + state.stroke = state.stroke.wrapping_add(1); + state.active = true; + state.finish_pending = false; + state.latest_input = Some(input_at); + state.samples.clear(); + state.copied_bytes = 0; + state.uploaded_bytes = 0; + state.visible_updates = 0; + }); +} + +/// Records the latest pixel-changing input and its immediate dispatch delay. +pub fn record_input(input_at: Instant) { + if !enabled() { + return; + } + with_state(|state| { + if state.active { + state.latest_input = Some(input_at); + state + .samples + .entry("input_to_update") + .or_default() + .push(input_at.elapsed().as_secs_f64() * 1000.0); + } + }); +} + +/// Records one named stage duration. +pub fn record_duration(stage: &'static str, duration: Duration) { + if !enabled() { + return; + } + with_state(|state| { + if state.active { + state + .samples + .entry(stage) + .or_default() + .push(duration.as_secs_f64() * 1000.0); + } + }); +} + +/// Records CPU copy and texture payload bytes for one visible update. +pub fn record_transfer(copied_bytes: usize, uploaded_bytes: usize) { + if !enabled() { + return; + } + with_state(|state| { + if state.active { + state.copied_bytes = state.copied_bytes.saturating_add(copied_bytes as u64); + state.uploaded_bytes = state.uploaded_bytes.saturating_add(uploaded_bytes as u64); + state.visible_updates += 1; + if let Some(input) = state.latest_input { + state + .samples + .entry("input_to_texture_update_proxy") + .or_default() + .push(input.elapsed().as_secs_f64() * 1000.0); + } + } + }); +} + +/// Requests summary emission after the final texture update. +pub fn request_finish() { + if enabled() { + with_state(|state| state.finish_pending = state.active); + } +} + +/// Emits the pending summary after a render pass has had an opportunity to upload. +pub fn finish_after_render() { + if !enabled() { + return; + } + with_state(|state| { + if !state.active || !state.finish_pending { + return; + } + state.active = false; + state.finish_pending = false; + let mut stages = Vec::new(); + for (name, samples) in &mut state.samples { + samples.sort_by(f64::total_cmp); + if let Some(max) = samples.last().copied() { + stages.push(format!( + "{}[n={},p50={:.3}ms,p95={:.3}ms,max={:.3}ms]", + name, + samples.len(), + percentile(samples, 0.50), + percentile(samples, 0.95), + max + )); + } + } + log::info!( + target: "hcie_canvas_perf", + "egui stroke={} {} counters[copied={}B,uploaded={}B,visible_updates={}]", + state.stroke, + stages.join(" "), + state.copied_bytes, + state.uploaded_bytes, + state.visible_updates + ); + }); +} + +/// Selects the nearest sample for a percentile from an already sorted slice. +fn percentile(samples: &[f64], fraction: f64) -> f64 { + if samples.is_empty() { + return 0.0; + } + let index = ((samples.len() - 1) as f64 * fraction.clamp(0.0, 1.0)).round() as usize; + samples[index] +} + +/// Runs an operation while holding the diagnostic accumulator lock. +fn with_state(operation: impl FnOnce(&mut PerfState) -> T) -> T { + static STATE: OnceLock> = OnceLock::new(); + let mut state = STATE + .get_or_init(|| Mutex::new(PerfState::default())) + .lock() + .unwrap_or_else(|error| error.into_inner()); + operation(&mut state) +} + +#[cfg(test)] +mod tests { + use super::percentile; + + /// Confirms percentile output matches the iced probe for equivalent samples. + #[test] + fn percentile_uses_nearest_ranked_sample() { + let samples = [1.0, 2.0, 3.0, 4.0, 5.0]; + assert_eq!(percentile(&samples, 0.50), 3.0); + assert_eq!(percentile(&samples, 0.95), 5.0); + } +} diff --git a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs index 6bff398..33e52a6 100644 --- a/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs +++ b/hcie-egui-app/crates/hcie-gui-egui/src/canvas/render.rs @@ -169,13 +169,17 @@ pub fn render_composition( if needs_render { let t_composite_start = std::time::Instant::now(); let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region(); - let composite_ms = t_composite_start.elapsed().as_millis(); + let composite_elapsed = t_composite_start.elapsed(); + let composite_ms = composite_elapsed.as_millis(); + super::perf::record_duration("render_composite_region", composite_elapsed); + let copy_start = std::time::Instant::now(); if !buf_ptr.is_null() && buf_size > 0 && buf_size == doc.composite_buffer.len() { unsafe { std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_buffer.as_mut_ptr(), buf_size); } } + super::perf::record_duration("cpu_staging", copy_start.elapsed()); let region = match region_result { Some([x0, y0, x1, y1]) if x1 > x0 && y1 > y0 => [x0, y0, x1, y1], @@ -188,6 +192,8 @@ pub fn render_composition( ..Default::default() }; + let texture_start = std::time::Instant::now(); + let mut uploaded_bytes = 0usize; // Partial texture upload for the dirty region only if let Some(tex) = &mut doc.composite_texture { let size = tex.size(); @@ -212,6 +218,7 @@ pub fn render_composition( ®ion_pixels, ); tex.set_partial([x0 as usize, y0 as usize], region_image, options); + uploaded_bytes = region_pixels.len(); } } else { let color_image = egui::ColorImage::from_rgba_unmultiplied( @@ -219,6 +226,7 @@ pub fn render_composition( &doc.composite_buffer, ); tex.set(color_image, options); + uploaded_bytes = doc.composite_buffer.len(); } } else { let color_image = egui::ColorImage::from_rgba_unmultiplied( @@ -226,7 +234,10 @@ pub fn render_composition( &doc.composite_buffer, ); doc.composite_texture = Some(ctx.load_texture("composite-view", color_image, options)); + uploaded_bytes = doc.composite_buffer.len(); } + super::perf::record_duration("texture_update", texture_start.elapsed()); + super::perf::record_transfer(buf_size, uploaded_bytes); doc.thumbnails_dirty = true; doc.engine.clear_dirty_flags(); @@ -265,6 +276,7 @@ pub fn render_composition( } } + super::perf::finish_after_render(); false } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 46c3b83..f5580da 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -515,6 +515,10 @@ pub struct IcedDocument { /// Cached layer thumbnails keyed by layer ID — (thumb_w, thumb_h, RGBA pixels). /// Regenerated only for dirty layers in `refresh_panel_caches()`. pub layer_thumbnails: std::collections::HashMap)>, + /// Layer IDs whose thumbnails must be regenerated after a canvas-only refresh. + /// Dirty engine flags are cleared after compositing, so this set carries the + /// invalidation until panel work is safe to perform at stroke completion. + pub pending_thumbnail_layers: std::collections::HashSet, /// Thumbnail generation generation counter; incremented when any thumbnail /// is regenerated so the view can detect stale cache without locking. pub thumb_gen: u64, @@ -581,8 +585,6 @@ pub struct ToolState { pub brush_color_variant: bool, /// Magnitude of the per-dab color variation (0.0..1.0). pub brush_variant_amount: f32, - /// Timestamp of the last composite refresh for throttling during strokes. - pub last_composite_refresh: std::time::Instant, /// Timestamp of the last update() call for frame timing. pub last_update_instant: std::time::Instant, /// Imported brush presets from ABR/KPP files. @@ -617,7 +619,6 @@ impl Default for ToolState { brush_spacing: 1.0, brush_color_variant: false, brush_variant_amount: 0.0, - last_composite_refresh: std::time::Instant::now(), last_update_instant: std::time::Instant::now(), imported_brushes: Vec::new(), active_brush_preset: None, @@ -661,10 +662,12 @@ pub enum Message { CanvasPointerPressed { x: f32, y: f32, + captured_at: std::time::Instant, }, CanvasPointerMoved { x: f32, y: f32, + captured_at: std::time::Instant, }, CanvasPointerReleased, CanvasPointerRightClicked { @@ -691,6 +694,8 @@ pub enum Message { // ── Engine operations ─────────────────────────────── CompositeRefresh, + /// Paces visible stroke updates independently from raw pointer message frequency. + CanvasFrameTick, /// Polled after end_stroke to commit any pending history snapshots /// that the background thread hadn't finished yet. CompositeRefreshPending, @@ -1762,6 +1767,7 @@ impl HcieIcedApp { rotation_snap_angle: 0.0, resize_snap_active: false, layer_thumbnails: std::collections::HashMap::new(), + pending_thumbnail_layers: std::collections::HashSet::new(), thumb_gen: 0, }; @@ -2337,17 +2343,29 @@ impl HcieIcedApp { } } - /// Refresh the composite buffer using incremental dirty-region compositing. + /// Refresh the canvas using incremental dirty-region compositing. /// /// Uses `render_composite_region()` which only re-composites dirty tiles. /// Only the dirty region is copied from the engine's scratch buffer into /// the local composite buffer, avoiding a full 33 MB copy for small strokes. - fn refresh_composite_if_needed(&mut self) { + fn refresh_canvas_if_needed(&mut self) { let doc = &mut self.documents[self.active_doc]; if doc.engine.is_composite_dirty() { + let refresh_start = std::time::Instant::now(); + for layer in &doc.cached_layers { + if doc.engine.is_layer_dirty(layer.id) { + doc.pending_thumbnail_layers.insert(layer.id); + } + } + let composite_start = std::time::Instant::now(); let (dirty_rect, ptr, len) = doc.engine.render_composite_region(); + crate::canvas::perf::record_duration( + "render_composite_region", + composite_start.elapsed(), + ); if len > 0 && !ptr.is_null() { + let staging_start = std::time::Instant::now(); let mut is_full_copy = false; // Resize composite_raw if canvas dimensions changed (e.g. after PSD import) if doc.composite_raw.len() != len { @@ -2359,6 +2377,7 @@ impl HcieIcedApp { // If the canvas changed size, we MUST force a full upload because the old memory // structure is no longer valid, and a partial copy would leave zeros in the new buffer. if let Some(rect) = dirty_rect.filter(|_| !is_full_copy) { + crate::canvas::perf::record_dirty_region(rect); let w = doc.engine.canvas_width() as usize; let [x0, y0, x1, y1] = rect; let x0 = x0 as usize; @@ -2394,6 +2413,12 @@ impl HcieIcedApp { doc.dirty_region.replace(Some(union_regions(current, rect))); } else { // Full update + crate::canvas::perf::record_dirty_region([ + 0, + 0, + doc.engine.canvas_width(), + doc.engine.canvas_height(), + ]); unsafe { std::ptr::copy_nonoverlapping(ptr, doc.composite_raw.as_mut_ptr(), len); } @@ -2403,25 +2428,37 @@ impl HcieIcedApp { } doc.render_generation = doc.render_generation.wrapping_add(1); + crate::canvas::perf::record_duration("cpu_staging", staging_start.elapsed()); } doc.engine.clear_dirty_flags(); + crate::canvas::perf::record_duration("canvas_refresh", refresh_start.elapsed()); } sync_document_selection_if_needed(doc); + } + + /// Refresh panel caches after canvas work has completed. + /// + /// Layer pixel copies and history description allocation are intentionally + /// excluded from the active-stroke path and run only for completed document + /// operations or stroke release. + fn refresh_panel_caches(&mut self) { + let refresh_start = std::time::Instant::now(); + let doc = &mut self.documents[self.active_doc]; - // Always refresh cached panel data (cheap operation) doc.cached_layers = doc.engine.layer_infos(); - // Regenerate thumbnail cache — only for dirty or uncached layers let max_dim = 48u32; doc.layer_thumbnails .retain(|id, _| doc.cached_layers.iter().any(|l| l.id == *id)); + let mut thumbnails_changed = false; for info in &doc.cached_layers { if info.width == 0 || info.height == 0 { continue; } let cached = doc.layer_thumbnails.contains_key(&info.id); - let dirty = doc.engine.is_layer_dirty(info.id); + let dirty = doc.pending_thumbnail_layers.contains(&info.id) + || doc.engine.is_layer_dirty(info.id); if cached && !dirty { continue; } @@ -2436,15 +2473,29 @@ impl HcieIcedApp { max_dim, ); doc.layer_thumbnails.insert(info.id, (max_dim, th, thumb)); + doc.pending_thumbnail_layers.remove(&info.id); + thumbnails_changed = true; } } } - doc.thumb_gen += 1; + if thumbnails_changed { + doc.thumb_gen = doc.thumb_gen.wrapping_add(1); + } let history_len = doc.engine.history_len(); doc.cached_history = (0..history_len) .filter_map(|i| doc.engine.history_description(i).map(|d| (i, d))) .collect(); doc.history_current = doc.engine.history_current(); + crate::canvas::perf::record_duration("panel_cache_refresh", refresh_start.elapsed()); + } + + /// Refresh both the visible canvas and the document panel caches. + /// + /// This preserves the existing behavior for non-interactive operations while + /// allowing active drawing to call `refresh_canvas_if_needed()` directly. + fn refresh_composite_if_needed(&mut self) { + self.refresh_canvas_if_needed(); + self.refresh_panel_caches(); } /// Apply brush parameters from tool state to the engine. @@ -2752,7 +2803,7 @@ impl HcieIcedApp { } } - Message::CanvasPointerPressed { x, y } => { + Message::CanvasPointerPressed { x, y, captured_at } => { // Coordinates are already in canvas-space from the canvas widget let canvas_x = x; let canvas_y = y; @@ -2845,6 +2896,8 @@ impl HcieIcedApp { match tool { Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => { + crate::canvas::perf::begin_stroke(); + crate::canvas::perf::record_render_input(captured_at); // Apply brush parameters before starting stroke self.apply_brush_params(); @@ -2864,8 +2917,7 @@ impl HcieIcedApp { self.tool_state.is_drawing = true; self.tool_state.last_stroke_pos = Some((canvas_x, canvas_y)); self.tool_state.brush_accumulated_dist = 0.0; - self.tool_state.last_composite_refresh = std::time::Instant::now(); - self.refresh_composite_if_needed(); + self.refresh_canvas_if_needed(); } Tool::Eyedropper => { let cx = canvas_x as u32; @@ -3103,7 +3155,7 @@ impl HcieIcedApp { } } - Message::CanvasPointerMoved { x, y } => { + Message::CanvasPointerMoved { x, y, captured_at } => { // Coordinates are already in canvas-space from the canvas widget self.canvas_cursor_pos = Some((x as u32, y as u32)); @@ -3187,20 +3239,27 @@ impl HcieIcedApp { .lock() .map(|state| state.current_pressure()) .unwrap_or(1.0); + crate::canvas::perf::record_render_input(captured_at); + let engine_start = std::time::Instant::now(); self.documents[self.active_doc] .engine .draw_pen_segment(layer_id, last_x, last_y, x, y, pressure); + crate::canvas::perf::record_duration( + "engine_draw", + engine_start.elapsed(), + ); self.tool_state.last_stroke_pos = Some((x, y)); - self.refresh_composite_if_needed(); } return Task::none(); } let spacing: f32 = match self.tool_state.active_tool { - Tool::Spray => 2.0_f32, + Tool::Spray => (self.tool_state.brush_size * 0.25).max(2.0), + Tool::Brush | Tool::Eraser => { + (self.tool_state.brush_size * 0.12).max(1.0) + } _ => 1.0_f32, - } - .max(1.0); + }; if let Some((last_x, last_y)) = self.tool_state.last_stroke_pos { let dx = x - last_x; @@ -3216,25 +3275,29 @@ impl HcieIcedApp { .unwrap_or(1.0); if dist >= spacing { + crate::canvas::perf::record_render_input(captured_at); + let engine_start = std::time::Instant::now(); self.documents[self.active_doc] .engine .stroke_to(layer_id, x, y, pressure); + crate::canvas::perf::record_duration( + "engine_draw", + engine_start.elapsed(), + ); self.tool_state.last_stroke_pos = Some((x, y)); - - // Throttle composite refresh to ~60 FPS during strokes. - let now = std::time::Instant::now(); - let elapsed = - now.duration_since(self.tool_state.last_composite_refresh); - if elapsed.as_secs_f32() >= 0.016 { - self.tool_state.last_composite_refresh = now; - self.refresh_composite_if_needed(); - } } } else { + crate::canvas::perf::record_render_input(captured_at); + let engine_start = std::time::Instant::now(); self.documents[self.active_doc] .engine .stroke_to(layer_id, x, y, 1.0); + crate::canvas::perf::record_duration( + "engine_draw", + engine_start.elapsed(), + ); self.tool_state.last_stroke_pos = Some((x, y)); + self.refresh_canvas_if_needed(); } } } // end else (non-selection tool) @@ -3375,6 +3438,7 @@ impl HcieIcedApp { self.documents[self.active_doc].modified = true; self.refresh_composite_if_needed(); + crate::canvas::perf::request_finish_stroke(); // If the background thread hasn't finished yet, schedule // a delayed poll so the history entry is committed. @@ -3469,7 +3533,11 @@ impl HcieIcedApp { // is resized (e.g. dock splitter dragged). } - Message::CompositeRefresh | Message::Undo | Message::Redo => { + Message::CanvasFrameTick | Message::CompositeRefresh => { + self.refresh_canvas_if_needed(); + } + + Message::Undo | Message::Redo => { match message { Message::Undo => { let doc = &mut self.documents[self.active_doc]; @@ -3495,7 +3563,7 @@ impl HcieIcedApp { self.documents[self.active_doc].engine.redo(); self.documents[self.active_doc].selection_history.clear(); } - _ => {} + _ => unreachable!(), } self.refresh_composite_if_needed(); } @@ -3509,7 +3577,6 @@ impl HcieIcedApp { let committed = doc.engine.commit_pending_history(); if committed && doc.engine.has_pending_history() { // Background thread still producing — schedule another poll. - self.refresh_composite_if_needed(); return Task::perform( async { tokio::time::sleep(std::time::Duration::from_millis(16)).await; @@ -3517,13 +3584,8 @@ impl HcieIcedApp { |_| Message::CompositeRefreshPending, ); } - // All done — refresh composite and update cached history list. - let history_len = doc.engine.history_len(); - doc.cached_history = (0..history_len) - .filter_map(|i| doc.engine.history_description(i).map(|d| (i, d))) - .collect(); - doc.history_current = doc.engine.history_current(); - self.refresh_composite_if_needed(); + // Canvas pixels were already staged on release; only panel metadata changed. + self.refresh_panel_caches(); } // ── Layer operations ────────────────────────── @@ -5414,6 +5476,7 @@ impl HcieIcedApp { rotation_snap_angle: 0.0, resize_snap_active: false, layer_thumbnails: std::collections::HashMap::new(), + pending_thumbnail_layers: std::collections::HashSet::new(), thumb_gen: 0, }; if self.show_welcome && self.documents.len() == 1 { @@ -8292,6 +8355,7 @@ impl HcieIcedApp { rotation_snap_angle: 0.0, resize_snap_active: false, layer_thumbnails: std::collections::HashMap::new(), + pending_thumbnail_layers: std::collections::HashSet::new(), thumb_gen: 0, }); } @@ -10007,7 +10071,19 @@ impl HcieIcedApp { iced::Subscription::none() }; - iced::Subscription::batch(vec![keyboard, mouse, marching_ants]) + // Raw pointer messages update the brush engine as quickly as input arrives, + // while this bounded timer controls expensive compositing and GPU staging. + let stroke_frames = if should_schedule_canvas_frames( + self.tool_state.is_drawing, + self.tool_state.active_tool, + ) { + iced::time::every(std::time::Duration::from_millis(16)) + .map(|_| Message::CanvasFrameTick) + } else { + iced::Subscription::none() + }; + + iced::Subscription::batch(vec![keyboard, mouse, marching_ants, stroke_frames]) } } @@ -10083,13 +10159,24 @@ fn active_index_after_document_close( } } +/// Determines whether the bounded canvas frame timer should run. +/// +/// **Arguments:** `is_drawing` is the pointer-drag state and `tool` is the active editor tool. +/// **Returns:** `true` only for an active raster paint stroke. **Logic & Workflow:** Selection, +/// vector, transform, and idle states continue to use their event-driven previews. +/// **Side Effects / Dependencies:** None. +fn should_schedule_canvas_frames(is_drawing: bool, tool: Tool) -> bool { + is_drawing && matches!(tool, Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray) +} + #[cfg(test)] mod cycle_one_ux_tests { use super::{ active_index_after_document_close, consume_selection_sync_request, - document_close_disposition, overlay_escape_target, union_regions, DocumentCloseDisposition, - OverlayEscapeTarget, + document_close_disposition, overlay_escape_target, should_schedule_canvas_frames, + union_regions, DocumentCloseDisposition, OverlayEscapeTarget, }; + use hcie_engine_api::Tool; /// Confirms Escape dismisses subtools before menus and leaves editor cancellation untouched. #[test] @@ -10156,6 +10243,17 @@ mod cycle_one_ux_tests { assert_eq!(union, [10, 5, 50, 40]); } + /// Confirms the frame timer is bounded to active raster strokes. + #[test] + fn canvas_frame_timer_runs_only_for_active_paint_strokes() { + for tool in [Tool::Pen, Tool::Brush, Tool::Eraser, Tool::Spray] { + assert!(should_schedule_canvas_frames(true, tool)); + assert!(!should_schedule_canvas_frames(false, tool)); + } + assert!(!should_schedule_canvas_frames(true, Tool::Select)); + assert!(!should_schedule_canvas_frames(true, Tool::VectorSelect)); + } + // ── Recent color proximity dedup ───────────────────── /// Confirms an identical color IS proximate (diff=0 ≤ threshold). diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs index ca0a085..a9ab149 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs @@ -19,6 +19,7 @@ //! - Only dirty sub-regions are uploaded — never the full 33MB buffer //! - The checkerboard is procedural (in the fragment shader) — no CPU geometry +pub mod perf; pub mod shader_canvas; pub mod texture_update; // pub mod viewport; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/perf.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/perf.rs new file mode 100644 index 0000000..c6c0974 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/perf.rs @@ -0,0 +1,280 @@ +//! Opt-in real-time canvas performance measurements. +//! +//! **Purpose:** Separates input queueing, engine drawing, CPU compositing/staging, +//! GPU upload, and renderer preparation costs without adding production log noise. +//! **Logic & Workflow:** When `HCIE_CANVAS_PERF=1`, callers record named durations +//! and byte counters in a process-wide stroke accumulator. Stroke completion sorts +//! samples and emits one p50/p95/max summary. **Side Effects / Dependencies:** Uses +//! a standard-library mutex and `log`; disabled builds do not allocate sample data. + +use std::collections::BTreeMap; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// Environment variable that enables canvas performance diagnostics. +const PERF_ENV: &str = "HCIE_CANVAS_PERF"; + +/// Mutable measurements for the current stroke and renderer cadence. +#[derive(Default)] +struct PerfState { + stroke_number: u64, + active: bool, + samples_ms: BTreeMap<&'static str, Vec>, + dirty_bytes: u64, + uploaded_bytes: u64, + full_uploads: u64, + visible_updates: u64, + latest_render_input: Option, + last_prepare: Option, + finish_pending: bool, +} + +/// Returns whether diagnostics are enabled for this process. +/// +/// **Arguments:** None. **Returns:** `true` only for `1`, `true`, or `yes`. +/// **Side Effects / Dependencies:** Reads `HCIE_CANVAS_PERF` once and caches it. +pub fn enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + std::env::var(PERF_ENV) + .map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) + }) +} + +/// Starts a fresh stroke measurement window. +/// +/// **Arguments:** None. **Returns:** Nothing. +/// **Side Effects / Dependencies:** Clears prior samples and resets frame cadence. +pub fn begin_stroke() { + if !enabled() { + return; + } + with_state(|state| { + state.stroke_number = state.stroke_number.wrapping_add(1); + state.active = true; + state.samples_ms.clear(); + state.dirty_bytes = 0; + state.uploaded_bytes = 0; + state.full_uploads = 0; + state.visible_updates = 0; + state.latest_render_input = None; + state.last_prepare = None; + state.finish_pending = false; + }); +} + +/// Records elapsed time for one named pipeline stage. +/// +/// **Arguments:** `stage` is a stable metric name and `elapsed` is its duration. +/// **Returns:** Nothing. **Side Effects / Dependencies:** Appends one sample while active. +pub fn record_duration(stage: &'static str, elapsed: Duration) { + if !enabled() { + return; + } + with_state(|state| { + if state.active { + state + .samples_ms + .entry(stage) + .or_default() + .push(elapsed.as_secs_f64() * 1000.0); + } + }); +} + +/// Records event queue delay and marks the input represented by the next texture update. +/// +/// **Arguments:** `captured_at` is the time the shader widget emitted the input message. +/// **Returns:** Nothing. **Side Effects / Dependencies:** Updates input latency samples. +pub fn record_render_input(captured_at: Instant) { + if !enabled() { + return; + } + with_state(|state| { + if state.active { + state + .samples_ms + .entry("input_to_update") + .or_default() + .push(captured_at.elapsed().as_secs_f64() * 1000.0); + state.latest_render_input = Some(captured_at); + } + }); +} + +/// Returns the latest input timestamp that changed engine pixels. +/// +/// **Arguments:** None. **Returns:** Optional monotonic input timestamp. +/// **Side Effects / Dependencies:** Reads the shared diagnostic accumulator. +pub fn latest_render_input() -> Option { + if !enabled() { + return None; + } + with_state(|state| state.latest_render_input) +} + +/// Records one dirty region produced by CPU compositing. +/// +/// **Arguments:** `region` uses exclusive bounds. **Returns:** Nothing. +/// **Side Effects / Dependencies:** Adds the RGBA byte area to the current stroke. +pub fn record_dirty_region(region: [u32; 4]) { + if !enabled() { + return; + } + let bytes = (region[2].saturating_sub(region[0]) as u64) + * (region[3].saturating_sub(region[1]) as u64) + * 4; + with_state(|state| { + if state.active { + state.dirty_bytes = state.dirty_bytes.saturating_add(bytes); + } + }); +} + +/// Records bytes submitted to the GPU texture and whether the upload was full-canvas. +/// +/// **Arguments:** `bytes` is payload size and `full` identifies recovery/replace uploads. +/// **Returns:** Nothing. **Side Effects / Dependencies:** Updates current stroke counters. +pub fn record_upload(bytes: usize, full: bool) { + if !enabled() { + return; + } + with_state(|state| { + if state.active { + state.uploaded_bytes = state.uploaded_bytes.saturating_add(bytes as u64); + state.full_uploads += u64::from(full); + state.visible_updates += 1; + } + }); +} + +/// Records renderer preparation cadence and input-to-GPU-prepare proxy latency. +/// +/// **Arguments:** `input_at` is the latest pixel-changing input represented by this update. +/// **Returns:** Nothing. **Side Effects / Dependencies:** Updates shared prepare timing state. +pub fn record_prepare(input_at: Option) { + if !enabled() { + return; + } + let now = Instant::now(); + let should_finish = with_state(|state| { + if let Some(previous) = state.last_prepare.replace(now) { + if state.active { + state + .samples_ms + .entry("prepare_interval") + .or_default() + .push(now.duration_since(previous).as_secs_f64() * 1000.0); + } + } + if state.active { + if let Some(input_at) = input_at { + state + .samples_ms + .entry("input_to_gpu_prepare_proxy") + .or_default() + .push(now.duration_since(input_at).as_secs_f64() * 1000.0); + } + } + state.finish_pending + }); + if should_finish { + finish_stroke(); + } +} + +/// Requests summary emission after the final renderer preparation for this stroke. +/// +/// **Arguments:** None. **Returns:** Nothing. +/// **Side Effects / Dependencies:** Marks the active diagnostic window for deferred completion. +pub fn request_finish_stroke() { + if !enabled() { + return; + } + with_state(|state| { + if state.active { + state.finish_pending = true; + } + }); +} + +/// Finishes the current stroke and logs one percentile summary. +/// +/// **Arguments:** None. **Returns:** Nothing. +/// **Side Effects / Dependencies:** Sorts captured samples and emits one info-level log entry. +fn finish_stroke() { + if !enabled() { + return; + } + with_state(|state| { + if !state.active { + return; + } + state.active = false; + state.finish_pending = false; + let mut stages = Vec::with_capacity(state.samples_ms.len()); + for (name, samples) in &mut state.samples_ms { + samples.sort_by(f64::total_cmp); + if let Some(max) = samples.last().copied() { + stages.push(format!( + "{}[n={},p50={:.3}ms,p95={:.3}ms,max={:.3}ms]", + name, + samples.len(), + percentile(samples, 0.50), + percentile(samples, 0.95), + max + )); + } + } + log::info!( + target: "hcie_canvas_perf", + "stroke={} {} counters[dirty={}B,uploaded={}B,full_uploads={},visible_updates={}]", + state.stroke_number, + stages.join(" "), + state.dirty_bytes, + state.uploaded_bytes, + state.full_uploads, + state.visible_updates + ); + }); +} + +/// Returns a percentile from a sorted sample slice. +/// +/// **Arguments:** `samples` must be sorted and `fraction` is in `0.0..=1.0`. +/// **Returns:** Selected sample or zero for an empty slice. **Side Effects:** None. +fn percentile(samples: &[f64], fraction: f64) -> f64 { + if samples.is_empty() { + return 0.0; + } + let index = ((samples.len() - 1) as f64 * fraction.clamp(0.0, 1.0)).round() as usize; + samples[index] +} + +/// Executes a closure while holding the diagnostic state lock. +/// +/// **Arguments:** `operation` receives mutable state. **Returns:** Closure result. +/// **Side Effects / Dependencies:** Initializes and locks the process-wide accumulator. +fn with_state(operation: impl FnOnce(&mut PerfState) -> T) -> T { + static STATE: OnceLock> = OnceLock::new(); + let mut state = STATE + .get_or_init(|| Mutex::new(PerfState::default())) + .lock() + .unwrap_or_else(|error| error.into_inner()); + operation(&mut state) +} + +#[cfg(test)] +mod tests { + use super::percentile; + + /// Confirms percentile selection is deterministic for short frame samples. + #[test] + fn percentile_uses_nearest_ranked_sample() { + let samples = [1.0, 2.0, 3.0, 4.0, 5.0]; + assert_eq!(percentile(&samples, 0.50), 3.0); + assert_eq!(percentile(&samples, 0.95), 5.0); + assert_eq!(percentile(&[], 0.50), 0.0); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs index 8871544..de2a53d 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/shader_canvas.rs @@ -231,6 +231,7 @@ impl CanvasShaderPipeline { canvas_h, initial_pixels.len() ); + super::perf::record_upload(initial_pixels.len(), true); } // ── Sampler (nearest for zoom-in, linear for zoom-out) ─────────── @@ -517,6 +518,7 @@ impl CanvasShaderPipeline { depth_or_array_layers: 1, }, ); + super::perf::record_upload(pixels.len(), true); } // Recreate selection mask texture with new dimensions @@ -570,6 +572,59 @@ impl CanvasShaderPipeline { self.texture_h = canvas_h; } + /// Replace every pixel in the existing composite texture without rebuilding GPU resources. + /// + /// ## Arguments + /// * `queue` — wgpu queue used for the texture write + /// * `pixels` — complete tightly packed RGBA image matching the current dimensions + /// + /// ## Returns + /// Nothing. Invalid buffer lengths are logged and ignored. + /// + /// ## Side Effects + /// Enqueues one full texture write while preserving texture views, selection resources, + /// samplers, and the bind group. + fn upload_full_texture(&self, queue: &wgpu::Queue, pixels: &[u8]) { + let expected = self.texture_w as usize * self.texture_h as usize * 4; + if self.texture_w == 0 || self.texture_h == 0 || pixels.len() != expected { + log::warn!( + "[CanvasShaderPipeline::upload_full_texture] size mismatch: pixels={} expected={} canvas={}x{}", + pixels.len(), + expected, + self.texture_w, + self.texture_h + ); + return; + } + + queue.write_texture( + wgpu::ImageCopyTexture { + texture: &self.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + pixels, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(self.texture_w * 4), + rows_per_image: Some(self.texture_h), + }, + wgpu::Extent3d { + width: self.texture_w, + height: self.texture_h, + depth_or_array_layers: 1, + }, + ); + super::perf::record_upload(pixels.len(), true); + log::debug!( + "[CanvasShaderPipeline::upload_full_texture] uploaded {}x{} texture ({} bytes)", + self.texture_w, + self.texture_h, + pixels.len() + ); + } + /// Upload only the dirty sub-region of the composite buffer to the GPU. /// /// ## Arguments @@ -598,6 +653,8 @@ impl CanvasShaderPipeline { }, ); let elapsed = t0.elapsed(); + super::perf::record_duration("gpu_upload", elapsed); + super::perf::record_upload(update.pixels.len(), false); log::debug!( "[CanvasShaderPipeline::upload_dirty_region] {}×{} region at ({},{}) → {:.2}ms, {} bytes", update.width(), update.height(), x0, y0, elapsed.as_secs_f64() * 1000.0, update.pixels.len() @@ -710,6 +767,15 @@ impl shader::Primitive for CanvasShaderPrimitive { _viewport: &shader::Viewport, ) { let t0 = std::time::Instant::now(); + let represented_input = self + .texture_update + .as_ref() + .and_then(|update| update.input_at) + .or_else(|| { + self.full_upload + .then(super::perf::latest_render_input) + .flatten() + }); // ── Create or retrieve pipeline ────────────────────────────────── let created_pipeline = !storage.has::(); @@ -736,9 +802,9 @@ impl shader::Primitive for CanvasShaderPrimitive { let pixels = self.composite_pixels.read(); pipeline.resize_texture(device, queue, self.canvas_w, self.canvas_h, &pixels); } else if !created_pipeline && self.full_upload { - // Full upload requested (e.g., after loading a file) + // Same-size document replacement: preserve all persistent GPU resources. let pixels = self.composite_pixels.read(); - pipeline.resize_texture(device, queue, self.canvas_w, self.canvas_h, &pixels); + pipeline.upload_full_texture(queue, &pixels); } else if !created_pipeline { // ── Partial upload: only the dirty sub-region ──────────────── if let Some(update) = self.texture_update.as_ref() { @@ -806,6 +872,8 @@ impl shader::Primitive for CanvasShaderPrimitive { pipeline.update_uniforms(queue, &uniforms); let elapsed = t0.elapsed(); + super::perf::record_duration("gpu_prepare", elapsed); + super::perf::record_prepare(represented_input); log::trace!( "[CanvasShaderPrimitive::prepare] {:.2}ms | viewport={}×{} | canvas={}×{} | zoom={:.2}", elapsed.as_secs_f64() * 1000.0, @@ -1020,7 +1088,11 @@ impl shader::Program for CanvasShaderProgram { let cy = canvas_y.unwrap_or(0.0).clamp(0.0, self.engine_h as f32); return ( iced::event::Status::Captured, - Some(Message::CanvasPointerMoved { x: cx, y: cy }), + Some(Message::CanvasPointerMoved { + x: cx, + y: cy, + captured_at: std::time::Instant::now(), + }), ); } @@ -1107,7 +1179,11 @@ impl shader::Program for CanvasShaderProgram { state.left_pressed = true; return ( iced::event::Status::Captured, - Some(Message::CanvasPointerPressed { x: cx, y: cy }), + Some(Message::CanvasPointerPressed { + x: cx, + y: cy, + captured_at: std::time::Instant::now(), + }), ); } } else if button == mouse::Button::Middle { diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/texture_update.rs b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/texture_update.rs index d0d21d5..de19e44 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/canvas/texture_update.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/canvas/texture_update.rs @@ -91,6 +91,8 @@ pub struct TextureUpdate { pub bytes_per_row: u32, /// Immutable packed RGBA rows. pub pixels: Arc<[u8]>, + /// Latest pixel-changing input represented by this upload, for diagnostics. + pub input_at: Option, } impl TextureUpdate { @@ -100,6 +102,7 @@ impl TextureUpdate { /// exclusive canvas bounds. **Returns:** A tightly packed upload payload or a validation error. /// **Side Effects / Dependencies:** Allocates exactly `dirty_width * dirty_height * 4` bytes. pub fn pack(source: &[u8], canvas_w: u32, region: [u32; 4]) -> Result { + let started = std::time::Instant::now(); let validated = ValidatedRegion::new(source.len(), canvas_w, region, 4)?; let mut pixels = vec![0; validated.row_bytes * validated.height]; for row in 0..validated.height { @@ -108,11 +111,14 @@ impl TextureUpdate { pixels[target_offset..target_offset + validated.row_bytes] .copy_from_slice(&source[source_offset..source_offset + validated.row_bytes]); } - Ok(Self { + let update = Self { region, bytes_per_row: validated.row_bytes as u32, pixels: pixels.into(), - }) + input_at: super::perf::latest_render_input(), + }; + super::perf::record_duration("dirty_pack", started.elapsed()); + Ok(update) } /// Returns the packed rectangle width. diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs index 25ff432..c363bdf 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/main.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/main.rs @@ -35,6 +35,15 @@ fn main() { .try_init(); log::info!("Starting HCIE Iced GUI"); + if canvas::perf::enabled() { + log::info!( + target: "hcie_canvas_perf", + "renderer diagnostics enabled: ICED_BACKEND={} WGPU_BACKEND={} WGPU_POWER_PREF={}; verify iced_wgpu Selected AdapterInfo is a hardware Vulkan adapter", + std::env::var("ICED_BACKEND").unwrap_or_else(|_| "".to_string()), + std::env::var("WGPU_BACKEND").unwrap_or_else(|_| "".to_string()), + std::env::var("WGPU_POWER_PREF").unwrap_or_else(|_| "".to_string()) + ); + } let args = match cli::parse_args(std::env::args().skip(1)) { Ok(args) => args,