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.
This commit is contained in:
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Instant>,
|
||||
samples: BTreeMap<&'static str, Vec<f64>>,
|
||||
copied_bytes: u64,
|
||||
uploaded_bytes: u64,
|
||||
visible_updates: u64,
|
||||
}
|
||||
|
||||
/// Returns whether `HCIE_CANVAS_PERF` enables diagnostics.
|
||||
pub fn enabled() -> bool {
|
||||
static ENABLED: OnceLock<bool> = 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<T>(operation: impl FnOnce(&mut PerfState) -> T) -> T {
|
||||
static STATE: OnceLock<Mutex<PerfState>> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u64, (u32, u32, Vec<u8>)>,
|
||||
/// 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<u64>,
|
||||
/// 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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<f64>>,
|
||||
dirty_bytes: u64,
|
||||
uploaded_bytes: u64,
|
||||
full_uploads: u64,
|
||||
visible_updates: u64,
|
||||
latest_render_input: Option<Instant>,
|
||||
last_prepare: Option<Instant>,
|
||||
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<bool> = 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<Instant> {
|
||||
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<Instant>) {
|
||||
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<T>(operation: impl FnOnce(&mut PerfState) -> T) -> T {
|
||||
static STATE: OnceLock<Mutex<PerfState>> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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::<CanvasShaderPipeline>();
|
||||
@@ -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<Message> 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<Message> 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 {
|
||||
|
||||
@@ -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<std::time::Instant>,
|
||||
}
|
||||
|
||||
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<Self, &'static str> {
|
||||
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.
|
||||
|
||||
@@ -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(|_| "<default>".to_string()),
|
||||
std::env::var("WGPU_BACKEND").unwrap_or_else(|_| "<default>".to_string()),
|
||||
std::env::var("WGPU_POWER_PREF").unwrap_or_else(|_| "<default>".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
let args = match cli::parse_args(std::env::args().skip(1)) {
|
||||
Ok(args) => args,
|
||||
|
||||
Reference in New Issue
Block a user