Files
hcie-rust-v3.05/hcie-iced-app/crates/hcie-iced-gui/src/canvas/texture_update.rs
T
Your Name a2264b130d 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.
2026-07-23 23:57:03 +03:00

297 lines
12 KiB
Rust

//! CPU-side staging for persistent canvas texture updates.
//!
//! **Purpose:** Keeps the shader's recovery image in stable shared storage while carrying ordinary
//! frame updates as tightly packed dirty rectangles. **Logic & Workflow:** Engine pixels are copied
//! into the shared full image by row, then the final per-frame dirty union is packed once for wgpu.
//! **Side Effects / Dependencies:** Uses a standard-library read/write lock because iced may retain
//! shader primitives across application updates; no lock is held during normal partial GPU uploads.
use std::sync::{Arc, RwLock, RwLockReadGuard};
/// Stable full-canvas pixels used only for pipeline creation, resize, and renderer recovery.
#[derive(Clone, Debug)]
pub struct SharedCompositePixels {
pixels: Arc<RwLock<Vec<u8>>>,
}
impl SharedCompositePixels {
/// Creates shared storage from one complete RGBA image.
///
/// **Arguments:** `pixels` is a row-major RGBA image. **Returns:** Shared stable storage.
/// **Side Effects / Dependencies:** Allocates once and initializes an `RwLock`.
pub fn new(pixels: Vec<u8>) -> Self {
Self {
pixels: Arc::new(RwLock::new(pixels)),
}
}
/// Replaces the complete recovery image after a document or canvas-size change.
///
/// **Arguments:** `pixels` is the new complete RGBA image. **Returns:** Nothing.
/// **Side Effects / Dependencies:** Takes the write lock and may resize the backing allocation.
pub fn replace(&self, pixels: &[u8]) {
let mut target = self
.pixels
.write()
.unwrap_or_else(|error| error.into_inner());
target.clear();
target.extend_from_slice(pixels);
}
/// Copies one RGBA rectangle from a complete source image into the recovery image.
///
/// **Arguments:** `source` is a full row-major RGBA image, `canvas_w` is its pixel width, and
/// `region` is an exclusive `[x0, y0, x1, y1]` rectangle. **Returns:** Copied byte count, or an
/// error for malformed dimensions/bounds. **Side Effects / Dependencies:** Takes the write lock
/// only while copying the selected rows.
pub fn copy_region_from(
&self,
source: &[u8],
canvas_w: u32,
region: [u32; 4],
) -> Result<usize, &'static str> {
let validated = ValidatedRegion::new(source.len(), canvas_w, region, 4)?;
let mut target = self
.pixels
.write()
.unwrap_or_else(|error| error.into_inner());
if target.len() != source.len() {
return Err("shared composite length does not match source");
}
for row in 0..validated.height {
let offset = validated.source_offset(row);
let end = offset + validated.row_bytes;
target[offset..end].copy_from_slice(&source[offset..end]);
}
Ok(validated.row_bytes * validated.height)
}
/// Borrows the complete image for a rare full GPU upload.
///
/// **Arguments:** None. **Returns:** A read guard exposing the full RGBA bytes.
/// **Side Effects / Dependencies:** Takes a read lock; callers must keep its scope short.
pub fn read(&self) -> RwLockReadGuard<'_, Vec<u8>> {
self.pixels
.read()
.unwrap_or_else(|error| error.into_inner())
}
#[cfg(test)]
fn allocation_identity(&self) -> *const RwLock<Vec<u8>> {
Arc::as_ptr(&self.pixels)
}
}
/// One tightly packed RGBA rectangle ready for `wgpu::Queue::write_texture`.
#[derive(Clone, Debug)]
pub struct TextureUpdate {
/// Exclusive canvas-space bounds `[x0, y0, x1, y1]`.
pub region: [u32; 4],
/// Number of bytes in one packed row.
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 {
/// Packs one dirty RGBA rectangle from a complete image.
///
/// **Arguments:** `source` is a full row-major RGBA image, `canvas_w` its width, and `region`
/// 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 {
let source_offset = validated.source_offset(row);
let target_offset = row * validated.row_bytes;
pixels[target_offset..target_offset + validated.row_bytes]
.copy_from_slice(&source[source_offset..source_offset + validated.row_bytes]);
}
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.
///
/// **Arguments:** None. **Returns:** Width in pixels. **Side Effects / Dependencies:** None.
pub fn width(&self) -> u32 {
self.region[2] - self.region[0]
}
/// Returns the packed rectangle height.
///
/// **Arguments:** None. **Returns:** Height in pixels. **Side Effects / Dependencies:** None.
pub fn height(&self) -> u32 {
self.region[3] - self.region[1]
}
}
/// Validated row-copy geometry shared by recovery and upload staging.
struct ValidatedRegion {
canvas_w: usize,
x0: usize,
y0: usize,
height: usize,
row_bytes: usize,
bytes_per_pixel: usize,
}
impl ValidatedRegion {
/// Validates a rectangle against a complete tightly packed image.
///
/// **Arguments:** Image length, width, exclusive region, and channel byte count. **Returns:**
/// Safe row-copy geometry or an error. **Side Effects / Dependencies:** None.
fn new(
source_len: usize,
canvas_w: u32,
region: [u32; 4],
bytes_per_pixel: usize,
) -> Result<Self, &'static str> {
let [x0, y0, x1, y1] = region;
if canvas_w == 0 || x0 >= x1 || y0 >= y1 || x1 > canvas_w {
return Err("invalid dirty rectangle");
}
let row_stride = canvas_w as usize * bytes_per_pixel;
if row_stride == 0 || !source_len.is_multiple_of(row_stride) {
return Err("source length does not match canvas width");
}
let canvas_h = source_len / row_stride;
if y1 as usize > canvas_h {
return Err("dirty rectangle exceeds canvas height");
}
Ok(Self {
canvas_w: canvas_w as usize,
x0: x0 as usize,
y0: y0 as usize,
height: (y1 - y0) as usize,
row_bytes: (x1 - x0) as usize * bytes_per_pixel,
bytes_per_pixel,
})
}
/// Computes one source row's byte offset.
///
/// **Arguments:** `row` is relative to the validated rectangle. **Returns:** Byte offset into
/// the complete image. **Side Effects / Dependencies:** None.
fn source_offset(&self, row: usize) -> usize {
((self.y0 + row) * self.canvas_w + self.x0) * self.bytes_per_pixel
}
}
#[cfg(test)]
mod tests {
use super::{SharedCompositePixels, TextureUpdate};
use std::sync::Arc;
const WIDTH: u32 = 3840;
const HEIGHT: u32 = 2160;
const FULL_BYTES: usize = WIDTH as usize * HEIGHT as usize * 4;
/// Verifies retained shader references cannot trigger a full-canvas clone in the new path.
#[test]
fn retained_shader_reference_keeps_stable_allocation_and_dirty_budget() {
let mut source = vec![0; FULL_BYTES];
let shared = SharedCompositePixels::new(source.clone());
let shader_reference = shared.clone();
let identity = shared.allocation_identity();
let mut copied = 0usize;
let mut uploaded = 0usize;
for sample in 0..120u32 {
let x0 = 100 + sample;
let region = [x0, 200, x0 + 48, 248];
let marker = sample as u8;
for y in region[1]..region[3] {
let start = ((y * WIDTH + region[0]) * 4) as usize;
source[start..start + 48 * 4].fill(marker);
}
copied += shared.copy_region_from(&source, WIDTH, region).unwrap();
let update = TextureUpdate::pack(&source, WIDTH, region).unwrap();
copied += update.pixels.len();
uploaded += update.pixels.len();
assert_eq!(update.pixels.len(), 48 * 48 * 4);
}
assert_eq!(identity, shader_reference.allocation_identity());
assert_eq!(copied, 120 * 48 * 48 * 4 * 2);
assert_eq!(uploaded, 120 * 48 * 48 * 4);
assert_eq!(&*shared.read(), &source);
}
/// Reproduces why the removed copy-on-write fallback cloned 33 MB per ordinary 4K update.
#[test]
fn legacy_arc_copy_on_write_reproduction_is_full_canvas() {
let mut pixels = Arc::new(vec![0u8; FULL_BYTES]);
let _shader_reference = pixels.clone();
assert!(Arc::get_mut(&mut pixels).is_none());
let fallback = Arc::new(pixels.as_ref().clone());
assert_eq!(fallback.len(), 33_177_600);
}
/// Confirms packed uploads preserve exact rows and reject malformed rectangles.
#[test]
fn packed_update_is_exact_and_bounds_checked() {
let source: Vec<u8> = (0..6 * 4 * 4).map(|value| value as u8).collect();
let update = TextureUpdate::pack(&source, 6, [2, 1, 5, 3]).unwrap();
assert_eq!(update.width(), 3);
assert_eq!(update.height(), 2);
assert_eq!(update.bytes_per_row, 12);
assert_eq!(&update.pixels[..12], &source[32..44]);
assert_eq!(&update.pixels[12..], &source[56..68]);
assert!(TextureUpdate::pack(&source, 6, [5, 1, 2, 3]).is_err());
assert!(TextureUpdate::pack(&source, 6, [0, 0, 7, 1]).is_err());
assert!(TextureUpdate::pack(&source, 6, [0, 0, 1, 5]).is_err());
}
/// Reports repeatable GUI staging latency and byte volume for a 4K stroke workload.
#[test]
#[ignore = "diagnostic benchmark; run with --ignored --nocapture"]
fn diagnostic_4k_dirty_staging_latency() {
let mut source = vec![0u8; FULL_BYTES];
let shared = SharedCompositePixels::new(source.clone());
let mut samples = Vec::with_capacity(120);
let mut dirty_bytes = 0usize;
for sample in 0..130u32 {
let x0 = 200 + sample * 8;
let region = [x0, 700, x0 + 48, 748];
let start = std::time::Instant::now();
for y in region[1]..region[3] {
let offset = ((y * WIDTH + region[0]) * 4) as usize;
source[offset..offset + 48 * 4].fill(sample as u8);
}
shared.copy_region_from(&source, WIDTH, region).unwrap();
let update = TextureUpdate::pack(&source, WIDTH, region).unwrap();
if sample >= 10 {
samples.push(start.elapsed().as_secs_f64() * 1000.0);
dirty_bytes += update.pixels.len();
}
}
samples.sort_by(f64::total_cmp);
let percentile = |fraction: f64| {
let index = ((samples.len() - 1) as f64 * fraction).round() as usize;
samples[index]
};
let legacy_bytes = samples.len() * FULL_BYTES;
println!(
"4K iced staging: p50={:.3}ms p95={:.3}ms max={:.3}ms dirty={}B legacy_cow={}B reduction={:.0}x",
percentile(0.5),
percentile(0.95),
samples[samples.len() - 1],
dirty_bytes,
legacy_bytes,
legacy_bytes as f64 / dirty_bytes as f64,
);
assert_eq!(dirty_bytes, 120 * 48 * 48 * 4);
}
}