feat: optimize GUI rendering with GPU persistent textures, incremental dirty-region updates, and coordinate-aligned viewport mapping
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user