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:
+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();
|
||||
|
||||
Reference in New Issue
Block a user