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:
@@ -42,6 +42,16 @@ pub struct AppDocument {
|
||||
/// The last egui Context used to render/upload this document's GPU textures.
|
||||
/// Used to detect when a panel is floated/docked (context change) and re-upload textures.
|
||||
pub last_ctx: Option<egui::Context>,
|
||||
/// When true, layer thumbnails need regeneration on the next frame.
|
||||
/// Deferring thumbnail generation by one frame avoids cloning ~33MB per
|
||||
/// dirty layer on the same frame the composite runs.
|
||||
/// When true, layer thumbnails need regeneration on the next frame.
|
||||
/// Deferring thumbnail generation by one frame avoids cloning ~33MB per
|
||||
/// dirty layer on the same frame the composite runs.
|
||||
pub thumbnails_dirty: bool,
|
||||
/// Tracks when the last stroke ended so thumbnail regeneration can be
|
||||
/// deferred until the user has been idle (no drawing) for a short period.
|
||||
pub last_stroke_end_time: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl AppDocument {
|
||||
@@ -68,6 +78,8 @@ impl AppDocument {
|
||||
selection_edge_cache: SelectionEdgeCache::default(),
|
||||
pan_offset: egui::Vec2::ZERO,
|
||||
last_ctx: None,
|
||||
thumbnails_dirty: false,
|
||||
last_stroke_end_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5126,8 +5126,17 @@ impl eframe::App for HcieApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-save interval for eframe storage persistence.
|
||||
///
|
||||
/// **IMPORTANT**: eframe calls `save()` on the UI thread at this interval.
|
||||
/// `save()` serializes all settings, brush presets (including ABR bitmap
|
||||
/// data), dock state, and writes them to disk. A 5-second interval caused
|
||||
/// ~500ms periodic freezes during active drawing because the serialization
|
||||
/// blocked the frame loop. 120 seconds is a safe trade-off: settings are
|
||||
/// still persisted on explicit events (SaveSettings), and the auto-save
|
||||
/// catches any unsaved state on idle.
|
||||
fn auto_save_interval(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(5)
|
||||
std::time::Duration::from_secs(120)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,11 @@ pub struct ToolState {
|
||||
pub brush_thumbnail_cache: std::collections::HashMap<String, egui::TextureHandle>,
|
||||
/// When true, all cached thumbnails are regenerated on the next frame.
|
||||
pub brush_thumbnail_dirty: bool,
|
||||
/// Frame counter used by `render_composition` to throttle compositing
|
||||
/// during active drawing. Composite runs every other frame (even counts)
|
||||
/// to keep strokes visible in real-time while limiting performance cost.
|
||||
/// Reset to 0 when drawing ends.
|
||||
pub drawing_composite_skip_counter: u32,
|
||||
}
|
||||
|
||||
impl Default for ToolState {
|
||||
@@ -206,6 +211,7 @@ impl Default for ToolState {
|
||||
brush_resize_start_pos: None,
|
||||
brush_thumbnail_cache: std::collections::HashMap::new(),
|
||||
brush_thumbnail_dirty: true,
|
||||
drawing_composite_skip_counter: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::app::{tools::shape_sync, AppDocument, SelectionTransform, ToolState,
|
||||
use crate::event_bus::{AppEvent, EventBus};
|
||||
use eframe::egui;
|
||||
use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN};
|
||||
use hcie_engine_api::brush::BrushStyle;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
pub mod render;
|
||||
pub mod text_overlay;
|
||||
@@ -488,6 +488,15 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
let layer_id = self.doc.engine.active_layer_id();
|
||||
if self.state.active_tool != Tool::Pen {
|
||||
self.doc.engine.begin_stroke(layer_id, ux as f32, uy as f32);
|
||||
// Draw single dab on click (even without movement).
|
||||
// The spacing check in the drag handler prevents
|
||||
// re-drawing the same dab on the first move event.
|
||||
self.doc.engine.stroke_to(
|
||||
layer_id,
|
||||
ux as f32,
|
||||
uy as f32,
|
||||
self.state.current_pressure,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1086,8 +1095,11 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
}
|
||||
}
|
||||
Tool::Pen | Tool::Brush | Tool::Eraser | Tool::Spray => {
|
||||
let t_stroke_end = std::time::Instant::now();
|
||||
let layer_id = self.doc.engine.active_layer_id();
|
||||
self.doc.engine.end_stroke(layer_id);
|
||||
log::warn!("[PERF] self.doc.engine.end_stroke took {}ms", t_stroke_end.elapsed().as_millis());
|
||||
self.doc.last_stroke_end_time = Some(std::time::Instant::now());
|
||||
|
||||
let desc = match self.state.active_tool {
|
||||
Tool::Pen => "Pen Stroke".to_string(),
|
||||
@@ -1430,151 +1442,203 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
}
|
||||
|
||||
// ── Brush cursor preview ─────────────────────────────────
|
||||
// Renders the active brush tip as a single-dab stamp texture. The texture is
|
||||
// cached by style, size, hardness, opacity, color, and zoom, and is regenerated
|
||||
// only when one of those parameters changes.
|
||||
if let Some((cx, cy)) = self.state.cursor_canvas_pos {
|
||||
if self.state.active_tool.shows_pen_tips() {
|
||||
let screen_x = canvas_rect.min.x + cx as f32 * zoom;
|
||||
let screen_y = canvas_rect.min.y + cy as f32 * zoom;
|
||||
let cursor_color = egui::Color32::from_rgba_unmultiplied(
|
||||
self.state.primary_color[0],
|
||||
self.state.primary_color[1],
|
||||
self.state.primary_color[2],
|
||||
120,
|
||||
);
|
||||
let active_tip = self
|
||||
.state
|
||||
.brush_presets
|
||||
.iter()
|
||||
.find(|p| p.id == self.state.active_brush_preset_id)
|
||||
.map(|p| &p.tip);
|
||||
if let Some(tip) = active_tip {
|
||||
match tip.style {
|
||||
BrushStyle::Bitmap
|
||||
if !tip.bitmap_pixels.is_empty()
|
||||
&& tip.bitmap_w > 0
|
||||
&& tip.bitmap_h > 0 =>
|
||||
|
||||
// Build a live tip from the active tool config so the preview matches
|
||||
// the Properties panel in real time.
|
||||
let mut tip = hcie_engine_api::BrushTip::default();
|
||||
match self.state.active_tool {
|
||||
Tool::Pen => {
|
||||
tip.style = hcie_engine_api::BrushStyle::Round;
|
||||
tip.size = self.state.tool_configs.pen.size;
|
||||
tip.opacity = self.state.tool_configs.pen.opacity;
|
||||
tip.hardness = self.state.tool_configs.pen.hardness;
|
||||
}
|
||||
Tool::Eraser => {
|
||||
tip.style = hcie_engine_api::BrushStyle::Round;
|
||||
tip.size = self.state.tool_configs.eraser.size;
|
||||
tip.opacity = self.state.tool_configs.eraser.opacity;
|
||||
tip.hardness = self.state.tool_configs.eraser.hardness;
|
||||
}
|
||||
Tool::Spray => {
|
||||
tip.style = hcie_engine_api::BrushStyle::Spray;
|
||||
tip.size = self.state.tool_configs.spray.radius;
|
||||
tip.opacity = self.state.tool_configs.spray.opacity;
|
||||
tip.hardness = self.state.tool_configs.spray.hardness;
|
||||
}
|
||||
Tool::Brush | _ => {
|
||||
tip.style = match self.state.tool_configs.brush.style {
|
||||
hcie_engine_api::tools::BrushStyle::Default => hcie_engine_api::BrushStyle::Round,
|
||||
hcie_engine_api::tools::BrushStyle::Round => hcie_engine_api::BrushStyle::Round,
|
||||
hcie_engine_api::tools::BrushStyle::Square => hcie_engine_api::BrushStyle::Square,
|
||||
hcie_engine_api::tools::BrushStyle::HardRound => hcie_engine_api::BrushStyle::HardRound,
|
||||
hcie_engine_api::tools::BrushStyle::SoftRound => hcie_engine_api::BrushStyle::SoftRound,
|
||||
hcie_engine_api::tools::BrushStyle::Noise => hcie_engine_api::BrushStyle::Noise,
|
||||
hcie_engine_api::tools::BrushStyle::Texture => hcie_engine_api::BrushStyle::Texture,
|
||||
hcie_engine_api::tools::BrushStyle::Spray => hcie_engine_api::BrushStyle::Spray,
|
||||
hcie_engine_api::tools::BrushStyle::Pen => hcie_engine_api::BrushStyle::Pen,
|
||||
hcie_engine_api::tools::BrushStyle::Oil => hcie_engine_api::BrushStyle::Oil,
|
||||
hcie_engine_api::tools::BrushStyle::Charcoal => hcie_engine_api::BrushStyle::Charcoal,
|
||||
hcie_engine_api::tools::BrushStyle::Leaf => hcie_engine_api::BrushStyle::Leaf,
|
||||
hcie_engine_api::tools::BrushStyle::Rock => hcie_engine_api::BrushStyle::Rock,
|
||||
hcie_engine_api::tools::BrushStyle::Meadow => hcie_engine_api::BrushStyle::Meadow,
|
||||
hcie_engine_api::tools::BrushStyle::Wood => hcie_engine_api::BrushStyle::Wood,
|
||||
hcie_engine_api::tools::BrushStyle::Watercolor => hcie_engine_api::BrushStyle::Watercolor,
|
||||
hcie_engine_api::tools::BrushStyle::Calligraphy => hcie_engine_api::BrushStyle::Calligraphy,
|
||||
hcie_engine_api::tools::BrushStyle::Marker => hcie_engine_api::BrushStyle::Marker,
|
||||
hcie_engine_api::tools::BrushStyle::Sketch => hcie_engine_api::BrushStyle::Sketch,
|
||||
hcie_engine_api::tools::BrushStyle::Hatch => hcie_engine_api::BrushStyle::Hatch,
|
||||
hcie_engine_api::tools::BrushStyle::Star => hcie_engine_api::BrushStyle::Star,
|
||||
hcie_engine_api::tools::BrushStyle::Glow => hcie_engine_api::BrushStyle::Glow,
|
||||
hcie_engine_api::tools::BrushStyle::Airbrush => hcie_engine_api::BrushStyle::Airbrush,
|
||||
hcie_engine_api::tools::BrushStyle::Pencil => hcie_engine_api::BrushStyle::Pencil,
|
||||
hcie_engine_api::tools::BrushStyle::Crayon => hcie_engine_api::BrushStyle::Crayon,
|
||||
hcie_engine_api::tools::BrushStyle::WetPaint => hcie_engine_api::BrushStyle::WetPaint,
|
||||
hcie_engine_api::tools::BrushStyle::InkPen => hcie_engine_api::BrushStyle::InkPen,
|
||||
hcie_engine_api::tools::BrushStyle::Clouds => hcie_engine_api::BrushStyle::Clouds,
|
||||
hcie_engine_api::tools::BrushStyle::Dirt => hcie_engine_api::BrushStyle::Dirt,
|
||||
hcie_engine_api::tools::BrushStyle::Tree => hcie_engine_api::BrushStyle::Tree,
|
||||
hcie_engine_api::tools::BrushStyle::Bristle => hcie_engine_api::BrushStyle::Bristle,
|
||||
hcie_engine_api::tools::BrushStyle::Mixer => hcie_engine_api::BrushStyle::Mixer,
|
||||
hcie_engine_api::tools::BrushStyle::Blender => hcie_engine_api::BrushStyle::Blender,
|
||||
hcie_engine_api::tools::BrushStyle::Bitmap => hcie_engine_api::BrushStyle::Bitmap,
|
||||
};
|
||||
tip.size = self.state.tool_configs.brush.size;
|
||||
tip.opacity = self.state.tool_configs.brush.opacity;
|
||||
tip.hardness = self.state.tool_configs.brush.hardness;
|
||||
tip.spacing = self.state.tool_configs.brush.spacing;
|
||||
tip.jitter_amount = self.state.tool_configs.brush.jitter_amount;
|
||||
tip.scatter_amount = self.state.tool_configs.brush.scatter_amount;
|
||||
tip.angle = self.state.tool_configs.brush.angle;
|
||||
tip.roundness = self.state.tool_configs.brush.roundness;
|
||||
tip.density = self.state.tool_configs.brush.density;
|
||||
tip.color_variant = self.state.tool_configs.brush.color_variant;
|
||||
tip.variant_amount = self.state.tool_configs.brush.variant_amount;
|
||||
tip.drawing_angle = self.state.tool_configs.brush.drawing_angle;
|
||||
tip.rotation_random = self.state.tool_configs.brush.rotation_random;
|
||||
}
|
||||
}
|
||||
|
||||
// For bitmap (ABR) brushes, copy the imported alpha mask from the active
|
||||
// preset so the preview shows the actual brush shape, but keep the live
|
||||
// size from the Properties panel so scaling works correctly.
|
||||
let mut is_bitmap = false;
|
||||
if tip.style == hcie_engine_api::BrushStyle::Bitmap {
|
||||
if let Some(preset) = self
|
||||
.state
|
||||
.brush_presets
|
||||
.iter()
|
||||
.find(|p| p.id == self.state.active_brush_preset_id)
|
||||
{
|
||||
if !preset.tip.bitmap_pixels.is_empty()
|
||||
&& preset.tip.bitmap_w > 0
|
||||
&& preset.tip.bitmap_h > 0
|
||||
{
|
||||
let bw = tip.bitmap_w as f32;
|
||||
let bh = tip.bitmap_h as f32;
|
||||
let stamp_size = self.state.active_size() * zoom;
|
||||
let scale = (stamp_size / bw).min(stamp_size / bh);
|
||||
let draw_w = bw * scale;
|
||||
let draw_h = bh * scale;
|
||||
let origin_x = screen_x - draw_w * 0.5;
|
||||
let origin_y = screen_y - draw_h * 0.5;
|
||||
let px_size = scale.max(1.0);
|
||||
let mut shapes = Vec::new();
|
||||
let mut py = 0.0;
|
||||
let mut sy = 0u32;
|
||||
while sy < tip.bitmap_h {
|
||||
let mut px = 0.0;
|
||||
let mut sx = 0u32;
|
||||
while sx < tip.bitmap_w {
|
||||
let mask = tip.bitmap_pixels
|
||||
[(sy * tip.bitmap_w + sx) as usize]
|
||||
as f32
|
||||
/ 255.0;
|
||||
if mask > 0.01 {
|
||||
let a = (mask * 120.0).round() as u8;
|
||||
let c = egui::Color32::from_rgba_unmultiplied(
|
||||
cursor_color.r(),
|
||||
cursor_color.g(),
|
||||
cursor_color.b(),
|
||||
a,
|
||||
);
|
||||
let p = egui::pos2(origin_x + px, origin_y + py);
|
||||
shapes.push(egui::Shape::rect_filled(
|
||||
egui::Rect::from_min_size(
|
||||
p,
|
||||
egui::vec2(px_size, px_size),
|
||||
),
|
||||
0.0,
|
||||
c,
|
||||
));
|
||||
}
|
||||
px += px_size;
|
||||
sx += 1;
|
||||
}
|
||||
py += px_size;
|
||||
sy += 1;
|
||||
}
|
||||
painter.add(egui::Shape::Vec(shapes));
|
||||
}
|
||||
BrushStyle::Round
|
||||
| BrushStyle::HardRound
|
||||
| BrushStyle::SoftRound => {
|
||||
let radius = self.state.active_size() * zoom * 0.5;
|
||||
let hardness = tip.hardness;
|
||||
if hardness >= 0.95 {
|
||||
painter.circle_stroke(
|
||||
egui::pos2(screen_x, screen_y),
|
||||
radius,
|
||||
egui::Stroke::new(1.5, cursor_color),
|
||||
);
|
||||
} else {
|
||||
let softness = 1.0 - hardness;
|
||||
let layers = 5;
|
||||
for i in 0..layers {
|
||||
let frac = i as f32 / layers as f32;
|
||||
let r = radius * (1.0 + softness * 0.3 * (1.0 - frac));
|
||||
let a = (120.0 * (1.0 - frac * 0.6)) as u8;
|
||||
let c = egui::Color32::from_rgba_unmultiplied(
|
||||
cursor_color.r(),
|
||||
cursor_color.g(),
|
||||
cursor_color.b(),
|
||||
a,
|
||||
);
|
||||
painter.circle_stroke(
|
||||
egui::pos2(screen_x, screen_y),
|
||||
r,
|
||||
egui::Stroke::new(1.0, c),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
BrushStyle::Square => {
|
||||
let radius = self.state.active_size() * zoom * 0.5;
|
||||
let rect = egui::Rect::from_center_size(
|
||||
egui::pos2(screen_x, screen_y),
|
||||
egui::vec2(radius * 2.0, radius * 2.0),
|
||||
);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
0.0,
|
||||
egui::Stroke::new(1.5, cursor_color),
|
||||
egui::StrokeKind::Outside,
|
||||
);
|
||||
}
|
||||
BrushStyle::Star | BrushStyle::Spray => {
|
||||
let radius = self.state.active_size() * zoom * 1.6;
|
||||
painter.circle_stroke(
|
||||
egui::pos2(screen_x, screen_y),
|
||||
radius,
|
||||
egui::Stroke::new(1.0, cursor_color),
|
||||
);
|
||||
}
|
||||
BrushStyle::Clouds | BrushStyle::Tree => {
|
||||
let radius = self.state.active_size() * zoom;
|
||||
painter.circle_stroke(
|
||||
egui::pos2(screen_x, screen_y),
|
||||
radius,
|
||||
egui::Stroke::new(1.0, cursor_color),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let radius = self.state.active_size() * zoom * 0.5;
|
||||
painter.circle_stroke(
|
||||
egui::pos2(screen_x, screen_y),
|
||||
radius,
|
||||
egui::Stroke::new(1.5, cursor_color),
|
||||
);
|
||||
tip.bitmap_pixels = preset.tip.bitmap_pixels.clone();
|
||||
tip.bitmap_w = preset.tip.bitmap_w;
|
||||
tip.bitmap_h = preset.tip.bitmap_h;
|
||||
// aspect ratio is preserved by scaling to the square stamp
|
||||
is_bitmap = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let radius = self.state.active_size() * zoom * 0.5;
|
||||
painter.circle_stroke(
|
||||
}
|
||||
|
||||
let display_size = self.state.active_size();
|
||||
// active_size() returns the diameter of the brush.
|
||||
// The preview texture must cover this diameter in screen space.
|
||||
let diameter_px = display_size * zoom;
|
||||
let tex_size = (diameter_px.ceil() as u32).clamp(1, 256);
|
||||
|
||||
// Cache key: hash all stamp-relevant tip fields + color + zoom + active preset ID
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
tip.style.hash(&mut hasher);
|
||||
display_size.to_bits().hash(&mut hasher);
|
||||
tip.hardness.to_bits().hash(&mut hasher);
|
||||
tip.opacity.to_bits().hash(&mut hasher);
|
||||
self.state.primary_color.hash(&mut hasher);
|
||||
self.state.active_brush_preset_id.hash(&mut hasher);
|
||||
zoom.to_bits().hash(&mut hasher);
|
||||
if is_bitmap {
|
||||
tip.bitmap_w.hash(&mut hasher);
|
||||
tip.bitmap_h.hash(&mut hasher);
|
||||
tip.bitmap_pixels.len().hash(&mut hasher);
|
||||
if !tip.bitmap_pixels.is_empty() {
|
||||
let end = tip.bitmap_pixels.len().min(256);
|
||||
hasher.write(&tip.bitmap_pixels[..end]);
|
||||
}
|
||||
}
|
||||
let cache_hash = hasher.finish();
|
||||
|
||||
let cache_id = ui.id().with("cursor_stamp");
|
||||
let cached_hash = ui.data(|d| d.get_temp::<u64>(cache_id));
|
||||
|
||||
if cached_hash != Some(cache_hash) {
|
||||
let pixels = hcie_engine_api::Engine::render_brush_stamp_preview(
|
||||
tex_size,
|
||||
tex_size,
|
||||
&tip,
|
||||
self.state.primary_color,
|
||||
);
|
||||
let image = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[tex_size as usize, tex_size as usize],
|
||||
&pixels,
|
||||
);
|
||||
let tex = ui.ctx().load_texture(
|
||||
&format!("cursor_stamp_{:x}", cache_hash),
|
||||
image,
|
||||
egui::TextureOptions::LINEAR,
|
||||
);
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(cache_id, cache_hash);
|
||||
d.insert_temp(ui.id().with("cursor_stamp_tex"), tex);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(tex) = ui
|
||||
.data(|d| d.get_temp::<egui::TextureHandle>(ui.id().with("cursor_stamp_tex")))
|
||||
{
|
||||
// Display the stamp preview at the correct diameter.
|
||||
// The texture was rendered at tex_size × tex_size pixels;
|
||||
// display it at diameter_px × diameter_px screen pixels so
|
||||
// the preview matches the actual brush footprint.
|
||||
let display_dim = egui::vec2(diameter_px, diameter_px);
|
||||
let rect = egui::Rect::from_center_size(
|
||||
egui::pos2(screen_x, screen_y),
|
||||
radius,
|
||||
egui::Stroke::new(1.5, cursor_color),
|
||||
display_dim,
|
||||
);
|
||||
painter.add(egui::Shape::image(
|
||||
tex.id(),
|
||||
rect,
|
||||
egui::Rect::from_min_max(
|
||||
egui::pos2(0.0, 0.0),
|
||||
egui::pos2(1.0, 1.0),
|
||||
),
|
||||
egui::Color32::WHITE,
|
||||
));
|
||||
|
||||
// Draw a thin crosshair at the center of the stamp preview
|
||||
// so the user can see the exact brush center point.
|
||||
let cross_color = egui::Color32::from_rgba_unmultiplied(200, 200, 200, 160);
|
||||
let cross_len = 5.0_f32;
|
||||
painter.line_segment(
|
||||
[
|
||||
egui::pos2(screen_x - cross_len, screen_y),
|
||||
egui::pos2(screen_x + cross_len, screen_y),
|
||||
],
|
||||
egui::Stroke::new(1.0, cross_color),
|
||||
);
|
||||
painter.line_segment(
|
||||
[
|
||||
egui::pos2(screen_x, screen_y - cross_len),
|
||||
egui::pos2(screen_x, screen_y + cross_len),
|
||||
],
|
||||
egui::Stroke::new(1.0, cross_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1888,8 +1952,14 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Raster tool cursor preview (crosshair) ────────────────
|
||||
if self.state.active_tool.is_raster_compatible() || self.state.active_tool.is_ai_tool() {
|
||||
// ── Raster tool cursor preview (crosshair + circle) ──────────
|
||||
// Only draw the circle+crosshair overlay for raster tools that do NOT
|
||||
// already have a stamp-texture preview (those are handled by the
|
||||
// "Brush cursor preview" block above). Tools with shows_pen_tips()
|
||||
// already get a shaped stamp preview + crosshair there.
|
||||
if (self.state.active_tool.is_raster_compatible() || self.state.active_tool.is_ai_tool())
|
||||
&& !self.state.active_tool.shows_pen_tips()
|
||||
{
|
||||
if let Some(hover_pos) = ui.input(|i| i.pointer.hover_pos()) {
|
||||
if canvas_rect.contains(hover_pos) {
|
||||
if !matches!(self.state.active_tool, Tool::AiObjectRemoval) {
|
||||
@@ -2481,3 +2551,5 @@ fn find_text_layer_at(engine: &hcie_engine_api::Engine, x: f32, y: f32) -> Optio
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -122,90 +122,100 @@ pub fn render_composition(
|
||||
_canvas_rect: egui::Rect,
|
||||
_zoom: f32,
|
||||
) -> bool {
|
||||
// Commit any background-computed undo snapshots before compositing
|
||||
let t_commit = std::time::Instant::now();
|
||||
doc.engine.commit_pending_history();
|
||||
let commit_elapsed = t_commit.elapsed().as_millis();
|
||||
if commit_elapsed > 1 {
|
||||
log::warn!("[PERF] commit_pending_history took {}ms", commit_elapsed);
|
||||
}
|
||||
|
||||
let t_start = std::time::Instant::now();
|
||||
|
||||
let rw = doc.engine.canvas_width().max(1);
|
||||
let rh = doc.engine.canvas_height().max(1);
|
||||
|
||||
let full_size = (rw * rh * 4) as usize;
|
||||
let has_buffer = doc.composite_buffer.len() == full_size;
|
||||
|
||||
let needs_render =
|
||||
doc.engine.is_composite_dirty() || doc.composite_texture.is_none() || !has_buffer;
|
||||
|
||||
if !needs_render {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip expensive composite rendering during active drawing to prevent UI freezes.
|
||||
// The composite will be rendered on the next frame after the stroke ends.
|
||||
if state.is_drawing && doc.composite_texture.is_some() && has_buffer {
|
||||
return false;
|
||||
}
|
||||
let composite_dirty = doc.engine.is_composite_dirty();
|
||||
|
||||
// Ensure buffer exists with correct size
|
||||
if !has_buffer {
|
||||
doc.composite_buffer = vec![0u8; full_size];
|
||||
}
|
||||
|
||||
// Try region-based compositing first (partial update)
|
||||
let (region, buf_ptr, buf_size) = doc.engine.render_composite_region();
|
||||
let needs_render = composite_dirty || doc.composite_texture.is_none() || !has_buffer;
|
||||
|
||||
// Copy from engine's pooled internal buffer into our composite_buffer
|
||||
if !buf_ptr.is_null() && buf_size > 0 && buf_size == doc.composite_buffer.len() {
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_buffer.as_mut_ptr(), buf_size);
|
||||
// ── Drawing-time composite throttle ─────────────────────────────────
|
||||
// During active drawing, composite every other frame to keep the stroke
|
||||
// visible in real-time while giving the brush engine time to accumulate
|
||||
// more dabs between composites. This replaces the old is_drawing guard
|
||||
// that completely blocked compositing and made strokes invisible until
|
||||
// mouse release.
|
||||
//
|
||||
// The counter resets to 0 when not drawing, so the first frame after
|
||||
// stroke end always composites immediately.
|
||||
if needs_render && state.is_drawing && doc.composite_texture.is_some() && has_buffer {
|
||||
state.drawing_composite_skip_counter += 1;
|
||||
if state.drawing_composite_skip_counter % 2 != 0 {
|
||||
// Skip this frame, composite on the next one.
|
||||
ctx.request_repaint();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
state.drawing_composite_skip_counter = 0;
|
||||
}
|
||||
|
||||
// If the engine reports no dirty region we may still need to create the
|
||||
// initial texture (e.g. a brand-new blank canvas). In that case the local
|
||||
// composite_buffer is already zero-filled, which is enough to show the
|
||||
// checkerboard background behind it.
|
||||
let region = match region {
|
||||
Some([x0, y0, x1, y1]) if x1 > x0 && y1 > y0 => [x0, y0, x1, y1],
|
||||
_ => [0, 0, rw, rh],
|
||||
};
|
||||
if needs_render {
|
||||
let t_composite_start = std::time::Instant::now();
|
||||
let (region_result, buf_ptr, buf_size) = doc.engine.render_composite_region();
|
||||
let composite_ms = t_composite_start.elapsed().as_millis();
|
||||
|
||||
let options = egui::TextureOptions {
|
||||
magnification: egui::TextureFilter::Nearest,
|
||||
minification: egui::TextureFilter::Linear,
|
||||
..Default::default()
|
||||
};
|
||||
if !buf_ptr.is_null() && buf_size > 0 && buf_size == doc.composite_buffer.len() {
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(buf_ptr, doc.composite_buffer.as_mut_ptr(), buf_size);
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks whether this call created a brand-new composite texture. egui
|
||||
// uploads GPU textures asynchronously, so the frame that creates the
|
||||
// texture will not yet display it. Returning `true` lets the caller issue
|
||||
// an extra repaint so the texture appears without requiring a user click.
|
||||
let mut created_new_texture = false;
|
||||
let region = match region_result {
|
||||
Some([x0, y0, x1, y1]) if x1 > x0 && y1 > y0 => [x0, y0, x1, y1],
|
||||
_ => [0, 0, rw, rh],
|
||||
};
|
||||
|
||||
if let Some(tex) = &mut doc.composite_texture {
|
||||
let size = tex.size();
|
||||
if size[0] == rw as usize && size[1] == rh as usize {
|
||||
let region_w = (region[2] - region[0]) as usize;
|
||||
let region_h = (region[3] - region[1]) as usize;
|
||||
if region_w == rw as usize && region_h == rh as usize {
|
||||
// Full canvas update — fast path avoiding sub-region copy
|
||||
let options = egui::TextureOptions {
|
||||
magnification: egui::TextureFilter::Nearest,
|
||||
minification: egui::TextureFilter::Linear,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Partial texture upload for the dirty region only
|
||||
if let Some(tex) = &mut doc.composite_texture {
|
||||
let size = tex.size();
|
||||
if size[0] == rw as usize && size[1] == rh as usize {
|
||||
let region_w = (region[2] - region[0]) as usize;
|
||||
let region_h = (region[3] - region[1]) as usize;
|
||||
if region_w > 0 && region_h > 0 {
|
||||
let mut region_pixels = vec![0u8; region_w * region_h * 4];
|
||||
let x0 = region[0];
|
||||
let y0 = region[1];
|
||||
let y1 = region[3];
|
||||
for y in y0..y1 {
|
||||
let src_start = ((y * rw + x0) as usize) * 4;
|
||||
let dst_start = ((y - y0) as usize * region_w) * 4;
|
||||
let row_bytes = region_w * 4;
|
||||
region_pixels[dst_start..dst_start + row_bytes]
|
||||
.copy_from_slice(&doc.composite_buffer[src_start..src_start + row_bytes]);
|
||||
}
|
||||
let region_image =
|
||||
egui::ColorImage::from_rgba_unmultiplied([region_w, region_h], ®ion_pixels);
|
||||
tex.set_partial([x0 as usize, y0 as usize], region_image, options);
|
||||
}
|
||||
} else {
|
||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[rw as usize, rh as usize],
|
||||
&doc.composite_buffer,
|
||||
);
|
||||
tex.set(color_image, options);
|
||||
} else if region_w > 0 && region_h > 0 {
|
||||
// Partial texture upload: only the dirty sub-region
|
||||
let mut region_pixels = vec![0u8; region_w * region_h * 4];
|
||||
let x0 = region[0];
|
||||
let y0 = region[1];
|
||||
let _x1 = region[2];
|
||||
let y1 = region[3];
|
||||
for y in y0..y1 {
|
||||
let src_start = ((y * rw + x0) as usize) * 4;
|
||||
let dst_start = ((y - y0) as usize * region_w) * 4;
|
||||
let row_bytes = region_w * 4;
|
||||
region_pixels[dst_start..dst_start + row_bytes]
|
||||
.copy_from_slice(&doc.composite_buffer[src_start..src_start + row_bytes]);
|
||||
}
|
||||
let region_image =
|
||||
egui::ColorImage::from_rgba_unmultiplied([region_w, region_h], ®ion_pixels);
|
||||
tex.set_partial([x0 as usize, y0 as usize], region_image, options);
|
||||
}
|
||||
} else {
|
||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||
@@ -213,20 +223,56 @@ pub fn render_composition(
|
||||
&doc.composite_buffer,
|
||||
);
|
||||
doc.composite_texture = Some(ctx.load_texture("composite-view", color_image, options));
|
||||
created_new_texture = true;
|
||||
}
|
||||
} else {
|
||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[rw as usize, rh as usize],
|
||||
&doc.composite_buffer,
|
||||
);
|
||||
doc.composite_texture = Some(ctx.load_texture("composite-view", color_image, options));
|
||||
created_new_texture = true;
|
||||
|
||||
doc.thumbnails_dirty = true;
|
||||
doc.engine.clear_dirty_flags();
|
||||
|
||||
let total_ms = t_start.elapsed().as_millis();
|
||||
if total_ms > 16 {
|
||||
log::warn!(
|
||||
"[render_composition] frame budget exceeded: composite={}ms, total={}ms, region=[{},{},{},{}], is_drawing={}",
|
||||
composite_ms, total_ms, region[0], region[1], region[2], region[3], state.is_drawing
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer thumbnails: only regenerate when the engine reports the layer is dirty
|
||||
// or when we do not yet have a texture for it. The engine's `is_layer_dirty`
|
||||
// flag is cleared after each composite, so thumbnails stay cheap between frames.
|
||||
// ── Step 3: Regenerate deferred thumbnails ──────────────────────────────
|
||||
// Delay thumbnail generation until the user has stopped drawing for at least 250ms
|
||||
// to prevent freezing between fast consecutive brush strokes.
|
||||
if doc.thumbnails_dirty {
|
||||
if state.is_drawing {
|
||||
// Skip while actively drawing
|
||||
} else if let Some(last_end) = doc.last_stroke_end_time {
|
||||
let elapsed = last_end.elapsed();
|
||||
let delay = std::time::Duration::from_millis(250);
|
||||
if elapsed < delay {
|
||||
// Not enough idle time yet. Request repaint at the exact moment
|
||||
// the delay expires to perform the thumbnail update.
|
||||
ctx.request_repaint_after(delay - elapsed);
|
||||
} else {
|
||||
// User has been idle for >=250ms. Regenerate now.
|
||||
regenerate_thumbnails(ctx, doc);
|
||||
doc.thumbnails_dirty = false;
|
||||
}
|
||||
} else {
|
||||
// Fallback: no stroke time recorded, regenerate immediately
|
||||
regenerate_thumbnails(ctx, doc);
|
||||
doc.thumbnails_dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Regenerate layer thumbnails that were deferred from a previous frame.
|
||||
///
|
||||
/// # Purpose
|
||||
/// Generates layer thumbnail textures for the layers panel. This is called
|
||||
/// one frame after the composite to avoid cloning ~33MB per dirty layer
|
||||
/// on the same frame as the composite.
|
||||
fn regenerate_thumbnails(ctx: &egui::Context, doc: &mut AppDocument) {
|
||||
let t_regen = std::time::Instant::now();
|
||||
let layers = doc.engine.layer_infos();
|
||||
doc.layer_textures.retain(|k, _| *k < layers.len());
|
||||
|
||||
@@ -269,10 +315,10 @@ pub fn render_composition(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.engine.clear_dirty_flags();
|
||||
|
||||
created_new_texture
|
||||
let elapsed = t_regen.elapsed().as_millis();
|
||||
if elapsed > 1 {
|
||||
log::warn!("[PERF] regenerate_thumbnails took {}ms", elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute shape-specific key handle positions in canvas space.
|
||||
|
||||
Reference in New Issue
Block a user