brush fixes and improvements
This commit is contained in:
parent
292328bf87
commit
f2c15d7f0d
|
|
@ -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,
|
||||
|
|
@ -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 !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) {
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,47 +293,49 @@ 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; }
|
||||
|
||||
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; }
|
||||
let canvas = match self.canvases.get_mut(&keyframe_id) {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// 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 canvas = self.canvases.get_mut(&keyframe_id).unwrap();
|
||||
|
||||
// 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_copy_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 { x: x0, y: y0, z: 0 },
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: canvas.dst(),
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d { x: x0, y: y0, z: 0 },
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::Extent3d { width: bbox_w, height: bbox_h, depth_or_array_layers: 1 },
|
||||
wgpu::Extent3d { width: canvas_w, height: canvas_h, depth_or_array_layers: 1 },
|
||||
);
|
||||
queue.submit(Some(copy_enc.finish()));
|
||||
|
||||
let dab_bytes = bytemuck::bytes_of(dab);
|
||||
// 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,
|
||||
|
|
@ -341,7 +349,7 @@ impl GpuBrushEngine {
|
|||
bbox_y0: y0 as i32,
|
||||
bbox_w,
|
||||
bbox_h,
|
||||
num_dabs: 1,
|
||||
num_dabs: dabs.len() as u32,
|
||||
canvas_w,
|
||||
canvas_h,
|
||||
_pad: 0,
|
||||
|
|
@ -365,6 +373,7 @@ impl GpuBrushEngine {
|
|||
],
|
||||
});
|
||||
|
||||
// Step 3: Single dispatch over the union bounding box.
|
||||
let mut compute_enc = device.create_command_encoder(
|
||||
&wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") },
|
||||
);
|
||||
|
|
@ -377,9 +386,10 @@ impl GpuBrushEngine {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,7 +454,8 @@ 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);
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
"inputs": {}
|
||||
},
|
||||
"dabs_per_second": {
|
||||
"base_value": 0.0,
|
||||
"base_value": 30.0,
|
||||
"inputs": {}
|
||||
},
|
||||
"direction_filter": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue