fix: optimize brush stroke handling and improve cursor preview

- Changed default VISION_BACKEND from CPU to GPU for better performance.
- Made `map_brush_style` public to allow external access.
- Updated `Engine` struct to use pooled buffers for stroke snapshots, reducing memory allocations during brush strokes.
- Introduced `CompositeSnapshot` for efficient background compositing without blocking the UI thread.
- Implemented `SendPtr` for safe raw pointer handling across threads.
- Enhanced `commit_pending_history` to recycle buffers and improve performance.
- Fixed cursor preview issues for procedural brushes by removing unnecessary preset synchronization.
- Added tests and documentation for new features and performance improvements.
This commit is contained in:
2026-07-11 08:53:17 +03:00
parent 089ce00b49
commit 0d1b0eae4c
14 changed files with 1021 additions and 305 deletions
+190 -63
View File
@@ -25,23 +25,97 @@ use hcie_tile::TiledLayer;
use crate::Engine;
use crate::dynamic_loader::tiled;
/// Wrapper to send a raw pixel pointer to a background thread as a `usize`.
///
/// # Safety Invariant
///
/// The pointer is valid for the entire lifetime of the `Engine`. The background
/// thread only reads from it, and no mutations happen to `layer.pixels` between
/// `end_stroke()` and the next `begin_stroke()`.
struct SendPtr(usize);
impl SendPtr {
#[inline]
fn new(p: *const u8) -> Self { Self(p as usize) }
#[inline]
fn as_ptr(&self) -> *const u8 { self.0 as *const u8 }
}
// SAFETY: The usize encodes a pointer to layer.pixels which is stable for the engine lifetime.
// The background thread only reads, never writes.
unsafe impl Send for SendPtr {}
pub struct PendingHistoryItem {
pub layer_idx: usize,
pub before_pixels: Vec<u8>,
pub after_pixels: Vec<u8>,
pub bounds: Option<[u32; 4]>,
pub before_shapes: Option<Vec<hcie_protocol::VectorShape>>,
pub after_shapes: Option<Vec<hcie_protocol::VectorShape>>,
pub description: String,
/// Full-layer before buffer returned from background thread for pool reuse.
pub return_before: Option<Vec<u8>>,
/// Full-layer after buffer returned from background thread for pool reuse.
pub return_after: Option<Vec<u8>>,
}
impl Engine {
/// Commit any pending history items computed in the background thread.
/// This should be polled on the UI thread.
///
/// Also recycles returned buffers back into the engine's pool to avoid
/// allocations on subsequent strokes.
pub fn commit_pending_history(&mut self) {
let items = {
let mut pending = self.pending_history.lock().unwrap();
if pending.is_empty() {
return;
}
std::mem::take(&mut *pending)
};
for item in items {
// Recycle returned before buffer back into the pool
if let Some(buf) = item.return_before {
if self.stroke_before_buf.as_ref().map_or(true, |b| b.len() != buf.len()) {
self.stroke_before_buf = Some(buf);
}
}
if let Some(bounds) = item.bounds {
self.document.push_draw_snapshot_subrect(
item.layer_idx,
item.before_pixels,
item.after_pixels,
(bounds[0], bounds[1], bounds[2], bounds[3]),
item.description,
);
} else {
// If bounds are None, perform full-layer snapshot using the cached after pixels.
// We use document's internal layers state to push the action directly.
self.document.push_draw_snapshot(
item.layer_idx,
item.before_pixels,
item.before_shapes,
item.description,
);
}
}
}
/// Begin a new brush stroke on the given layer.
///
/// # Allocation Behaviour
///
/// This function performs two allocations per call:
/// This function performs two pooled buffer operations per call:
///
/// 1. `active_stroke_mask` — **Pooled**: if the existing buffer matches
/// `layer_size`, it is zeroed in-place with `fill(0)`. Only if the
/// canvas dimensions changed does a fresh `vec![0u8; layer_size]`
/// allocation occur (~8MB on 4K). The buffer is retained across strokes.
///
/// 2. `stroke_before` — **NOT pooled**: `layer.pixels.clone()` allocates a
/// fresh ~33MB buffer on every call. This buffer is consumed by
/// `push_draw_snapshot()` during `end_stroke()` and becomes owned by the
/// undo history. Pooling would require the history crate to
/// participate in a buffer return mechanism. See struct-level doc.
/// 2. `stroke_before_buf` — **Pooled**: `layer.pixels` is copied into the
/// existing buffer via `copy_from_slice` (no allocation after first use).
/// If the buffer doesn't exist or has wrong size, a fresh allocation
/// occurs (~33MB on 4K). The buffer is returned to the pool after the
/// background thread extracts the sub-rect snapshot.
///
/// # Risk
///
@@ -53,7 +127,8 @@ impl Engine {
self.last_stroke_pos = Some((x, y, 1.0));
self.sketch_history.clear();
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
let layer_size = (layer.width * layer.height) as usize;
let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size
let layer_size = (layer.width * layer.height) as usize; // pixel count (for mask)
// Pool active_stroke_mask: reuse buffer if size matches, otherwise allocate.
// This avoids a ~16MB allocation per stroke on a 4K canvas.
@@ -80,13 +155,22 @@ impl Engine {
let y1 = ((iy + dr + 1).min(layer.height as i32 - 1)).max(0) as u32;
self.last_stroke_bounds = Some([x0, y0, x1, y1]);
let before = layer.pixels.clone();
// Pool the "before" pixel buffer: reuse if size matches, otherwise allocate.
// This avoids a ~33MB allocation per stroke on 4K canvases.
match &mut self.stroke_before_buf {
Some(buf) if buf.len() == layer_pixels => {
buf.copy_from_slice(&layer.pixels);
}
_ => {
self.stroke_before_buf = Some(layer.pixels.clone());
}
}
let before_shapes = if let LayerData::Vector { shapes } = &layer.data {
Some(shapes.clone())
} else {
None
};
self.stroke_before = Some((layer_id, before, before_shapes));
self.stroke_before = Some((layer_id, before_shapes));
// Cache selection mask once at stroke start to avoid ~8MB clone per stroke_to().
self.cached_selection_mask = self.document.selection_mask.clone();
@@ -98,79 +182,122 @@ impl Engine {
/// End the current brush stroke and commit a sub-rect undo snapshot.
///
/// Uses `last_stroke_bounds` to extract only the changed region (~100KB on a
/// 4K canvas with a typical brush) instead of cloning the entire 33MB layer.
/// The full `stroke_before` buffer is still used for draw_brush_stroke
/// reference, but the history snapshot is sub-rect.
/// Offloads the expensive sub-rect copy and vector comparison to a background
/// thread using `std::thread::spawn` so the UI remains completely responsive and free of pauses.
pub fn end_stroke(&mut self, layer_id: u64) {
log::trace!(
"[end_stroke] layer_id={}, stroke_before={}, below_cache={}, below_cache_active_idx={:?}, last_stroke_bounds={:?}",
"[end_stroke_async] layer_id={}, stroke_before={}, below_cache={}, below_cache_active_idx={:?}, last_stroke_bounds={:?}",
layer_id,
self.stroke_before.is_some(),
self.below_cache.is_some(),
self.below_cache_active_idx,
self.last_stroke_bounds
);
if let Some((id, before, before_shapes)) = self.stroke_before.take() {
if let Some((id, before_shapes)) = self.stroke_before.take() {
if id == layer_id {
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
if let Some([sx0, sy0, sx1, sy1]) = self.last_stroke_bounds {
if sx0 < sx1 && sy0 < sy1 {
let layer = &self.document.layers[idx];
let lw = layer.width;
let rw = sx1 - sx0;
let rh = sy1 - sy0;
let rect_size = (rw * rh * 4) as usize;
let mut before_rect = vec![0u8; rect_size];
let mut after_rect = vec![0u8; rect_size];
for row in 0..rh {
let src_start = (((sy0 + row) * lw + sx0) * 4) as usize;
let dst_start = (row * rw * 4) as usize;
let len = (rw * 4) as usize;
before_rect[dst_start..dst_start + len]
.copy_from_slice(&before[src_start..src_start + len]);
after_rect[dst_start..dst_start + len]
.copy_from_slice(&layer.pixels[src_start..src_start + len]);
let layer = &self.document.layers[idx];
let lw = layer.width;
let layer_pixels = (layer.width * layer.height * 4) as usize; // RGBA byte size
// Take the pooled before buffer (no allocation)
log::debug!("[end_stroke] layer_pixels={}, stroke_before_buf={:?}", layer_pixels, self.stroke_before_buf.as_ref().map(|b| b.len()));
let before = match self.stroke_before_buf.take() {
Some(buf) if buf.len() == layer_pixels => buf,
_ => vec![0u8; layer_pixels],
};
log::debug!("[end_stroke] before.len()={}, lw={}", before.len(), lw);
// Layer 3: Zero-copy after-snapshot via raw pointer.
// SAFETY: After end_stroke(), no mutations happen to layer.pixels
// until the next begin_stroke(). The background thread only reads.
let after_ptr = SendPtr::new(layer.pixels.as_ptr());
let after_len = layer.pixels.len();
let after_shapes = if let LayerData::Vector { shapes } = &layer.data {
Some(shapes.clone())
} else {
None
};
let bounds = self.last_stroke_bounds;
let pending_history = self.pending_history.clone();
std::thread::spawn(move || {
let t_start = std::time::Instant::now();
// SAFETY: after_ptr points to layer.pixels which is valid for the
// engine lifetime. No mutations happen between end_stroke() and next begin_stroke().
let after_slice = unsafe { std::slice::from_raw_parts(after_ptr.as_ptr(), after_len) };
let mut item = PendingHistoryItem {
layer_idx: idx,
before_pixels: Vec::new(),
after_pixels: Vec::new(),
bounds: None,
before_shapes,
after_shapes,
description: "Brush Stroke".to_string(),
return_before: None,
return_after: None,
};
if let Some([sx0, sy0, sx1, sy1]) = bounds {
if sx0 < sx1 && sy0 < sy1 {
let rw = sx1 - sx0;
let rh = sy1 - sy0;
let rect_size = (rw * rh * 4) as usize;
log::debug!("[end_stroke_bg] bounds=[{},{},{},{}], rw={}, rh={}, rect_size={}, before.len()={}, after.len()={}, lw={}", sx0, sy0, sx1, sy1, rw, rh, rect_size, before.len(), after_slice.len(), lw);
let mut before_rect = vec![0u8; rect_size];
let mut after_rect = vec![0u8; rect_size];
for row in 0..rh {
let src_start = (((sy0 + row) * lw + sx0) * 4) as usize;
let dst_start = (row * rw * 4) as usize;
let len = (rw * 4) as usize;
before_rect[dst_start..dst_start + len]
.copy_from_slice(&before[src_start..src_start + len]);
after_rect[dst_start..dst_start + len]
.copy_from_slice(&after_slice[src_start..src_start + len]);
}
if before_rect != after_rect {
item.before_pixels = before_rect;
item.after_pixels = after_rect;
item.bounds = Some([sx0, sy0, sx1, sy1]);
// Return before buffer to pool (after is a borrowed pointer)
item.return_before = Some(before);
pending_history.lock().unwrap().push(item);
}
log::trace!("[end_stroke_async] Background snapshot comparison took {}ms", t_start.elapsed().as_millis());
return;
}
self.document.push_draw_snapshot_subrect(
idx, before_rect, after_rect,
(sx0, sy0, sx1, sy1),
"Brush Stroke".to_string(),
);
// Stroke changed layer.pixels — invalidate effects backup so
// the next style edit captures the post-stroke raw pixels.
self.raw_pixel_backup.remove(&layer_id);
self.last_stroke_bounds = None;
self.last_stroke_pos = None;
// The active layer's pixels changed during the stroke, but
// the layers *below* the active layer did not. Retain the
// below_cache snapshot and mark it valid so the next
// begin_stroke on the same active layer can reuse it.
self.below_cache_dirty = false;
log::trace!("[end_stroke] sub-rect snapshot done, below_cache retained for reuse");
if let Some(mask) = &mut self.active_stroke_mask {
mask.fill(0);
}
self.cached_selection_mask = None;
return;
}
}
self.document.push_draw_snapshot(
idx, before, before_shapes,
"Brush Stroke".to_string(),
);
// Stroke changed layer.pixels — invalidate effects backup.
// Fallback: use full buffers as pixels (no sub-rect optimization)
item.before_pixels = before;
// SAFETY: after_slice is valid for the engine lifetime.
// Copy into owned memory for PendingHistoryItem.
item.after_pixels = after_slice.to_vec();
pending_history.lock().unwrap().push(item);
log::trace!("[end_stroke_async] Background fallback snapshot comparison took {}ms", t_start.elapsed().as_millis());
});
// Stroke changed layer.pixels — invalidate effects backup so
// the next style edit captures the post-stroke raw pixels.
self.raw_pixel_backup.remove(&layer_id);
self.last_stroke_bounds = None;
self.last_stroke_pos = None;
self.below_cache_dirty = false;
if let Some(mask) = &mut self.active_stroke_mask {
mask.fill(0);
}
self.cached_selection_mask = None;
return;
}
}
}
self.last_stroke_pos = None;
self.last_stroke_bounds = None;
// Retain the below_cache snapshot across strokes: the layers below the
// active layer did not change, so the cache is still valid for the
// same active layer index.
self.below_cache_dirty = false;
log::trace!("[end_stroke] fallback cleanup done, below_cache retained for reuse");
log::trace!("[end_stroke_async] fallback cleanup done, below_cache retained for reuse");
if let Some(mask) = &mut self.active_stroke_mask {
mask.fill(0);
}