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:
@@ -7,9 +7,9 @@ use libloading::Library;
|
||||
|
||||
/// Global vision backend preference mirrored into the dynamic `hcie-vision` plugin.
|
||||
/// 0 = CPU, 1 = GPU. Stored here so it can be set before the plugin is loaded.
|
||||
static VISION_BACKEND: AtomicU8 = AtomicU8::new(0);
|
||||
static VISION_BACKEND: AtomicU8 = AtomicU8::new(1);
|
||||
|
||||
fn map_brush_style(style: hcie_protocol::BrushStyle) -> hcie_brush_engine::BrushStyle {
|
||||
pub fn map_brush_style(style: hcie_protocol::BrushStyle) -> hcie_brush_engine::BrushStyle {
|
||||
match style {
|
||||
hcie_protocol::BrushStyle::Round => hcie_brush_engine::BrushStyle::Round,
|
||||
hcie_protocol::BrushStyle::Square => hcie_brush_engine::BrushStyle::Square,
|
||||
|
||||
+134
-11
@@ -10,7 +10,7 @@ pub mod ffi;
|
||||
|
||||
// Performance-critical engine paths isolated into dedicated modules.
|
||||
mod layer_property_ops;
|
||||
mod partial_composite;
|
||||
pub mod partial_composite;
|
||||
mod stroke_brush;
|
||||
mod stroke_cache;
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::dynamic_loader::{
|
||||
composite_layers,
|
||||
apply_filter,
|
||||
load_image, save_image, import_psd, import_kra, save_native, load_native, export_psd, export_kra,
|
||||
map_brush_style,
|
||||
};
|
||||
use crate::dynamic_loader::svg_import::import_svg;
|
||||
use hcie_tile::TiledLayer;
|
||||
@@ -133,12 +134,15 @@ fn protocol_effect_to_style(fx: &hcie_protocol::effects::LayerEffect) -> Option<
|
||||
/// a fresh allocation occurs. On `end_stroke()` the buffer is zeroed but
|
||||
/// **retained** (not set to `None`). This avoids ~8MB allocation per stroke.
|
||||
///
|
||||
/// - `stroke_before`: **NOT YET POOLED.** Currently `layer.pixels.clone()`
|
||||
/// allocates a fresh ~33MB buffer on every `begin_stroke()`. This buffer is
|
||||
/// then passed to `push_draw_snapshot()` on `end_stroke()` and owned by the
|
||||
/// history system. Pooling this buffer requires changes to both `Engine` and
|
||||
/// `hcie-history` (the snapshot stores the buffer permanently, so a pool
|
||||
/// would need a take-and-return mechanism). See PROGRESS.md item #4.
|
||||
/// - `stroke_before` + `stroke_before_buf`: Pooled.
|
||||
/// At `begin_stroke()`, `layer.pixels` is copied into `stroke_before_buf`
|
||||
/// (no allocation after first use). At `end_stroke()`, the buffer is moved
|
||||
/// into the background thread for sub-rect extraction, then returned via
|
||||
/// `PendingHistoryItem.return_before` and recycled by `commit_pending_history()`.
|
||||
/// The "after" snapshot uses a zero-copy raw pointer (Layer 3): the background
|
||||
/// thread reads directly from `layer.pixels` via a `SendPtr` wrapper, which
|
||||
/// is safe because no mutations happen between `end_stroke()` and the next
|
||||
/// `begin_stroke()`. This avoids ~66MB allocation per stroke on 4K canvases.
|
||||
///
|
||||
/// ## Merge Conflict Artifact Cleanup (2026-05-28)
|
||||
///
|
||||
@@ -172,10 +176,17 @@ pub struct Engine {
|
||||
cyclic_speed: f32,
|
||||
/// Pre-stroke snapshot for undo history: (layer_id, pixel_buffer, vector_shapes).
|
||||
///
|
||||
/// Allocated as `layer.pixels.clone()` (~33MB on 4K) on every `begin_stroke()`.
|
||||
/// Consumed by `push_draw_snapshot()` on `end_stroke()`. NOT pooled — see
|
||||
/// struct-level doc for pooling roadmap.
|
||||
stroke_before: Option<(u64, Vec<u8>, Option<Vec<VectorShape>>)>,
|
||||
/// The pixel buffer is now **pooled** in `stroke_before_buf` to avoid a
|
||||
/// ~33MB allocation per stroke on 4K canvases. Only the layer_id and
|
||||
/// vector shapes are stored here; the pixel data lives in the pooled buffer.
|
||||
stroke_before: Option<(u64, Option<Vec<VectorShape>>)>,
|
||||
/// Pooled buffer for the "before" snapshot pixel data.
|
||||
///
|
||||
/// Retained across strokes and reused if size matches. At `begin_stroke()`,
|
||||
/// `layer.pixels` is copied into this buffer (no allocation after first use).
|
||||
/// At `end_stroke()`, the buffer is moved into the background thread for
|
||||
/// sub-rect extraction, then returned via the pending_history channel.
|
||||
stroke_before_buf: Option<Vec<u8>>,
|
||||
tile_layers: Vec<Option<TiledLayer>>,
|
||||
svg_sources: std::collections::HashMap<u64, Vec<u8>>,
|
||||
filter_preview_original: Option<Vec<u8>>,
|
||||
@@ -239,6 +250,9 @@ pub struct Engine {
|
||||
/// alloc+copy per pointer event. This cache clones it once at stroke start
|
||||
/// and all stroke functions reference it instead.
|
||||
cached_selection_mask: Option<Vec<u8>>,
|
||||
/// Thread-safe queue of history items computed on background threads.
|
||||
/// Polled and committed on the UI thread via `commit_pending_history()`.
|
||||
pub pending_history: std::sync::Arc<std::sync::Mutex<Vec<crate::stroke_cache::PendingHistoryItem>>>,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
@@ -295,6 +309,7 @@ impl Engine {
|
||||
cyclic_color: false,
|
||||
cyclic_speed: 0.5,
|
||||
stroke_before: None,
|
||||
stroke_before_buf: None,
|
||||
tile_layers: Vec::new(),
|
||||
svg_sources: std::collections::HashMap::new(),
|
||||
filter_preview_original: None,
|
||||
@@ -307,6 +322,7 @@ impl Engine {
|
||||
raw_pixel_backup: std::collections::HashMap::new(),
|
||||
below_cache_dirty: true,
|
||||
cached_selection_mask: None,
|
||||
pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1470,6 +1486,113 @@ impl Engine {
|
||||
preview.get_composite_pixels()
|
||||
}
|
||||
|
||||
/// Render a single brush-dab stamp into an RGBA buffer for use as a cursor preview.
|
||||
///
|
||||
/// **Purpose:** Provides a lightweight, pixel-accurate preview of the current brush
|
||||
/// tip shape, size, hardness, opacity, and color without mutating any document.
|
||||
///
|
||||
/// **Logic & Workflow:**
|
||||
/// 1. Clamp the requested dimensions to a safe range (1..=256).
|
||||
/// 2. Use the brush engine's existing `generate_brush_stamp` to obtain the correct
|
||||
/// alpha mask for the brush style (Round, Square, Star, Bitmap, etc.).
|
||||
/// 3. For bitmap brushes, scale the imported alpha mask to the requested size.
|
||||
/// 4. Multiply the stamp alpha by the color alpha, brush opacity, and a visibility
|
||||
/// factor, then write the result into an RGBA buffer.
|
||||
///
|
||||
/// **Arguments:**
|
||||
/// * `width`, `height` — Output buffer dimensions in pixels.
|
||||
/// * `tip` — The `BrushTip` describing style, size, hardness, opacity, and bitmap data.
|
||||
/// * `color` — The RGBA color to tint the preview.
|
||||
///
|
||||
/// **Returns:** A `Vec<u8>` of size `width * height * 4` containing RGBA pixels,
|
||||
/// suitable for upload as an egui texture.
|
||||
///
|
||||
/// **Side Effects:** None; only allocates a small temporary stamp buffer.
|
||||
pub fn render_brush_stamp_preview(width: u32, height: u32, tip: &BrushTip, color: [u8; 4]) -> Vec<u8> {
|
||||
let w = width.clamp(1, 256) as usize;
|
||||
let h = height.clamp(1, 256) as usize;
|
||||
let mut buf = vec![0u8; w * h * 4];
|
||||
|
||||
let preview_alpha = (color[3] as f32 / 255.0) * tip.opacity * 0.85;
|
||||
|
||||
match tip.style {
|
||||
BrushStyle::Bitmap if !tip.bitmap_pixels.is_empty() && tip.bitmap_w > 0 && tip.bitmap_h > 0 => {
|
||||
let bw = tip.bitmap_w as f32;
|
||||
let bh = tip.bitmap_h as f32;
|
||||
for py in 0..h {
|
||||
let map_y = (py as f32 / h as f32 * (bh - 1.0))
|
||||
.round()
|
||||
.clamp(0.0, bh - 1.0) as u32;
|
||||
for px in 0..w {
|
||||
let map_x = (px as f32 / w as f32 * (bw - 1.0))
|
||||
.round()
|
||||
.clamp(0.0, bw - 1.0) as u32;
|
||||
let mask = tip.bitmap_pixels[(map_y * tip.bitmap_w + map_x) as usize] as f32
|
||||
/ 255.0;
|
||||
let a = (mask * preview_alpha * 255.0).round() as u8;
|
||||
let idx = (py * w + px) * 4;
|
||||
buf[idx] = color[0];
|
||||
buf[idx + 1] = color[1];
|
||||
buf[idx + 2] = color[2];
|
||||
buf[idx + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Build a procedural stamp via the brush engine. This correctly handles
|
||||
// Round, Square, Star, HardRound, SoftRound, and falls back to Round for
|
||||
// procedural styles whose visual identity comes from multi-dab strokes.
|
||||
// Convert the protocol BrushTip to the engine's internal type.
|
||||
let engine_tip = hcie_brush_engine::BrushTip {
|
||||
style: map_brush_style(tip.style),
|
||||
size: tip.size,
|
||||
opacity: tip.opacity,
|
||||
hardness: tip.hardness,
|
||||
spacing: tip.spacing,
|
||||
flow: tip.flow,
|
||||
jitter_amount: tip.jitter_amount,
|
||||
scatter_amount: tip.scatter_amount,
|
||||
angle: tip.angle,
|
||||
roundness: tip.roundness,
|
||||
spray_particle_size: tip.spray_particle_size,
|
||||
spray_density: tip.spray_density,
|
||||
bitmap_pixels: tip.bitmap_pixels.clone(),
|
||||
bitmap_w: tip.bitmap_w,
|
||||
bitmap_h: tip.bitmap_h,
|
||||
color_variant: tip.color_variant,
|
||||
variant_amount: tip.variant_amount,
|
||||
density: tip.density,
|
||||
drawing_angle: false,
|
||||
rotation_random: 0.0,
|
||||
};
|
||||
let stamp = hcie_brush_engine::generate_brush_stamp(&engine_tip);
|
||||
let stamp_d = (stamp.len() as f32).sqrt().round() as usize;
|
||||
if stamp_d == 0 {
|
||||
return buf;
|
||||
}
|
||||
for py in 0..h {
|
||||
let map_y = (py as f32 / h as f32 * (stamp_d as f32 - 1.0))
|
||||
.round()
|
||||
.clamp(0.0, stamp_d as f32 - 1.0) as usize;
|
||||
for px in 0..w {
|
||||
let map_x = (px as f32 / w as f32 * (stamp_d as f32 - 1.0))
|
||||
.round()
|
||||
.clamp(0.0, stamp_d as f32 - 1.0) as usize;
|
||||
let mask = stamp[map_y * stamp_d + map_x] as f32 / 255.0;
|
||||
let a = (mask * preview_alpha * 255.0).round() as u8;
|
||||
let idx = (py * w + px) * 4;
|
||||
buf[idx] = color[0];
|
||||
buf[idx + 1] = color[1];
|
||||
buf[idx + 2] = color[2];
|
||||
buf[idx + 3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn rename_layer(&mut self, id: u64, name: &str) {
|
||||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||||
l.name = name.to_string();
|
||||
|
||||
@@ -319,3 +319,103 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of layer data needed for background compositing.
|
||||
///
|
||||
/// # Purpose
|
||||
/// Captures all state required to composite the canvas without borrowing
|
||||
/// `&mut Engine`. The background thread uses this snapshot to compute the
|
||||
/// composite independently of the UI thread.
|
||||
///
|
||||
/// # Logic & Workflow
|
||||
/// 1. `Engine::create_composite_snapshot()` clones layer pixels, properties,
|
||||
/// and tile data into this struct.
|
||||
/// 2. `composite_from_snapshot()` reads the snapshot and produces the flat
|
||||
/// RGBA composite buffer on a background thread.
|
||||
pub struct CompositeSnapshot {
|
||||
pub layers: Vec<hcie_protocol::Layer>,
|
||||
pub tile_layers: Vec<Option<TiledLayer>>,
|
||||
pub canvas_width: u32,
|
||||
pub canvas_height: u32,
|
||||
pub active_layer: usize,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
/// Create a snapshot of the current layer state for background compositing.
|
||||
///
|
||||
/// # Purpose
|
||||
/// Clones all layer pixels, properties, and tile data so a background
|
||||
/// thread can compute the composite without borrowing the engine.
|
||||
///
|
||||
/// # Side Effects
|
||||
/// Calls `apply_effects_and_sync_tiles()` to ensure dirty layers are
|
||||
/// up-to-date before cloning. This mutates engine state but is safe
|
||||
/// because it's called on the UI thread before spawning the background.
|
||||
pub fn create_composite_snapshot(&mut self) -> CompositeSnapshot {
|
||||
// Ensure all dirty layers are up-to-date before cloning
|
||||
self.apply_effects_and_sync_tiles();
|
||||
|
||||
let layers = self.document.layers.clone();
|
||||
let tile_layers = self.tile_layers.clone();
|
||||
let canvas_width = self.document.canvas_width;
|
||||
let canvas_height = self.document.canvas_height;
|
||||
let active_layer = self.document.active_layer;
|
||||
|
||||
CompositeSnapshot {
|
||||
layers,
|
||||
tile_layers,
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
active_layer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the flat composite RGBA buffer from a snapshot.
|
||||
///
|
||||
/// # Purpose
|
||||
/// Performs the full canvas composite on a background thread using the
|
||||
/// cloned layer data from `CompositeSnapshot`. This function is standalone
|
||||
/// (not a method on `Engine`) so it can be called from a background thread
|
||||
/// without borrowing the engine.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `snapshot` — Cloned layer data from `create_composite_snapshot()`.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Vec<u8>` of size `width * height * 4` containing the flat RGBA composite.
|
||||
pub fn composite_from_snapshot(snapshot: CompositeSnapshot) -> Vec<u8> {
|
||||
let w = snapshot.canvas_width;
|
||||
let h = snapshot.canvas_height;
|
||||
let buf_size = (w * h * 4) as usize;
|
||||
|
||||
// Build tile slices for the compositor
|
||||
let tile_slice_len = snapshot.active_layer.min(snapshot.tile_layers.len());
|
||||
let _visible_below: Vec<(usize, bool)> = snapshot.layers[..snapshot.active_layer]
|
||||
.iter().enumerate().map(|(i, l)| (i, l.visible)).collect();
|
||||
|
||||
let mut buf = vec![0u8; buf_size];
|
||||
|
||||
// Composite below layers first (if any)
|
||||
if snapshot.active_layer > 0 {
|
||||
tiled::composite_tiled_into(
|
||||
&snapshot.layers[..snapshot.active_layer],
|
||||
&snapshot.tile_layers[..tile_slice_len],
|
||||
w, h,
|
||||
0, 0, w, h,
|
||||
&mut buf,
|
||||
);
|
||||
}
|
||||
|
||||
// Composite active layer and above
|
||||
let tile_start = snapshot.active_layer.min(snapshot.tile_layers.len());
|
||||
tiled::composite_tiled_into(
|
||||
&snapshot.layers[snapshot.active_layer..],
|
||||
&snapshot.tile_layers[tile_start..],
|
||||
w, h,
|
||||
0, 0, w, h,
|
||||
&mut buf,
|
||||
);
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ impl Engine {
|
||||
tip.spray_density,
|
||||
mask_ref,
|
||||
self.active_stroke_mask.as_deref_mut(),
|
||||
self.stroke_before.as_ref().map(|(_, px, _)| px.as_slice()),
|
||||
self.stroke_before_buf.as_deref(),
|
||||
tip.color_variant,
|
||||
tip.variant_amount,
|
||||
tip.density,
|
||||
@@ -134,8 +134,12 @@ impl Engine {
|
||||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
let dirty_r = match tip.style {
|
||||
BrushStyle::Star | BrushStyle::Spray => tip.size * 3.2,
|
||||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.0,
|
||||
// Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint.
|
||||
BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8,
|
||||
// Meadow/Leaf draw blades/leaves upward from center — asymmetric extent.
|
||||
BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0,
|
||||
// Clouds/Tree scatter particles in a wide area.
|
||||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5,
|
||||
_ => tip.size.max(2.0),
|
||||
};
|
||||
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
||||
@@ -159,7 +163,7 @@ impl Engine {
|
||||
self.is_eraser,
|
||||
mask_ref,
|
||||
self.active_stroke_mask.as_deref_mut(),
|
||||
self.stroke_before.as_ref().map(|(_, px, _)| px.as_slice()),
|
||||
self.stroke_before_buf.as_deref(),
|
||||
);
|
||||
layer.dirty = true;
|
||||
// Avoid triggering the expensive effects pass for layers that
|
||||
@@ -267,8 +271,12 @@ impl Engine {
|
||||
self.document.composite_dirty = true;
|
||||
self.document.modified = true;
|
||||
let dirty_r = match tip.style {
|
||||
BrushStyle::Star | BrushStyle::Spray => tip.size * 3.2,
|
||||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.0,
|
||||
// Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint.
|
||||
BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8,
|
||||
// Meadow/Leaf draw blades/leaves upward from center — asymmetric extent.
|
||||
BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0,
|
||||
// Clouds/Tree scatter particles in a wide area.
|
||||
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5,
|
||||
_ => tip.size.max(2.0),
|
||||
};
|
||||
for &(px, py, _) in points {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user