From f2c15d7f0d7237ca56d9240a9ba74a3a1c1a59e3 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 5 Mar 2026 20:24:38 -0500 Subject: [PATCH] brush fixes and improvements --- .../lightningbeam-core/src/brush_engine.rs | 320 +++++++++++++----- .../lightningbeam-editor/src/gpu_brush.rs | 232 ++++++++----- .../lightningbeam-editor/src/main.rs | 4 + .../src/panes/infopanel.rs | 39 ++- .../lightningbeam-editor/src/panes/mod.rs | 4 + .../lightningbeam-editor/src/panes/stage.rs | 155 ++++++++- src/assets/brushes/airbrush.myb | 2 +- 7 files changed, 571 insertions(+), 185 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index a40d181..65883e8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -15,8 +15,22 @@ //! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation. //! //! ### Dab placement -//! Spacing = 1 / max(dabs_per_basic_radius/radius, dabs_per_actual_radius/actual_radius). -//! Fractional remainder is tracked across consecutive calls via `StrokeState`. +//! Follows the libmypaint model: distance-based and time-based contributions are +//! **summed** into a single `partial_dabs` accumulator. A dab fires whenever the +//! accumulator reaches 1.0. +//! +//! Rate (dabs per pixel) = dabs_per_actual_radius / actual_radius +//! + dabs_per_basic_radius / base_radius +//! Time contribution added per call = dt × dabs_per_second +//! +//! ### Opacity +//! Matches libmypaint's `opaque_linearize` formula. `dabs_per_pixel` is a fixed +//! brush-level estimate of how many dabs overlap at any pixel: +//! +//! `dabs_per_pixel = 1 + opaque_linearize × ((dabs_per_actual + dabs_per_basic) × 2 - 1)` +//! `per_dab_alpha = 1 - (1 - raw_opacity) ^ (1 / dabs_per_pixel)` +//! +//! With `opaque_linearize = 0` the raw opacity is used directly per dab. //! //! ### Blending //! Normal mode uses the standard "over" operator on premultiplied RGBA. @@ -70,8 +84,9 @@ pub struct GpuDab { /// Transient brush stroke state (tracks position and randomness between segments) pub struct StrokeState { - /// Distance along the path already "consumed" toward the next dab (in pixels) - pub distance_since_last_dab: f32, + /// Fractional dab accumulator — reaches 1.0 when the next dab should fire. + /// Initialised to 1.0 so the very first call always emits at least one dab. + pub partial_dabs: f32, /// Exponentially-smoothed cursor X for slow_tracking pub smooth_x: f32, /// Exponentially-smoothed cursor Y for slow_tracking @@ -91,7 +106,8 @@ pub struct StrokeState { impl StrokeState { pub fn new() -> Self { Self { - distance_since_last_dab: 0.0, + // Start at 1.0 so the first call always emits the stroke-start dab. + partial_dabs: 1.0, smooth_x: 0.0, smooth_y: 0.0, smooth_initialized: false, @@ -111,7 +127,7 @@ impl Default for StrokeState { // Helpers // --------------------------------------------------------------------------- -/// xorshift32 — fast, no-alloc PRNG. Returns a value in [0, 1). +/// xorshift32 — fast, no-alloc PRNG. Returns a value in [0, 1). #[inline] fn xorshift(seed: &mut u32) -> f32 { let mut s = *seed; @@ -122,8 +138,17 @@ fn xorshift(seed: &mut u32) -> f32 { (s as f32) / (u32::MAX as f32) } -/// Convert linear RGB (premultiplied, alpha already separated) to HSV. -/// Input: r, g, b in [0, 1] (not premultiplied; caller divides by alpha first). +/// Box-Muller Gaussian sample with mean 0 and std-dev 1. +/// Consumes two xorshift samples; the second half of the pair is discarded +/// (acceptable for brush jitter which doesn't need correlated pairs). +#[inline] +fn gaussian(seed: &mut u32) -> f32 { + let u1 = xorshift(seed).max(1e-7); // avoid ln(0) + let u2 = xorshift(seed); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos() +} + +/// Convert linear RGB (not premultiplied) to HSV. fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) { let max = r.max(g).max(b); let min = r.min(g).min(b); @@ -160,20 +185,108 @@ fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { } } +// --------------------------------------------------------------------------- +// Per-dab effects helper +// --------------------------------------------------------------------------- + +/// Apply per-dab randomness and color-shift effects, matching libmypaint. +/// +/// Returns `(ex, ey, radius, opacity, cr, cg, cb)` ready for the dab emitter. +/// +/// Opacity uses the `opaque_linearize` formula (same fixed brush-level estimate +/// whether called from the single-point path or the drag path). +/// +/// Jitter uses Gaussian noise (matching libmypaint), not uniform. +/// +/// Radius jitter applies an opacity correction `× (base_r / jittered_r)²` to +/// keep perceived ink-amount constant as radius varies (matches libmypaint). +fn apply_dab_effects( + state: &mut StrokeState, + bs: &crate::brush_settings::BrushSettings, + x: f32, y: f32, + base_radius: f32, // radius_at_pressure(pressure), before jitter + pressure: f32, + color: [f32; 4], +) -> (f32, f32, f32, f32, f32, f32, f32) { + // ---- Opacity (libmypaint opaque_linearize formula) -------------------- + // Estimate average dab overlap per pixel from brush settings (fixed, not + // speed-dependent), then convert stroke-level opacity to per-dab alpha. + let raw_dpp = ((bs.dabs_per_actual_radius + bs.dabs_per_radius) * 2.0).max(1.0); + let dabs_per_pixel = (1.0 + bs.opaque_linearize * (raw_dpp - 1.0)).max(1.0); + let raw_o = bs.opacity_at_pressure(pressure); + let mut opacity = 1.0 - (1.0 - raw_o).powf(1.0 / dabs_per_pixel); + + // ---- Radius jitter (Gaussian in log-space, matching libmypaint) ------- + let mut radius = base_radius; + if bs.radius_by_random != 0.0 { + let noise = gaussian(&mut state.rng_seed) * bs.radius_by_random; + let jittered_log = bs.radius_log + noise; + radius = jittered_log.exp().clamp(0.5, 500.0); + // Opacity correction: keep ink-amount constant as radius varies. + let alpha_correction = (base_radius / radius).powi(2); + opacity = (opacity * alpha_correction).clamp(0.0, 1.0); + } + + // ---- Position jitter + fixed offset (Gaussian, matching libmypaint) --- + let mut ex = x; + let mut ey = y; + if bs.offset_by_random != 0.0 || bs.offset_x != 0.0 || bs.offset_y != 0.0 { + // libmypaint uses base_radius (no-pressure) for the jitter scale. + let base_r_fixed = bs.radius_log.exp(); + ex += gaussian(&mut state.rng_seed) * bs.offset_by_random * base_r_fixed + + bs.offset_x * base_r_fixed; + ey += gaussian(&mut state.rng_seed) * bs.offset_by_random * base_r_fixed + + bs.offset_y * base_r_fixed; + } + + // ---- Per-dab color phase shifts --------------------------------------- + state.color_h_phase += bs.change_color_h; + state.color_v_phase += bs.change_color_v; + state.color_s_phase += bs.change_color_hsv_s; + + let (mut cr, mut cg, mut cb) = (color[0], color[1], color[2]); + let ca = color[3]; + if ca > 1e-6 + && (bs.change_color_h != 0.0 + || bs.change_color_v != 0.0 + || bs.change_color_hsv_s != 0.0) + { + let (ur, ug, ub) = (cr / ca, cg / ca, cb / ca); + let (mut h, mut s, mut v) = rgb_to_hsv(ur, ug, ub); + h = (h + state.color_h_phase).rem_euclid(1.0); + v = (v + state.color_v_phase).clamp(0.0, 1.0); + s = (s + state.color_s_phase).clamp(0.0, 1.0); + let (r2, g2, b2) = hsv_to_rgb(h, s, v); + cr = r2 * ca; + cg = g2 * ca; + cb = b2 * ca; + } + + (ex, ey, radius, opacity.clamp(0.0, 1.0), cr, cg, cb) +} + +// --------------------------------------------------------------------------- +// Brush engine +// --------------------------------------------------------------------------- + /// Pure-Rust MyPaint-style Gaussian dab brush engine pub struct BrushEngine; impl BrushEngine { /// Compute the list of GPU dabs for a stroke segment. /// - /// Uses the MyPaint dab-spacing formula and produces [`GpuDab`] structs for - /// upload to the GPU compute pipeline. + /// `dt` is the elapsed time in seconds since the previous call for this + /// stroke. Pass `0.0` on the very first call (stroke start). /// - /// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)` in - /// integer canvas pixel coordinates (`x0==i32::MAX` when the Vec is empty). + /// Follows the libmypaint spacing model: distance-based and time-based + /// contributions are **summed** in a single `partial_dabs` accumulator. + /// A dab is emitted whenever `partial_dabs` reaches 1.0. + /// + /// Also returns the union bounding box of all dabs as `(x0, y0, x1, y1)`. pub fn compute_dabs( stroke: &StrokeRecord, state: &mut StrokeState, + dt: f32, ) -> (Vec, (i32, i32, i32, i32)) { let mut dabs: Vec = Vec::new(); let mut bbox = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); @@ -218,25 +331,55 @@ impl BrushEngine { }); }; + // Time-based accumulation: dt × dabs_per_second contributes to partial_dabs + // regardless of whether the cursor moved. + // Cap dt to 0.1 s to avoid a burst of dabs after a long pause. + let dt_capped = dt.min(0.1); + state.partial_dabs += dt_capped * bs.dabs_per_second; + + // ---------------------------------------------------------------- + // Single-point path: emit time-based (and stroke-start) dabs. + // The caller is responsible for timing; we just fire whenever + // partial_dabs ≥ 1.0. + // ---------------------------------------------------------------- if stroke.points.len() < 2 { if let Some(pt) = stroke.points.first() { - let r = bs.radius_at_pressure(pt.pressure); - // Default dpr for a single tap: prefer actual_radius spacing - let dpr = if bs.dabs_per_radius > 0.0 { bs.dabs_per_radius } - else { bs.dabs_per_actual_radius.max(0.01) }; - let raw_o = bs.opacity_at_pressure(pt.pressure); - let o = (1.0 - (1.0 - raw_o).powf(dpr * 0.5) - * (1.0 + bs.opaque_multiply)).clamp(0.0, 1.0); - if !matches!(base_blend, RasterBlendMode::Smudge) { - let (cr, cg, cb) = (stroke.color[0], stroke.color[1], stroke.color[2]); - push_dab(&mut dabs, &mut bbox, pt.x, pt.y, r, o, cr, cg, cb, - 0.0, 0.0, 0.0); + if !state.smooth_initialized { + state.smooth_x = pt.x; + state.smooth_y = pt.y; + state.smooth_initialized = true; + } + while state.partial_dabs >= 1.0 { + state.partial_dabs -= 1.0; + let base_r = bs.radius_at_pressure(pt.pressure); + let (ex, ey, r, o, cr, cg, cb) = apply_dab_effects( + state, bs, pt.x, pt.y, base_r, pt.pressure, stroke.color, + ); + if !matches!(base_blend, RasterBlendMode::Smudge) { + push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr, cg, cb, + 0.0, 0.0, 0.0); + } } - state.distance_since_last_dab = 0.0; } return (dabs, bbox); } + // ---------------------------------------------------------------- + // Drag path: walk the polyline, accumulating partial_dabs from + // both distance-based and time-based contributions. + // ---------------------------------------------------------------- + + // Track the last smoothed position so that any residual time-based + // dabs can be emitted at the end of the segment walk. + let mut last_smooth_x = state.smooth_x; + let mut last_smooth_y = state.smooth_y; + let mut last_pressure = stroke.points.last() + .map(|p| p.pressure) + .unwrap_or(1.0); + + // Fixed base radius (no pressure) used for the basic-radius spacing rate. + let base_radius_fixed = bs.radius_log.exp(); + for window in stroke.points.windows(2) { let p0 = &window[0]; let p1 = &window[1]; @@ -246,27 +389,39 @@ impl BrushEngine { let seg_len = (dx * dx + dy * dy).sqrt(); if seg_len < 1e-4 { continue; } + last_pressure = p1.pressure; + let mut t = 0.0f32; while t < 1.0 { let pressure = p0.pressure + t * (p1.pressure - p0.pressure); - let radius2 = bs.radius_at_pressure(pressure); + let radius_for_rate = bs.radius_at_pressure(pressure); - // Spacing: densest wins between basic-radius and actual-radius methods. - // dabs_per_basic_radius = N dabs per basic_radius pixels → spacing = basic_r / N - // dabs_per_actual_radius = N dabs per actual_radius pixels → spacing = actual_r / N - let spacing_basic = if bs.dabs_per_radius > 0.0 { - radius2 / bs.dabs_per_radius - } else { f32::MAX }; - let spacing_actual = if bs.dabs_per_actual_radius > 0.0 { - radius2 / bs.dabs_per_actual_radius - } else { f32::MAX }; - let spacing = spacing_basic.min(spacing_actual).max(0.5); + // Dab rate = sum of distance-based contributions (dabs per pixel). + // Matches libmypaint: dabs_per_actual/actual_r + dabs_per_basic/base_r. + // For elliptical brushes use the minor-axis radius so dabs connect + // when moving perpendicular to the major axis. + let eff_radius = if bs.elliptical_dab_ratio > 1.001 { + radius_for_rate / bs.elliptical_dab_ratio + } else { + radius_for_rate + }; + let rate_actual = if bs.dabs_per_actual_radius > 0.0 { + bs.dabs_per_actual_radius / eff_radius + } else { 0.0 }; + let rate_basic = if bs.dabs_per_radius > 0.0 { + bs.dabs_per_radius / base_radius_fixed + } else { 0.0 }; + let rate = rate_actual + rate_basic; // dabs per pixel - let dist_to_next = spacing - state.distance_since_last_dab; - let seg_t_to_next = (dist_to_next / seg_len).max(0.0); + let remaining = 1.0 - state.partial_dabs; + let pixels_to_next = if rate > 1e-8 { remaining / rate } else { f32::MAX }; + let seg_t_to_next = (pixels_to_next / seg_len).max(0.0); if seg_t_to_next > 1.0 - t { - state.distance_since_last_dab += seg_len * (1.0 - t); + // Won't reach the next dab within this segment. + if rate > 1e-8 { + state.partial_dabs += (1.0 - t) * seg_len * rate; + } break; } @@ -275,17 +430,11 @@ impl BrushEngine { // Stroke threshold gating if pressure2 < bs.stroke_threshold { - state.distance_since_last_dab = 0.0; + state.partial_dabs = 0.0; continue; } - let mut radius2 = bs.radius_at_pressure(pressure2); - - // Opacity — normalised so dense dabs don't saturate faster than sparse ones - let dpr = radius2 / spacing; // effective dabs per radius - let raw_opacity = bs.opacity_at_pressure(pressure2); - let mut opacity2 = 1.0 - (1.0 - raw_opacity).powf(dpr * 0.5); - opacity2 = (opacity2 * (1.0 + bs.opaque_multiply)).clamp(0.0, 1.0); + let base_r2 = bs.radius_at_pressure(pressure2); // Slow tracking: exponential position smoothing let x2 = p0.x + t * dx; @@ -294,57 +443,25 @@ impl BrushEngine { state.smooth_x = x2; state.smooth_y = y2; state.smooth_initialized = true; } + // spacing_px ≈ 1 / rate (pixels per dab), used as time-constant scale + let spacing_px = if rate > 1e-8 { 1.0 / rate } else { 1.0 }; let k = if bs.slow_tracking > 0.0 { - (-spacing / bs.slow_tracking.max(0.1)).exp() + (-spacing_px / bs.slow_tracking.max(0.1)).exp() } else { 0.0 }; state.smooth_x = state.smooth_x * k + x2 * (1.0 - k); state.smooth_y = state.smooth_y * k + y2 * (1.0 - k); - let mut ex = state.smooth_x; - let mut ey = state.smooth_y; + last_smooth_x = state.smooth_x; + last_smooth_y = state.smooth_y; - // Radius jitter (log-scale) - if bs.radius_by_random != 0.0 { - let r_rng = xorshift(&mut state.rng_seed) * 2.0 - 1.0; - radius2 = (radius2 * (bs.radius_by_random * r_rng).exp()).clamp(0.5, 500.0); - } - - // Position jitter + fixed offset - if bs.offset_by_random != 0.0 || bs.offset_x != 0.0 || bs.offset_y != 0.0 { - let jitter = bs.offset_by_random * radius2; - ex += (xorshift(&mut state.rng_seed) * 2.0 - 1.0) * jitter - + bs.offset_x * radius2; - ey += (xorshift(&mut state.rng_seed) * 2.0 - 1.0) * jitter - + bs.offset_y * radius2; - } - - // Per-dab color phase shifts - state.color_h_phase += bs.change_color_h; - state.color_v_phase += bs.change_color_v; - state.color_s_phase += bs.change_color_hsv_s; - - let (mut cr, mut cg, mut cb) = ( - stroke.color[0], stroke.color[1], stroke.color[2], + let (sx, sy) = (state.smooth_x, state.smooth_y); + let (ex, ey, radius2, opacity2, cr, cg, cb) = apply_dab_effects( + state, bs, sx, sy, base_r2, pressure2, stroke.color, ); - let ca = stroke.color[3]; - if ca > 1e-6 { - // un-premultiply for HSV conversion - let (ur, ug, ub) = (cr / ca, cg / ca, cb / ca); - let (mut h, mut s, mut v) = rgb_to_hsv(ur, ug, ub); - if bs.change_color_h != 0.0 || bs.change_color_v != 0.0 - || bs.change_color_hsv_s != 0.0 { - h = (h + state.color_h_phase).rem_euclid(1.0); - v = (v + state.color_v_phase).clamp(0.0, 1.0); - s = (s + state.color_s_phase).clamp(0.0, 1.0); - let (r2, g2, b2) = hsv_to_rgb(h, s, v); - cr = r2 * ca; cg = g2 * ca; cb = b2 * ca; - } - } if matches!(base_blend, RasterBlendMode::Smudge) { let ndx = dx / seg_len; let ndy = dy / seg_len; - let smudge_dist = - (radius2 * dpr).max(1.0) * bs.smudge_radius_log.exp(); + let smudge_dist = radius2 * bs.smudge_radius_log.exp(); push_dab(&mut dabs, &mut bbox, ex, ey, radius2, opacity2, cr, cg, cb, ndx, ndy, smudge_dist); @@ -354,7 +471,34 @@ impl BrushEngine { 0.0, 0.0, 0.0); } - state.distance_since_last_dab = 0.0; + state.partial_dabs = 0.0; + } + } + + // Emit any residual time-based dabs (partial_dabs ≥ 1.0 from the dt + // contribution not consumed by distance-based movement) at the last + // known cursor position. + if state.partial_dabs >= 1.0 && !matches!(base_blend, RasterBlendMode::Smudge) { + // Initialise smooth position if we never entered the segment loop. + if !state.smooth_initialized { + if let Some(pt) = stroke.points.last() { + state.smooth_x = pt.x; + state.smooth_y = pt.y; + state.smooth_initialized = true; + last_smooth_x = state.smooth_x; + last_smooth_y = state.smooth_y; + } + } + while state.partial_dabs >= 1.0 { + state.partial_dabs -= 1.0; + let base_r = bs.radius_at_pressure(last_pressure); + let (ex, ey, r, o, cr, cg, cb) = apply_dab_effects( + state, bs, + last_smooth_x, last_smooth_y, + base_r, last_pressure, stroke.color, + ); + push_dab(&mut dabs, &mut bbox, ex, ey, r, o, cr, cg, cb, + 0.0, 0.0, 0.0); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index 5855850..69f6a98 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -276,9 +276,15 @@ impl GpuBrushEngine { /// Dispatch the brush compute shader for `dabs` onto the canvas of `keyframe_id`. /// - /// Each dab is dispatched serially: copy the dab's bounding box from src→dst, - /// dispatch the compute shader, then swap. The bbox-only copy is safe because - /// neither normal/erase nor smudge reads outside the current dab's radius. + /// All dabs for the frame are batched into a single GPU dispatch: + /// 1. Copy the FULL canvas src→dst (so dst has all previous dabs). + /// 2. Upload all dabs as one storage buffer. + /// 3. Dispatch the compute shader once over the union bounding box. + /// 4. Swap once. + /// + /// Batching is required for correctness: a per-dab copy of only the dab's + /// bounding box would leave all other previous dabs missing from dst after swap, + /// causing every other dab to flicker in/out. /// /// If `dabs` is empty, does nothing. pub fn render_dabs( @@ -287,98 +293,102 @@ impl GpuBrushEngine { queue: &wgpu::Queue, keyframe_id: Uuid, dabs: &[GpuDab], - _bbox: (i32, i32, i32, i32), + bbox: (i32, i32, i32, i32), canvas_w: u32, canvas_h: u32, ) { if dabs.is_empty() { return; } - if !self.canvases.contains_key(&keyframe_id) { return; } + let canvas = match self.canvases.get_mut(&keyframe_id) { + Some(c) => c, + None => return, + }; - for dab in dabs { - let r_fringe = dab.radius + 1.0; - let x0 = ((dab.x - r_fringe).floor() as i32).max(0) as u32; - let y0 = ((dab.y - r_fringe).floor() as i32).max(0) as u32; - let x1 = ((dab.x + r_fringe).ceil() as i32).min(canvas_w as i32) as u32; - let y1 = ((dab.y + r_fringe).ceil() as i32).min(canvas_h as i32) as u32; - if x1 <= x0 || y1 <= y0 { continue; } + // Clamp the union bounding box to canvas bounds. + let x0 = bbox.0.max(0) as u32; + let y0 = bbox.1.max(0) as u32; + let x1 = (bbox.2 as u32).min(canvas_w); + let y1 = (bbox.3 as u32).min(canvas_h); + if x1 <= x0 || y1 <= y0 { return; } + let bbox_w = x1 - x0; + let bbox_h = y1 - y0; - let bbox_w = x1 - x0; - let bbox_h = y1 - y0; + // Step 1: Copy the ENTIRE canvas src→dst so dst starts with all previous dabs. + // A bbox-only copy would lose previous dabs outside this frame's region after swap. + let mut copy_enc = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("canvas_full_copy_encoder") }, + ); + copy_enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: canvas.src(), + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: canvas.dst(), + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { width: canvas_w, height: canvas_h, depth_or_array_layers: 1 }, + ); + queue.submit(Some(copy_enc.finish())); - let canvas = self.canvases.get_mut(&keyframe_id).unwrap(); + // Step 2: Upload all dabs as a single storage buffer. + let dab_bytes = bytemuck::cast_slice(dabs); + let dab_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("dab_storage_buf"), + size: dab_bytes.len() as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(&dab_buf, 0, dab_bytes); - let mut copy_enc = device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") }, + let params = DabParams { + bbox_x0: x0 as i32, + bbox_y0: y0 as i32, + bbox_w, + bbox_h, + num_dabs: dabs.len() as u32, + canvas_w, + canvas_h, + _pad: 0, + }; + let params_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("dab_params_buf"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer(¶ms_buf, 0, bytemuck::bytes_of(¶ms)); + + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("brush_dab_bg"), + layout: &self.compute_bg_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: dab_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: params_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(canvas.src_view()) }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(canvas.dst_view()) }, + ], + }); + + // Step 3: Single dispatch over the union bounding box. + let mut compute_enc = device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") }, + ); + { + let mut pass = compute_enc.begin_compute_pass( + &wgpu::ComputePassDescriptor { label: Some("brush_dab_pass"), timestamp_writes: None }, ); - copy_enc.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: canvas.src(), - mip_level: 0, - origin: wgpu::Origin3d { x: x0, y: y0, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: canvas.dst(), - mip_level: 0, - origin: wgpu::Origin3d { x: x0, y: y0, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { width: bbox_w, height: bbox_h, depth_or_array_layers: 1 }, - ); - queue.submit(Some(copy_enc.finish())); - - let dab_bytes = bytemuck::bytes_of(dab); - let dab_buf = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("dab_storage_buf"), - size: dab_bytes.len() as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - queue.write_buffer(&dab_buf, 0, dab_bytes); - - let params = DabParams { - bbox_x0: x0 as i32, - bbox_y0: y0 as i32, - bbox_w, - bbox_h, - num_dabs: 1, - canvas_w, - canvas_h, - _pad: 0, - }; - let params_buf = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("dab_params_buf"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - queue.write_buffer(¶ms_buf, 0, bytemuck::bytes_of(¶ms)); - - let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("brush_dab_bg"), - layout: &self.compute_bg_layout, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: dab_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: params_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(canvas.src_view()) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(canvas.dst_view()) }, - ], - }); - - let mut compute_enc = device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") }, - ); - { - let mut pass = compute_enc.begin_compute_pass( - &wgpu::ComputePassDescriptor { label: Some("brush_dab_pass"), timestamp_writes: None }, - ); - pass.set_pipeline(&self.compute_pipeline); - pass.set_bind_group(0, &bg, &[]); - pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1); - } - queue.submit(Some(compute_enc.finish())); - canvas.swap(); + pass.set_pipeline(&self.compute_pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1); } + queue.submit(Some(compute_enc.finish())); + + // Step 4: Swap once — dst (with all dabs applied) becomes the new src. + canvas.swap(); } /// Read the current canvas back to a CPU `Vec` (raw RGBA, row-major). @@ -465,6 +475,58 @@ impl GpuBrushEngine { Some(pixels) } + /// Render a set of dabs to an offscreen texture and return the raw pixels. + /// + /// This is a **blocking** GPU readback — intended for one-time renders such as + /// brush preview thumbnails. Do not call every frame on the hot path. + /// + /// The returned `Vec` is in **sRGB-encoded premultiplied RGBA** format, + /// suitable for creating an `egui::ColorImage` via + /// `ColorImage::from_rgba_premultiplied`. + /// + /// A dedicated scratch canvas keyed by a fixed UUID is reused across calls so + /// no allocation is needed after the first invocation. + pub fn render_to_image( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + dabs: &[GpuDab], + width: u32, + height: u32, + ) -> Vec { + use std::sync::OnceLock; + static SCRATCH_ID: OnceLock = OnceLock::new(); + let scratch_id = *SCRATCH_ID.get_or_init(Uuid::new_v4); + + // Ensure a correctly-sized scratch canvas exists. + self.ensure_canvas(device, scratch_id, width, height); + + // Clear to transparent so previous renders don't bleed through. + let blank = vec![0u8; (width * height * 4) as usize]; + if let Some(canvas) = self.canvases.get(&scratch_id) { + canvas.upload(queue, &blank); + } + + if !dabs.is_empty() { + // Compute the union bounding box of all dabs. + let bbox = dabs.iter().fold( + (i32::MAX, i32::MAX, i32::MIN, i32::MIN), + |acc, d| { + let r = d.radius + 1.0; + ( + acc.0.min((d.x - r).floor() as i32), + acc.1.min((d.y - r).floor() as i32), + acc.2.max((d.x + r).ceil() as i32), + acc.3.max((d.y + r).ceil() as i32), + ) + }, + ); + self.render_dabs(device, queue, scratch_id, dabs, bbox, width, height); + } + + self.readback_canvas(device, queue, scratch_id).unwrap_or_default() + } + /// Remove the canvas pair for a keyframe (e.g. when the layer is deleted). pub fn remove_canvas(&mut self, keyframe_id: &Uuid) { self.canvases.remove(keyframe_id); diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index b6f13d4..057baa0 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -768,6 +768,8 @@ struct EditorApp { brush_use_fg: bool, // true = paint with FG (stroke) color, false = BG (fill) color /// Full brush settings for the currently active preset (carries elliptical, jitter, etc.) active_brush_settings: lightningbeam_core::brush_settings::BrushSettings, + /// GPU-rendered brush preview pixel buffers, shared with VelloCallback::prepare(). + brush_preview_pixels: std::sync::Arc)>>>, // Audio engine integration #[allow(dead_code)] // Must be kept alive to maintain audio output audio_stream: Option, @@ -1050,6 +1052,7 @@ impl EditorApp { brush_spacing: 0.1, brush_use_fg: true, active_brush_settings: lightningbeam_core::brush_settings::BrushSettings::default(), + brush_preview_pixels: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), audio_stream, audio_controller, audio_event_rx, @@ -5557,6 +5560,7 @@ impl eframe::App for EditorApp { test_mode: &mut self.test_mode, #[cfg(debug_assertions)] synthetic_input: &mut synthetic_input_storage, + brush_preview_pixels: &self.brush_preview_pixels, }, pane_instances: &mut self.pane_instances, }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 2bb793f..8e4bc7b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -28,6 +28,8 @@ pub struct InfopanelPane { shape_section_open: bool, /// Index of the selected brush preset (None = custom / unset) selected_brush_preset: Option, + /// Cached preview textures, one per preset (populated lazily). + brush_preview_textures: Vec, } impl InfopanelPane { @@ -36,6 +38,7 @@ impl InfopanelPane { tool_section_open: true, shape_section_open: true, selected_brush_preset: None, + brush_preview_textures: Vec::new(), } } } @@ -366,6 +369,27 @@ impl InfopanelPane { let presets = bundled_brushes(); if presets.is_empty() { return; } + // Build preview TextureHandles from GPU-rendered pixel data when available. + if self.brush_preview_textures.len() != presets.len() { + if let Ok(previews) = shared.brush_preview_pixels.try_lock() { + if previews.len() == presets.len() { + self.brush_preview_textures.clear(); + for (idx, (w, h, pixels)) in previews.iter().enumerate() { + let image = egui::ColorImage::from_rgba_premultiplied( + [*w as usize, *h as usize], + pixels, + ); + let handle = ui.ctx().load_texture( + format!("brush_preview_{}", presets[idx].name), + image, + egui::TextureOptions::LINEAR, + ); + self.brush_preview_textures.push(handle); + } + } + } + } + let gap = 3.0; let cols = 2usize; let cell_w = ((ui.available_width() - gap * (cols as f32 - 1.0)) / cols as f32).max(50.0); @@ -406,7 +430,13 @@ impl InfopanelPane { rect.min + egui::vec2(4.0, 4.0), egui::vec2(cell_w - 8.0, cell_h - 22.0), ); - paint_brush_dab(painter, preview_rect, &preset.settings); + if let Some(tex) = self.brush_preview_textures.get(idx) { + painter.image( + tex.id(), preview_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } // Name painter.text( @@ -424,10 +454,11 @@ impl InfopanelPane { if resp.clicked() { self.selected_brush_preset = Some(idx); let s = &preset.settings; - *shared.brush_radius = s.radius_at_pressure(0.5).clamp(1.0, 200.0); - *shared.brush_opacity = s.opaque.clamp(0.0, 1.0); + // Size is intentionally NOT reset — it is global and persists + // across preset switches. All other parameters load from preset. + *shared.brush_opacity = s.opaque.clamp(0.0, 1.0); *shared.brush_hardness = s.hardness.clamp(0.0, 1.0); - *shared.brush_spacing = s.dabs_per_radius; + *shared.brush_spacing = s.dabs_per_radius; *shared.active_brush_settings = s.clone(); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 9ef22be..1ad44b4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -293,6 +293,10 @@ pub struct SharedPaneState<'a> { /// Synthetic input from test mode replay (debug builds only) #[cfg(debug_assertions)] pub synthetic_input: &'a mut Option, + /// GPU-rendered brush preview thumbnails. Populated by `VelloCallback::prepare()` + /// on the first frame; panes (e.g. infopanel) convert the pixel data to egui + /// TextureHandles. Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`. + pub brush_preview_pixels: &'a std::sync::Arc)>>>, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index f5cc640..66179fa 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -412,6 +412,11 @@ struct VelloRenderContext { /// True while the current stroke targets the float buffer (B) rather than /// the layer canvas (A). Used in prepare() to route the GPU canvas blit. painting_float: bool, + /// Shared pixel buffer for brush preview thumbnails. + /// `prepare()` renders all presets here on the first frame; + /// the infopanel converts the pixel data to egui TextureHandles. + /// Each entry is `(width, height, sRGB-premultiplied RGBA bytes)`. + brush_preview_pixels: std::sync::Arc)>>>, } /// Callback for Vello rendering within egui @@ -581,6 +586,49 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // Generate brush preview thumbnails on first use (one-time, blocking readback). + if let Ok(mut previews) = self.ctx.brush_preview_pixels.try_lock() { + if previews.is_empty() { + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + use lightningbeam_core::brush_engine::{BrushEngine, StrokeState}; + use lightningbeam_core::raster_layer::{StrokeRecord, StrokePoint, RasterBlendMode}; + use lightningbeam_core::brush_settings::bundled_brushes; + + const PW: u32 = 120; + const PH: u32 = 56; + + for preset in bundled_brushes() { + let preview_radius = (PH as f32 * 0.22).max(2.5); + let mut scaled = preset.settings.clone(); + scaled.radius_log = preview_radius.ln(); + scaled.slow_tracking = 0.0; + scaled.slow_tracking_per_dab = 0.0; + + let y_lo = PH as f32 * 0.72; + let y_hi = PH as f32 * 0.28; + let x0 = PW as f32 * 0.10; + let x1 = PW as f32 * 0.90; + let mid_x = (x0 + x1) * 0.5; + let mid_y = (y_lo + y_hi) * 0.5; + let stroke = StrokeRecord { + brush_settings: scaled, + color: [0.85f32, 0.88, 1.0, 1.0], + blend_mode: RasterBlendMode::Normal, + points: vec![ + StrokePoint { x: x0, y: y_lo, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, + StrokePoint { x: mid_x, y: mid_y, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, + StrokePoint { x: x1, y: y_hi, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 }, + ], + }; + let mut state = StrokeState::new(); + let (dabs, _) = BrushEngine::compute_dabs(&stroke, &mut state, 0.0); + let pixels = gpu_brush.render_to_image(device, queue, &dabs, PW, PH); + previews.push((PW, PH, pixels)); + } + } + } + } + let mut image_cache = shared.image_cache.lock().unwrap(); let composite_result = lightningbeam_core::renderer::render_document_for_compositing( @@ -2422,6 +2470,9 @@ pub struct StagePane { /// True while the current stroke is being painted onto the float buffer (B) /// rather than the layer canvas (A). painting_float: bool, + /// Timestamp (ui time in seconds) of the last `compute_dabs` call for this stroke. + /// Used to compute `dt` for the unified distance+time dab accumulator. + raster_last_compute_time: f64, /// Synthetic drag/click override for test mode replay (debug builds only) #[cfg(debug_assertions)] replay_override: Option, @@ -2545,6 +2596,7 @@ impl StagePane { pending_canvas_removal: None, stroke_clip_selection: None, painting_float: false, + raster_last_compute_time: 0.0, #[cfg(debug_assertions)] replay_override: None, } @@ -4723,7 +4775,11 @@ impl StagePane { // Start from the active preset (carries elliptical ratio/angle, jitter, etc.) // then override the four parameters the user controls via UI sliders. let mut b = shared.active_brush_settings.clone(); - b.radius_log = shared.brush_radius.ln(); + // Compensate for pressure_radius_gain so that the UI-chosen radius is the + // actual rendered radius at our fixed mouse pressure of 1.0. + // radius_at_pressure(1.0) = exp(radius_log + gain × 0.5) + // → radius_log = ln(brush_radius) - gain × 0.5 + b.radius_log = shared.brush_radius.ln() - b.pressure_radius_gain * 0.5; b.hardness = *shared.brush_hardness; b.opaque = *shared.brush_opacity; b.dabs_per_radius = *shared.brush_spacing; @@ -4744,7 +4800,16 @@ impl StagePane { // ---------------------------------------------------------------- // Mouse down: capture buffer_before, start stroke, compute first dab // ---------------------------------------------------------------- - if self.rsp_drag_started(response) || self.rsp_clicked(response) { + // Use primary_pressed (fires immediately on mouse-down) so the first dab + // appears before any drag movement. Guard against re-triggering if a stroke + // is already in progress. + // rsp_clicked fires on the release frame of a quick click; the first condition + // already handles the press frame with is_none() guard. The clicked guard is + // only needed when no stroke is active (avoids re-starting mid-stroke). + let stroke_start = (self.rsp_primary_pressed(ui) && response.hovered() + && self.raster_stroke_state.is_none()) + || (self.rsp_clicked(response) && self.raster_stroke_state.is_none()); + if stroke_start { // Determine if we are painting into the float (B) or the layer (A). let painting_float = shared.selection.raster_floating.is_some(); self.painting_float = painting_float; @@ -4763,7 +4828,6 @@ impl StagePane { // Compute first dab (same arithmetic as the layer case). let mut stroke_state = StrokeState::new(); - stroke_state.distance_since_last_dab = f32::MAX; // Convert to float-local space: dabs must be in canvas pixel coords. let first_pt = StrokePoint { x: world_pos.x - float_x as f32, @@ -4776,7 +4840,8 @@ impl StagePane { blend_mode, points: vec![first_pt.clone()], }; - let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state); + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); + self.raster_last_compute_time = ui.input(|i| i.time); self.painting_canvas = Some((layer_id, canvas_id)); self.pending_undo_before = Some(( @@ -4852,7 +4917,6 @@ impl StagePane { // Compute the first dab (single-point tap) let mut stroke_state = StrokeState::new(); - stroke_state.distance_since_last_dab = f32::MAX; let first_pt = StrokePoint { x: world_pos.x, y: world_pos.y, @@ -4864,7 +4928,8 @@ impl StagePane { blend_mode, points: vec![first_pt.clone()], }; - let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state); + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state, 0.0); + self.raster_last_compute_time = ui.input(|i| i.time); // Layer strokes apply selection masking at readback time via stroke_clip_selection. @@ -4946,7 +5011,10 @@ impl StagePane { blend_mode, points: vec![prev_pt, curr_local], }; - let (dabs, dab_bbox) = BrushEngine::compute_dabs(&seg, stroke_state); + let current_time = ui.input(|i| i.time); + let dt = (current_time - self.raster_last_compute_time).clamp(0.0, 0.1) as f32; + self.raster_last_compute_time = current_time; + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&seg, stroke_state, dt); self.pending_raster_dabs = Some(PendingRasterDabs { keyframe_id: canvas_id, layer_id, @@ -4965,6 +5033,78 @@ impl StagePane { } } + // ---------------------------------------------------------------- + // Stationary time-based dabs: when the mouse hasn't moved this frame, + // still pass dt to the engine so time-based brushes (airbrush, etc.) + // can accumulate and fire at the cursor position. + // ---------------------------------------------------------------- + if self.pending_raster_dabs.is_none() + && matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. }) + { + let current_time = ui.input(|i| i.time); + if self.raster_last_compute_time > 0.0 { + let dt = (current_time - self.raster_last_compute_time).clamp(0.0, 0.1) as f32; + self.raster_last_compute_time = current_time; + + if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state { + let canvas_info = if self.painting_float { + shared.selection.raster_floating.as_ref().map(|f| { + (f.canvas_id, f.width, f.height, f.x as f32, f.y as f32) + }) + } else { + let doc = shared.action_executor.document(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) { + if let Some(kf) = rl.keyframe_at(time) { + Some((kf.id, kf.width, kf.height, 0.0f32, 0.0f32)) + } else { None } + } else { None } + }; + + if let Some((canvas_id, cw, ch, cx, cy)) = canvas_info { + let pt = StrokePoint { + x: world_pos.x - cx, + y: world_pos.y - cy, + pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0, + }; + let single = StrokeRecord { + brush_settings: brush.clone(), + color, + blend_mode, + points: vec![pt], + }; + let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, stroke_state, dt); + if !dabs.is_empty() { + self.pending_raster_dabs = Some(PendingRasterDabs { + keyframe_id: canvas_id, + layer_id, + time, + canvas_width: cw, + canvas_height: ch, + initial_pixels: None, + dabs, + dab_bbox, + wants_final_readback: false, + }); + } + } + } + } + } + + // Reset compute-time tracker when stroke ends so next stroke starts fresh. + if !matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. }) { + self.raster_last_compute_time = 0.0; + } + + // Keep egui repainting while a stroke is active so that: + // 1. Time-based dabs (dabs_per_second) fire at the correct rate even when the + // mouse is held stationary (no move events → no automatic egui repaint). + // 2. The post-stroke Vello update (consuming the readback result) happens on + // the very next frame rather than waiting for the next user input event. + if matches!(*shared.tool_state, ToolState::DrawingRasterStroke { .. }) { + ui.ctx().request_repaint(); + } + // ---------------------------------------------------------------- // Mouse up: request a full-canvas readback for the undo snapshot // ---------------------------------------------------------------- @@ -8196,6 +8336,7 @@ impl PaneRenderer for StagePane { painting_canvas: self.painting_canvas, pending_canvas_removal: self.pending_canvas_removal.take(), painting_float: self.painting_float, + brush_preview_pixels: shared.brush_preview_pixels.clone(), }}; let cb = egui_wgpu::Callback::new_paint_callback( diff --git a/src/assets/brushes/airbrush.myb b/src/assets/brushes/airbrush.myb index 70e14b7..9babe67 100644 --- a/src/assets/brushes/airbrush.myb +++ b/src/assets/brushes/airbrush.myb @@ -67,7 +67,7 @@ "inputs": {} }, "dabs_per_second": { - "base_value": 0.0, + "base_value": 30.0, "inputs": {} }, "direction_filter": {