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:
Your Name
2026-07-23 23:57:03 +03:00
parent e9dad7ef0c
commit a2264b130d
10 changed files with 781 additions and 46 deletions
+137 -39
View File
@@ -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,