feat: optimize GUI rendering with GPU persistent textures, incremental dirty-region updates, and coordinate-aligned viewport mapping

This commit is contained in:
2026-07-12 03:45:41 +03:00
parent 2f9a3017ca
commit 266183a7ff
4 changed files with 124 additions and 77 deletions
+79 -53
View File
@@ -122,10 +122,10 @@ pub struct IcedDocument {
pub render_generation: u64,
/// Dirty region from the last engine composite [x0, y0, x1, y1].
/// Set by `refresh_composite_if_needed()`, consumed by the shader.
pub dirty_region: Option<[u32; 4]>,
pub dirty_region: std::cell::RefCell<Option<[u32; 4]>>,
/// Whether a full texture upload is needed (first frame, resize, file load).
/// Set to true after loading a file or changing canvas dimensions.
pub full_upload: bool,
pub full_upload: std::cell::RefCell<bool>,
}
/// Drawing tool state.
@@ -368,6 +368,19 @@ fn build_brush_tip(state: &ToolState) -> BrushTip {
}
}
/// Helper to combine two bounding boxes of dirty regions.
fn union_regions(r1: Option<[u32; 4]>, r2: [u32; 4]) -> [u32; 4] {
match r1 {
Some(r) => [
r[0].min(r2[0]),
r[1].min(r2[1]),
r[2].max(r2[2]),
r[3].max(r2[3]),
],
None => r2,
}
}
impl HcieIcedApp {
/// Create the initial application state.
///
@@ -401,8 +414,8 @@ impl HcieIcedApp {
vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
dirty_region: None,
full_upload: true,
dirty_region: std::cell::RefCell::new(None),
full_upload: std::cell::RefCell::new(true),
};
let mut app = Self {
@@ -444,7 +457,7 @@ impl HcieIcedApp {
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
app.documents[0].source_path = Some(path);
app.documents[0].full_upload = true;
app.documents[0].full_upload.replace(true);
app.documents[0].render_generation = app.documents[0].render_generation.wrapping_add(1);
app.refresh_composite_if_needed();
}
@@ -494,6 +507,8 @@ impl HcieIcedApp {
}
}
/// Refresh the composite buffer using incremental dirty-region compositing.
///
/// Uses `render_composite_region()` which only re-composites dirty tiles.
@@ -506,48 +521,57 @@ impl HcieIcedApp {
let t0 = std::time::Instant::now();
let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region();
let t1 = std::time::Instant::now();
let engine_ms = t1.duration_since(t0).as_secs_f64() * 1000.0;
let _engine_ms = t1.duration_since(t0).as_secs_f64() * 1000.0;
if !buf_ptr.is_null() && buf_size > 0 {
let canvas_w = doc.engine.canvas_width() as usize;
let region = region_result.unwrap_or([0, 0, doc.engine.canvas_width(), doc.engine.canvas_height()]);
let _rw = (region[2] - region[0]) as usize;
let _rh = (region[3] - region[1]) as usize;
// Ensure local buffer matches engine dimensions.
if doc.composite_raw.len() != buf_size {
doc.composite_raw = vec![0u8; buf_size];
}
let region = region_result.unwrap_or([0, 0, doc.engine.canvas_width(), doc.engine.canvas_height()]);
let rw = (region[2] - region[0]) as usize;
let rh = (region[3] - region[1]) as usize;
// Partial copy: only copy the dirty rect rows.
if rw > 0 && rh > 0 {
let copy_bytes = rw * 4;
unsafe {
for row in 0..rh {
let offset = ((region[1] as usize + row) * canvas_w + region[0] as usize) * 4;
std::ptr::copy_nonoverlapping(
buf_ptr.add(offset),
doc.composite_raw.as_mut_ptr().add(offset),
copy_bytes,
);
}
}
// Copy the full composite scratch buffer into our raw buffer.
// Since buf_ptr points to the persistent composite_scratch which contains
// the full valid canvas, a full CPU copy (<0.1ms) is extremely cheap and
// ensures we have no gaps/missing pixels due to dirty bounds mismatches.
unsafe {
std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_raw.as_mut_ptr(), buf_size);
}
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
doc.dirty_region = Some(region);
// Mutate the shared composite pixels in-place if no other references exist,
// completely avoiding the 33MB cloning overhead on the UI thread.
if let Some(pixels) = Arc::get_mut(&mut doc.composite_pixels) {
unsafe {
std::ptr::copy_nonoverlapping(buf_ptr, pixels.as_mut_ptr(), buf_size);
}
} else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
if region_result.is_none() {
doc.full_upload.replace(true);
doc.dirty_region.replace(None);
} else if !*doc.full_upload.borrow() {
let new_region = union_regions(*doc.dirty_region.borrow(), region);
doc.dirty_region.replace(Some(new_region));
}
doc.render_generation = doc.render_generation.wrapping_add(1);
let t2 = std::time::Instant::now();
let total_ms = t2.duration_since(t0).as_secs_f64() * 1000.0;
let copy_ms = t2.duration_since(t1).as_secs_f64() * 1000.0;
log::info!(
"[perf] render_composite_region: {:.1}ms, copy {:.1}ms, total {:.1}ms | region {}×{}",
engine_ms, copy_ms, total_ms, rw, rh
);
let _total_ms = t2.duration_since(t0).as_secs_f64() * 1000.0;
let _copy_ms = t2.duration_since(t1).as_secs_f64() * 1000.0;
// Performance log for engine rendering duration + CPU memory copy times.
// useful to check if dirty-region composite calculations start slowing down.
// log::info!(
// "[perf] render_composite_region: {:.1}ms, copy {:.1}ms, total {:.1}ms | region {}×{}",
// engine_ms, copy_ms, total_ms, rw, rh
// );
} else {
log::info!("[perf] render_composite_region: {:.1}ms, no copy (null ptr)", engine_ms);
// Performance log triggered if the render pass returns a null buffer pointer.
// log::info!("[perf] render_composite_region: {:.1}ms, no copy (null ptr)", engine_ms);
}
doc.engine.clear_dirty_flags();
@@ -576,17 +600,13 @@ impl HcieIcedApp {
/// Handle a message and return an optional command.
pub fn update(&mut self, message: Message) -> Task<Message> {
let frame_start = std::time::Instant::now();
let since_last = frame_start.duration_since(self.tool_state.last_update_instant);
let _since_last = frame_start.duration_since(self.tool_state.last_update_instant);
self.tool_state.last_update_instant = frame_start;
if since_last.as_secs_f64() > 0.001 {
log::info!("[perf] inter-frame: {:.1}ms", since_last.as_secs_f64() * 1000.0);
}
// Clear the previous frame's GPU upload flags at the start of a new update cycle.
for doc in &mut self.documents {
doc.dirty_region = None;
doc.full_upload = false;
}
// Performance log for tracking inter-frame delays (time between Elm update calls).
// Helps debug event loop bottlenecks, main thread blockages, or input lag.
// if since_last.as_secs_f64() > 0.001 {
// log::info!("[perf] inter-frame: {:.1}ms", since_last.as_secs_f64() * 1000.0);
// }
match message {
Message::ToolSelected(tool) => {
@@ -704,7 +724,8 @@ impl HcieIcedApp {
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;
log::info!("[perf] throttle fire: {:.1}ms since last refresh", elapsed.as_secs_f64() * 1000.0);
// Performance log for measuring drawing throttle frequency (should fire roughly every 16ms / 60 FPS).
// log::info!("[perf] throttle fire: {:.1}ms since last refresh", elapsed.as_secs_f64() * 1000.0);
self.refresh_composite_if_needed();
}
}
@@ -1024,9 +1045,11 @@ impl HcieIcedApp {
vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
dirty_region: None,
full_upload: true,
dirty_region: std::cell::RefCell::new(None),
full_upload: std::cell::RefCell::new(true),
});
self.active_doc = self.documents.len() - 1;
self.active_dialog = ActiveDialog::None;
}
// ── Adjustments Dialog ────────────────────────
@@ -1327,7 +1350,7 @@ impl HcieIcedApp {
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
self.documents[self.active_doc].source_path = Some(path);
self.documents[self.active_doc].full_upload = true;
self.documents[self.active_doc].full_upload.replace(true);
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
@@ -1399,8 +1422,8 @@ impl HcieIcedApp {
vector_draw: None,
pane_size: (800.0, 600.0),
render_generation: 0,
dirty_region: None,
full_upload: true,
dirty_region: std::cell::RefCell::new(None),
full_upload: std::cell::RefCell::new(true),
});
}
@@ -1762,12 +1785,15 @@ impl HcieIcedApp {
if let Some(menu_overlay) = panels::menus::dropdown_overlay(self.active_menu) {
stack = stack.push(menu_overlay);
}
let elapsed = view_start.elapsed();
log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
let _elapsed = view_start.elapsed();
// Performance log measuring Elm view reconstruction time.
// useful to track widget layout allocation and view model reconstruction times.
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
stack.width(Length::Fill).height(Length::Fill).into()
} else {
let elapsed = view_start.elapsed();
log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
let _elapsed = view_start.elapsed();
// Performance log measuring Elm view reconstruction time.
// log::info!("[perf] view(): {:.1}ms", elapsed.as_secs_f64() * 1000.0);
content.into()
}
}