brush fixes and improvements

This commit is contained in:
Skyler Lehmkuhl 2026-03-05 20:24:38 -05:00
parent 292328bf87
commit f2c15d7f0d
7 changed files with 571 additions and 185 deletions

View File

@ -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<GpuDab>, (i32, i32, i32, i32)) {
let mut dabs: Vec<GpuDab> = 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);
}
}

View File

@ -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::<DabParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&params_buf, 0, bytemuck::bytes_of(&params));
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::<DabParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&params_buf, 0, bytemuck::bytes_of(&params));
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<u8>` (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<u8>` 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<u8> {
use std::sync::OnceLock;
static SCRATCH_ID: OnceLock<Uuid> = 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);

View File

@ -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<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
// Audio engine integration
#[allow(dead_code)] // Must be kept alive to maintain audio output
audio_stream: Option<cpal::Stream>,
@ -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,
};

View File

@ -28,6 +28,8 @@ pub struct InfopanelPane {
shape_section_open: bool,
/// Index of the selected brush preset (None = custom / unset)
selected_brush_preset: Option<usize>,
/// Cached preview textures, one per preset (populated lazily).
brush_preview_textures: Vec<egui::TextureHandle>,
}
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();
}
}

View File

@ -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<crate::test_mode::SyntheticInput>,
/// 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<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
}
/// Trait for pane rendering

View File

@ -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<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
}
/// 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<ReplayDragState>,
@ -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(

View File

@ -67,7 +67,7 @@
"inputs": {}
},
"dabs_per_second": {
"base_value": 0.0,
"base_value": 30.0,
"inputs": {}
},
"direction_filter": {