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.
|
//! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation.
|
||||||
//!
|
//!
|
||||||
//! ### Dab placement
|
//! ### Dab placement
|
||||||
//! Spacing = 1 / max(dabs_per_basic_radius/radius, dabs_per_actual_radius/actual_radius).
|
//! Follows the libmypaint model: distance-based and time-based contributions are
|
||||||
//! Fractional remainder is tracked across consecutive calls via `StrokeState`.
|
//! **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
|
//! ### Blending
|
||||||
//! Normal mode uses the standard "over" operator on premultiplied RGBA.
|
//! 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)
|
/// Transient brush stroke state (tracks position and randomness between segments)
|
||||||
pub struct StrokeState {
|
pub struct StrokeState {
|
||||||
/// Distance along the path already "consumed" toward the next dab (in pixels)
|
/// Fractional dab accumulator — reaches 1.0 when the next dab should fire.
|
||||||
pub distance_since_last_dab: f32,
|
/// 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
|
/// Exponentially-smoothed cursor X for slow_tracking
|
||||||
pub smooth_x: f32,
|
pub smooth_x: f32,
|
||||||
/// Exponentially-smoothed cursor Y for slow_tracking
|
/// Exponentially-smoothed cursor Y for slow_tracking
|
||||||
|
|
@ -91,7 +106,8 @@ pub struct StrokeState {
|
||||||
impl StrokeState {
|
impl StrokeState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
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_x: 0.0,
|
||||||
smooth_y: 0.0,
|
smooth_y: 0.0,
|
||||||
smooth_initialized: false,
|
smooth_initialized: false,
|
||||||
|
|
@ -122,8 +138,17 @@ fn xorshift(seed: &mut u32) -> f32 {
|
||||||
(s as f32) / (u32::MAX as f32)
|
(s as f32) / (u32::MAX as f32)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert linear RGB (premultiplied, alpha already separated) to HSV.
|
/// Box-Muller Gaussian sample with mean 0 and std-dev 1.
|
||||||
/// Input: r, g, b in [0, 1] (not premultiplied; caller divides by alpha first).
|
/// 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) {
|
fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
||||||
let max = r.max(g).max(b);
|
let max = r.max(g).max(b);
|
||||||
let min = r.min(g).min(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
|
/// Pure-Rust MyPaint-style Gaussian dab brush engine
|
||||||
pub struct BrushEngine;
|
pub struct BrushEngine;
|
||||||
|
|
||||||
impl BrushEngine {
|
impl BrushEngine {
|
||||||
/// Compute the list of GPU dabs for a stroke segment.
|
/// Compute the list of GPU dabs for a stroke segment.
|
||||||
///
|
///
|
||||||
/// Uses the MyPaint dab-spacing formula and produces [`GpuDab`] structs for
|
/// `dt` is the elapsed time in seconds since the previous call for this
|
||||||
/// upload to the GPU compute pipeline.
|
/// 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
|
/// Follows the libmypaint spacing model: distance-based and time-based
|
||||||
/// integer canvas pixel coordinates (`x0==i32::MAX` when the Vec is empty).
|
/// 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(
|
pub fn compute_dabs(
|
||||||
stroke: &StrokeRecord,
|
stroke: &StrokeRecord,
|
||||||
state: &mut StrokeState,
|
state: &mut StrokeState,
|
||||||
|
dt: f32,
|
||||||
) -> (Vec<GpuDab>, (i32, i32, i32, i32)) {
|
) -> (Vec<GpuDab>, (i32, i32, i32, i32)) {
|
||||||
let mut dabs: Vec<GpuDab> = Vec::new();
|
let mut dabs: Vec<GpuDab> = Vec::new();
|
||||||
let mut bbox = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
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 stroke.points.len() < 2 {
|
||||||
if let Some(pt) = stroke.points.first() {
|
if let Some(pt) = stroke.points.first() {
|
||||||
let r = bs.radius_at_pressure(pt.pressure);
|
if !state.smooth_initialized {
|
||||||
// Default dpr for a single tap: prefer actual_radius spacing
|
state.smooth_x = pt.x;
|
||||||
let dpr = if bs.dabs_per_radius > 0.0 { bs.dabs_per_radius }
|
state.smooth_y = pt.y;
|
||||||
else { bs.dabs_per_actual_radius.max(0.01) };
|
state.smooth_initialized = true;
|
||||||
let raw_o = bs.opacity_at_pressure(pt.pressure);
|
}
|
||||||
let o = (1.0 - (1.0 - raw_o).powf(dpr * 0.5)
|
while state.partial_dabs >= 1.0 {
|
||||||
* (1.0 + bs.opaque_multiply)).clamp(0.0, 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) {
|
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, ex, ey, r, o, cr, cg, cb,
|
||||||
push_dab(&mut dabs, &mut bbox, pt.x, pt.y, r, o, cr, cg, cb,
|
|
||||||
0.0, 0.0, 0.0);
|
0.0, 0.0, 0.0);
|
||||||
}
|
}
|
||||||
state.distance_since_last_dab = 0.0;
|
}
|
||||||
}
|
}
|
||||||
return (dabs, bbox);
|
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) {
|
for window in stroke.points.windows(2) {
|
||||||
let p0 = &window[0];
|
let p0 = &window[0];
|
||||||
let p1 = &window[1];
|
let p1 = &window[1];
|
||||||
|
|
@ -246,27 +389,39 @@ impl BrushEngine {
|
||||||
let seg_len = (dx * dx + dy * dy).sqrt();
|
let seg_len = (dx * dx + dy * dy).sqrt();
|
||||||
if seg_len < 1e-4 { continue; }
|
if seg_len < 1e-4 { continue; }
|
||||||
|
|
||||||
|
last_pressure = p1.pressure;
|
||||||
|
|
||||||
let mut t = 0.0f32;
|
let mut t = 0.0f32;
|
||||||
while t < 1.0 {
|
while t < 1.0 {
|
||||||
let pressure = p0.pressure + t * (p1.pressure - p0.pressure);
|
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.
|
// Dab rate = sum of distance-based contributions (dabs per pixel).
|
||||||
// dabs_per_basic_radius = N dabs per basic_radius pixels → spacing = basic_r / N
|
// Matches libmypaint: dabs_per_actual/actual_r + dabs_per_basic/base_r.
|
||||||
// dabs_per_actual_radius = N dabs per actual_radius pixels → spacing = actual_r / N
|
// For elliptical brushes use the minor-axis radius so dabs connect
|
||||||
let spacing_basic = if bs.dabs_per_radius > 0.0 {
|
// when moving perpendicular to the major axis.
|
||||||
radius2 / bs.dabs_per_radius
|
let eff_radius = if bs.elliptical_dab_ratio > 1.001 {
|
||||||
} else { f32::MAX };
|
radius_for_rate / bs.elliptical_dab_ratio
|
||||||
let spacing_actual = if bs.dabs_per_actual_radius > 0.0 {
|
} else {
|
||||||
radius2 / bs.dabs_per_actual_radius
|
radius_for_rate
|
||||||
} else { f32::MAX };
|
};
|
||||||
let spacing = spacing_basic.min(spacing_actual).max(0.5);
|
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 remaining = 1.0 - state.partial_dabs;
|
||||||
let seg_t_to_next = (dist_to_next / seg_len).max(0.0);
|
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 {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,17 +430,11 @@ impl BrushEngine {
|
||||||
|
|
||||||
// Stroke threshold gating
|
// Stroke threshold gating
|
||||||
if pressure2 < bs.stroke_threshold {
|
if pressure2 < bs.stroke_threshold {
|
||||||
state.distance_since_last_dab = 0.0;
|
state.partial_dabs = 0.0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut radius2 = bs.radius_at_pressure(pressure2);
|
let base_r2 = 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);
|
|
||||||
|
|
||||||
// Slow tracking: exponential position smoothing
|
// Slow tracking: exponential position smoothing
|
||||||
let x2 = p0.x + t * dx;
|
let x2 = p0.x + t * dx;
|
||||||
|
|
@ -294,57 +443,25 @@ impl BrushEngine {
|
||||||
state.smooth_x = x2; state.smooth_y = y2;
|
state.smooth_x = x2; state.smooth_y = y2;
|
||||||
state.smooth_initialized = true;
|
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 {
|
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 };
|
} else { 0.0 };
|
||||||
state.smooth_x = state.smooth_x * k + x2 * (1.0 - k);
|
state.smooth_x = state.smooth_x * k + x2 * (1.0 - k);
|
||||||
state.smooth_y = state.smooth_y * k + y2 * (1.0 - k);
|
state.smooth_y = state.smooth_y * k + y2 * (1.0 - k);
|
||||||
let mut ex = state.smooth_x;
|
last_smooth_x = state.smooth_x;
|
||||||
let mut ey = state.smooth_y;
|
last_smooth_y = state.smooth_y;
|
||||||
|
|
||||||
// Radius jitter (log-scale)
|
let (sx, sy) = (state.smooth_x, state.smooth_y);
|
||||||
if bs.radius_by_random != 0.0 {
|
let (ex, ey, radius2, opacity2, cr, cg, cb) = apply_dab_effects(
|
||||||
let r_rng = xorshift(&mut state.rng_seed) * 2.0 - 1.0;
|
state, bs, sx, sy, base_r2, pressure2, stroke.color,
|
||||||
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 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) {
|
if matches!(base_blend, RasterBlendMode::Smudge) {
|
||||||
let ndx = dx / seg_len;
|
let ndx = dx / seg_len;
|
||||||
let ndy = dy / seg_len;
|
let ndy = dy / seg_len;
|
||||||
let smudge_dist =
|
let smudge_dist = radius2 * bs.smudge_radius_log.exp();
|
||||||
(radius2 * dpr).max(1.0) * bs.smudge_radius_log.exp();
|
|
||||||
push_dab(&mut dabs, &mut bbox,
|
push_dab(&mut dabs, &mut bbox,
|
||||||
ex, ey, radius2, opacity2, cr, cg, cb,
|
ex, ey, radius2, opacity2, cr, cg, cb,
|
||||||
ndx, ndy, smudge_dist);
|
ndx, ndy, smudge_dist);
|
||||||
|
|
@ -354,7 +471,34 @@ impl BrushEngine {
|
||||||
0.0, 0.0, 0.0);
|
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`.
|
/// 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,
|
/// All dabs for the frame are batched into a single GPU dispatch:
|
||||||
/// dispatch the compute shader, then swap. The bbox-only copy is safe because
|
/// 1. Copy the FULL canvas src→dst (so dst has all previous dabs).
|
||||||
/// neither normal/erase nor smudge reads outside the current dab's radius.
|
/// 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.
|
/// If `dabs` is empty, does nothing.
|
||||||
pub fn render_dabs(
|
pub fn render_dabs(
|
||||||
|
|
@ -287,47 +293,49 @@ impl GpuBrushEngine {
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
keyframe_id: Uuid,
|
keyframe_id: Uuid,
|
||||||
dabs: &[GpuDab],
|
dabs: &[GpuDab],
|
||||||
_bbox: (i32, i32, i32, i32),
|
bbox: (i32, i32, i32, i32),
|
||||||
canvas_w: u32,
|
canvas_w: u32,
|
||||||
canvas_h: u32,
|
canvas_h: u32,
|
||||||
) {
|
) {
|
||||||
if dabs.is_empty() { return; }
|
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,
|
||||||
for dab in dabs {
|
None => return,
|
||||||
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_w = x1 - x0;
|
||||||
let bbox_h = y1 - y0;
|
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(
|
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(
|
copy_enc.copy_texture_to_texture(
|
||||||
wgpu::TexelCopyTextureInfo {
|
wgpu::TexelCopyTextureInfo {
|
||||||
texture: canvas.src(),
|
texture: canvas.src(),
|
||||||
mip_level: 0,
|
mip_level: 0,
|
||||||
origin: wgpu::Origin3d { x: x0, y: y0, z: 0 },
|
origin: wgpu::Origin3d::ZERO,
|
||||||
aspect: wgpu::TextureAspect::All,
|
aspect: wgpu::TextureAspect::All,
|
||||||
},
|
},
|
||||||
wgpu::TexelCopyTextureInfo {
|
wgpu::TexelCopyTextureInfo {
|
||||||
texture: canvas.dst(),
|
texture: canvas.dst(),
|
||||||
mip_level: 0,
|
mip_level: 0,
|
||||||
origin: wgpu::Origin3d { x: x0, y: y0, z: 0 },
|
origin: wgpu::Origin3d::ZERO,
|
||||||
aspect: wgpu::TextureAspect::All,
|
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()));
|
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 {
|
let dab_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("dab_storage_buf"),
|
label: Some("dab_storage_buf"),
|
||||||
size: dab_bytes.len() as u64,
|
size: dab_bytes.len() as u64,
|
||||||
|
|
@ -341,7 +349,7 @@ impl GpuBrushEngine {
|
||||||
bbox_y0: y0 as i32,
|
bbox_y0: y0 as i32,
|
||||||
bbox_w,
|
bbox_w,
|
||||||
bbox_h,
|
bbox_h,
|
||||||
num_dabs: 1,
|
num_dabs: dabs.len() as u32,
|
||||||
canvas_w,
|
canvas_w,
|
||||||
canvas_h,
|
canvas_h,
|
||||||
_pad: 0,
|
_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(
|
let mut compute_enc = device.create_command_encoder(
|
||||||
&wgpu::CommandEncoderDescriptor { label: Some("brush_dab_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);
|
pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1);
|
||||||
}
|
}
|
||||||
queue.submit(Some(compute_enc.finish()));
|
queue.submit(Some(compute_enc.finish()));
|
||||||
|
|
||||||
|
// Step 4: Swap once — dst (with all dabs applied) becomes the new src.
|
||||||
canvas.swap();
|
canvas.swap();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the current canvas back to a CPU `Vec<u8>` (raw RGBA, row-major).
|
/// Read the current canvas back to a CPU `Vec<u8>` (raw RGBA, row-major).
|
||||||
///
|
///
|
||||||
|
|
@ -465,6 +475,58 @@ impl GpuBrushEngine {
|
||||||
Some(pixels)
|
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).
|
/// Remove the canvas pair for a keyframe (e.g. when the layer is deleted).
|
||||||
pub fn remove_canvas(&mut self, keyframe_id: &Uuid) {
|
pub fn remove_canvas(&mut self, keyframe_id: &Uuid) {
|
||||||
self.canvases.remove(keyframe_id);
|
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
|
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.)
|
/// Full brush settings for the currently active preset (carries elliptical, jitter, etc.)
|
||||||
active_brush_settings: lightningbeam_core::brush_settings::BrushSettings,
|
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
|
// Audio engine integration
|
||||||
#[allow(dead_code)] // Must be kept alive to maintain audio output
|
#[allow(dead_code)] // Must be kept alive to maintain audio output
|
||||||
audio_stream: Option<cpal::Stream>,
|
audio_stream: Option<cpal::Stream>,
|
||||||
|
|
@ -1050,6 +1052,7 @@ impl EditorApp {
|
||||||
brush_spacing: 0.1,
|
brush_spacing: 0.1,
|
||||||
brush_use_fg: true,
|
brush_use_fg: true,
|
||||||
active_brush_settings: lightningbeam_core::brush_settings::BrushSettings::default(),
|
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_stream,
|
||||||
audio_controller,
|
audio_controller,
|
||||||
audio_event_rx,
|
audio_event_rx,
|
||||||
|
|
@ -5557,6 +5560,7 @@ impl eframe::App for EditorApp {
|
||||||
test_mode: &mut self.test_mode,
|
test_mode: &mut self.test_mode,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
synthetic_input: &mut synthetic_input_storage,
|
synthetic_input: &mut synthetic_input_storage,
|
||||||
|
brush_preview_pixels: &self.brush_preview_pixels,
|
||||||
},
|
},
|
||||||
pane_instances: &mut self.pane_instances,
|
pane_instances: &mut self.pane_instances,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ pub struct InfopanelPane {
|
||||||
shape_section_open: bool,
|
shape_section_open: bool,
|
||||||
/// Index of the selected brush preset (None = custom / unset)
|
/// Index of the selected brush preset (None = custom / unset)
|
||||||
selected_brush_preset: Option<usize>,
|
selected_brush_preset: Option<usize>,
|
||||||
|
/// Cached preview textures, one per preset (populated lazily).
|
||||||
|
brush_preview_textures: Vec<egui::TextureHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InfopanelPane {
|
impl InfopanelPane {
|
||||||
|
|
@ -36,6 +38,7 @@ impl InfopanelPane {
|
||||||
tool_section_open: true,
|
tool_section_open: true,
|
||||||
shape_section_open: true,
|
shape_section_open: true,
|
||||||
selected_brush_preset: None,
|
selected_brush_preset: None,
|
||||||
|
brush_preview_textures: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -366,6 +369,27 @@ impl InfopanelPane {
|
||||||
let presets = bundled_brushes();
|
let presets = bundled_brushes();
|
||||||
if presets.is_empty() { return; }
|
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 gap = 3.0;
|
||||||
let cols = 2usize;
|
let cols = 2usize;
|
||||||
let cell_w = ((ui.available_width() - gap * (cols as f32 - 1.0)) / cols as f32).max(50.0);
|
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),
|
rect.min + egui::vec2(4.0, 4.0),
|
||||||
egui::vec2(cell_w - 8.0, cell_h - 22.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
|
// Name
|
||||||
painter.text(
|
painter.text(
|
||||||
|
|
@ -424,7 +454,8 @@ impl InfopanelPane {
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
self.selected_brush_preset = Some(idx);
|
self.selected_brush_preset = Some(idx);
|
||||||
let s = &preset.settings;
|
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_opacity = s.opaque.clamp(0.0, 1.0);
|
||||||
*shared.brush_hardness = s.hardness.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;
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,10 @@ pub struct SharedPaneState<'a> {
|
||||||
/// Synthetic input from test mode replay (debug builds only)
|
/// Synthetic input from test mode replay (debug builds only)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub synthetic_input: &'a mut Option<crate::test_mode::SyntheticInput>,
|
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
|
/// Trait for pane rendering
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,11 @@ struct VelloRenderContext {
|
||||||
/// True while the current stroke targets the float buffer (B) rather than
|
/// 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.
|
/// the layer canvas (A). Used in prepare() to route the GPU canvas blit.
|
||||||
painting_float: bool,
|
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
|
/// 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 mut image_cache = shared.image_cache.lock().unwrap();
|
||||||
|
|
||||||
let composite_result = lightningbeam_core::renderer::render_document_for_compositing(
|
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)
|
/// True while the current stroke is being painted onto the float buffer (B)
|
||||||
/// rather than the layer canvas (A).
|
/// rather than the layer canvas (A).
|
||||||
painting_float: bool,
|
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)
|
/// Synthetic drag/click override for test mode replay (debug builds only)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: Option<ReplayDragState>,
|
replay_override: Option<ReplayDragState>,
|
||||||
|
|
@ -2545,6 +2596,7 @@ impl StagePane {
|
||||||
pending_canvas_removal: None,
|
pending_canvas_removal: None,
|
||||||
stroke_clip_selection: None,
|
stroke_clip_selection: None,
|
||||||
painting_float: false,
|
painting_float: false,
|
||||||
|
raster_last_compute_time: 0.0,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: None,
|
replay_override: None,
|
||||||
}
|
}
|
||||||
|
|
@ -4723,7 +4775,11 @@ impl StagePane {
|
||||||
// Start from the active preset (carries elliptical ratio/angle, jitter, etc.)
|
// Start from the active preset (carries elliptical ratio/angle, jitter, etc.)
|
||||||
// then override the four parameters the user controls via UI sliders.
|
// then override the four parameters the user controls via UI sliders.
|
||||||
let mut b = shared.active_brush_settings.clone();
|
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.hardness = *shared.brush_hardness;
|
||||||
b.opaque = *shared.brush_opacity;
|
b.opaque = *shared.brush_opacity;
|
||||||
b.dabs_per_radius = *shared.brush_spacing;
|
b.dabs_per_radius = *shared.brush_spacing;
|
||||||
|
|
@ -4744,7 +4800,16 @@ impl StagePane {
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// Mouse down: capture buffer_before, start stroke, compute first dab
|
// 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).
|
// Determine if we are painting into the float (B) or the layer (A).
|
||||||
let painting_float = shared.selection.raster_floating.is_some();
|
let painting_float = shared.selection.raster_floating.is_some();
|
||||||
self.painting_float = painting_float;
|
self.painting_float = painting_float;
|
||||||
|
|
@ -4763,7 +4828,6 @@ impl StagePane {
|
||||||
|
|
||||||
// Compute first dab (same arithmetic as the layer case).
|
// Compute first dab (same arithmetic as the layer case).
|
||||||
let mut stroke_state = StrokeState::new();
|
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.
|
// Convert to float-local space: dabs must be in canvas pixel coords.
|
||||||
let first_pt = StrokePoint {
|
let first_pt = StrokePoint {
|
||||||
x: world_pos.x - float_x as f32,
|
x: world_pos.x - float_x as f32,
|
||||||
|
|
@ -4776,7 +4840,8 @@ impl StagePane {
|
||||||
blend_mode,
|
blend_mode,
|
||||||
points: vec![first_pt.clone()],
|
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.painting_canvas = Some((layer_id, canvas_id));
|
||||||
self.pending_undo_before = Some((
|
self.pending_undo_before = Some((
|
||||||
|
|
@ -4852,7 +4917,6 @@ impl StagePane {
|
||||||
|
|
||||||
// Compute the first dab (single-point tap)
|
// Compute the first dab (single-point tap)
|
||||||
let mut stroke_state = StrokeState::new();
|
let mut stroke_state = StrokeState::new();
|
||||||
stroke_state.distance_since_last_dab = f32::MAX;
|
|
||||||
|
|
||||||
let first_pt = StrokePoint {
|
let first_pt = StrokePoint {
|
||||||
x: world_pos.x, y: world_pos.y,
|
x: world_pos.x, y: world_pos.y,
|
||||||
|
|
@ -4864,7 +4928,8 @@ impl StagePane {
|
||||||
blend_mode,
|
blend_mode,
|
||||||
points: vec![first_pt.clone()],
|
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.
|
// Layer strokes apply selection masking at readback time via stroke_clip_selection.
|
||||||
|
|
||||||
|
|
@ -4946,7 +5011,10 @@ impl StagePane {
|
||||||
blend_mode,
|
blend_mode,
|
||||||
points: vec![prev_pt, curr_local],
|
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 {
|
self.pending_raster_dabs = Some(PendingRasterDabs {
|
||||||
keyframe_id: canvas_id,
|
keyframe_id: canvas_id,
|
||||||
layer_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
|
// Mouse up: request a full-canvas readback for the undo snapshot
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
@ -8196,6 +8336,7 @@ impl PaneRenderer for StagePane {
|
||||||
painting_canvas: self.painting_canvas,
|
painting_canvas: self.painting_canvas,
|
||||||
pending_canvas_removal: self.pending_canvas_removal.take(),
|
pending_canvas_removal: self.pending_canvas_removal.take(),
|
||||||
painting_float: self.painting_float,
|
painting_float: self.painting_float,
|
||||||
|
brush_preview_pixels: shared.brush_preview_pixels.clone(),
|
||||||
}};
|
}};
|
||||||
|
|
||||||
let cb = egui_wgpu::Callback::new_paint_callback(
|
let cb = egui_wgpu::Callback::new_paint_callback(
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
"inputs": {}
|
"inputs": {}
|
||||||
},
|
},
|
||||||
"dabs_per_second": {
|
"dabs_per_second": {
|
||||||
"base_value": 0.0,
|
"base_value": 30.0,
|
||||||
"inputs": {}
|
"inputs": {}
|
||||||
},
|
},
|
||||||
"direction_filter": {
|
"direction_filter": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue