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:
@@ -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