painting fixes
This commit is contained in:
parent
759e41d84a
commit
16b0d822e3
|
|
@ -5,27 +5,19 @@
|
||||||
//! Based on the libmypaint brush engine (ISC license, Martin Renold et al.).
|
//! Based on the libmypaint brush engine (ISC license, Martin Renold et al.).
|
||||||
//!
|
//!
|
||||||
//! ### Dab shape
|
//! ### Dab shape
|
||||||
//! For each pixel at normalised squared distance `rr = (dist / radius)²` from the
|
//! For each pixel at normalised distance `r = dist / radius` from the dab centre,
|
||||||
//! dab centre, the opacity weight is calculated using two linear segments:
|
//! the opacity weight uses a flat inner core and smooth quadratic outer falloff:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! - `r > 1`: opa = 0 (outside dab)
|
||||||
//! opa
|
//! - `r ≤ hardness` (or hardness = 1): opa = 1 (fully opaque core)
|
||||||
//! ^
|
//! - `hardness < r ≤ 1`: `opa = ((1 - r) / (1 - hardness))²` (smooth falloff)
|
||||||
//! * .
|
|
||||||
//! | *
|
|
||||||
//! | .
|
|
||||||
//! +-----------*> rr
|
|
||||||
//! 0 hardness 1
|
|
||||||
//! ```
|
|
||||||
//!
|
//!
|
||||||
//! - segment 1 (rr ≤ hardness): `opa = 1 + rr * (-(1/hardness - 1))`
|
//! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation.
|
||||||
//! - segment 2 (hardness < rr ≤ 1): `opa = hardness/(1-hardness) - rr * hardness/(1-hardness)`
|
|
||||||
//! - rr > 1: opa = 0
|
|
||||||
//!
|
//!
|
||||||
//! ### Dab placement
|
//! ### Dab placement
|
||||||
//! Dabs are placed along the stroke polyline at intervals of
|
//! Dabs are placed along the stroke polyline at intervals of
|
||||||
//! `spacing = radius * dabs_per_radius`. Fractional remainder is tracked across
|
//! `spacing = radius * dabs_per_radius`. Fractional remainder is tracked across
|
||||||
//! consecutive `apply_stroke` calls via `StrokeState`.
|
//! consecutive calls via `StrokeState`.
|
||||||
//!
|
//!
|
||||||
//! ### Blending
|
//! ### Blending
|
||||||
//! Normal mode uses the standard "over" operator on premultiplied RGBA:
|
//! Normal mode uses the standard "over" operator on premultiplied RGBA:
|
||||||
|
|
@ -120,7 +112,7 @@ impl BrushEngine {
|
||||||
RasterBlendMode::Smudge => 2u32,
|
RasterBlendMode::Smudge => 2u32,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut push_dab = |dabs: &mut Vec<GpuDab>,
|
let push_dab = |dabs: &mut Vec<GpuDab>,
|
||||||
bbox: &mut (i32, i32, i32, i32),
|
bbox: &mut (i32, i32, i32, i32),
|
||||||
x: f32, y: f32,
|
x: f32, y: f32,
|
||||||
radius: f32, opacity: f32,
|
radius: f32, opacity: f32,
|
||||||
|
|
@ -205,312 +197,9 @@ impl BrushEngine {
|
||||||
|
|
||||||
(dabs, bbox)
|
(dabs, bbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a complete stroke to a pixel buffer.
|
|
||||||
///
|
|
||||||
/// A fresh [`StrokeState`] is created for each stroke (starts with full dab
|
|
||||||
/// placement spacing so the first dab lands at the very first point).
|
|
||||||
pub fn apply_stroke(buffer: &mut RgbaImage, stroke: &StrokeRecord) {
|
|
||||||
let mut state = StrokeState::new();
|
|
||||||
// Ensure the very first point always gets a dab
|
|
||||||
state.distance_since_last_dab = f32::MAX;
|
|
||||||
Self::apply_stroke_with_state(buffer, stroke, &mut state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a stroke segment to a buffer while preserving dab-placement state.
|
|
||||||
///
|
|
||||||
/// Use this when building up a stroke incrementally (e.g. live drawing) so
|
|
||||||
/// that dab spacing is consistent across motion events.
|
|
||||||
pub fn apply_stroke_with_state(
|
|
||||||
buffer: &mut RgbaImage,
|
|
||||||
stroke: &StrokeRecord,
|
|
||||||
state: &mut StrokeState,
|
|
||||||
) {
|
|
||||||
if stroke.points.len() < 2 {
|
|
||||||
// Single-point "tap": draw one dab at the given pressure
|
|
||||||
if let Some(pt) = stroke.points.first() {
|
|
||||||
let r = stroke.brush_settings.radius_at_pressure(pt.pressure);
|
|
||||||
let o = stroke.brush_settings.opacity_at_pressure(pt.pressure);
|
|
||||||
// Smudge has no drag direction on a single tap — skip painting
|
|
||||||
if !matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
|
|
||||||
Self::render_dab(buffer, pt.x, pt.y, r, stroke.brush_settings.hardness,
|
|
||||||
o, stroke.color, stroke.blend_mode);
|
|
||||||
}
|
|
||||||
state.distance_since_last_dab = 0.0;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for window in stroke.points.windows(2) {
|
|
||||||
let p0 = &window[0];
|
|
||||||
let p1 = &window[1];
|
|
||||||
|
|
||||||
let dx = p1.x - p0.x;
|
|
||||||
let dy = p1.y - p0.y;
|
|
||||||
let seg_len = (dx * dx + dy * dy).sqrt();
|
|
||||||
if seg_len < 1e-4 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interpolate across this segment
|
|
||||||
let mut t = 0.0f32;
|
|
||||||
while t < 1.0 {
|
|
||||||
let pressure = p0.pressure + t * (p1.pressure - p0.pressure);
|
|
||||||
|
|
||||||
let radius = stroke.brush_settings.radius_at_pressure(pressure);
|
|
||||||
let spacing = radius * stroke.brush_settings.dabs_per_radius;
|
|
||||||
let spacing = spacing.max(0.5); // at least half a pixel
|
|
||||||
|
|
||||||
let dist_to_next = spacing - state.distance_since_last_dab;
|
|
||||||
let seg_t_to_next = (dist_to_next / seg_len).max(0.0);
|
|
||||||
|
|
||||||
if seg_t_to_next > 1.0 - t {
|
|
||||||
// Not enough distance left in this segment for another dab
|
|
||||||
state.distance_since_last_dab += seg_len * (1.0 - t);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
t += seg_t_to_next;
|
|
||||||
let x2 = p0.x + t * dx;
|
|
||||||
let y2 = p0.y + t * dy;
|
|
||||||
let pressure2 = p0.pressure + t * (p1.pressure - p0.pressure);
|
|
||||||
|
|
||||||
let radius2 = stroke.brush_settings.radius_at_pressure(pressure2);
|
|
||||||
let opacity2 = stroke.brush_settings.opacity_at_pressure(pressure2);
|
|
||||||
|
|
||||||
if matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
|
|
||||||
// Directional warp smudge: each pixel in the dab footprint
|
|
||||||
// samples from a position offset backwards along the stroke,
|
|
||||||
// preserving lateral color structure.
|
|
||||||
let ndx = dx / seg_len;
|
|
||||||
let ndy = dy / seg_len;
|
|
||||||
let smudge_dist = (radius2 * stroke.brush_settings.dabs_per_radius).max(1.0);
|
|
||||||
Self::render_smudge_dab(buffer, x2, y2, radius2,
|
|
||||||
stroke.brush_settings.hardness,
|
|
||||||
opacity2, ndx, ndy, smudge_dist);
|
|
||||||
} else {
|
|
||||||
Self::render_dab(buffer, x2, y2, radius2,
|
|
||||||
stroke.brush_settings.hardness,
|
|
||||||
opacity2, stroke.color, stroke.blend_mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.distance_since_last_dab = 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a single Gaussian dab at pixel position (x, y).
|
|
||||||
///
|
|
||||||
/// Uses the two-segment linear falloff from MyPaint/libmypaint for the
|
|
||||||
/// opacity mask, then blends using the requested `blend_mode`.
|
|
||||||
pub fn render_dab(
|
|
||||||
buffer: &mut RgbaImage,
|
|
||||||
x: f32,
|
|
||||||
y: f32,
|
|
||||||
radius: f32,
|
|
||||||
hardness: f32,
|
|
||||||
opacity: f32,
|
|
||||||
color: [f32; 4],
|
|
||||||
blend_mode: RasterBlendMode,
|
|
||||||
) {
|
|
||||||
if radius < 0.5 || opacity <= 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hardness = hardness.clamp(1e-3, 1.0);
|
|
||||||
|
|
||||||
// Pre-compute the two linear-segment coefficients (from libmypaint render_dab_mask)
|
|
||||||
let seg1_offset = 1.0f32;
|
|
||||||
let seg1_slope = -(1.0 / hardness - 1.0);
|
|
||||||
let seg2_offset = hardness / (1.0 - hardness);
|
|
||||||
let seg2_slope = -hardness / (1.0 - hardness);
|
|
||||||
|
|
||||||
let r_fringe = radius + 1.0;
|
|
||||||
let x0 = ((x - r_fringe).floor() as i32).max(0) as u32;
|
|
||||||
let y0 = ((y - r_fringe).floor() as i32).max(0) as u32;
|
|
||||||
let x1 = ((x + r_fringe).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32;
|
|
||||||
let y1 = ((y + r_fringe).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32;
|
|
||||||
|
|
||||||
let one_over_r2 = 1.0 / (radius * radius);
|
|
||||||
|
|
||||||
for py in y0..=y1 {
|
|
||||||
for px in x0..=x1 {
|
|
||||||
let dx = px as f32 + 0.5 - x;
|
|
||||||
let dy = py as f32 + 0.5 - y;
|
|
||||||
let rr = (dx * dx + dy * dy) * one_over_r2;
|
|
||||||
|
|
||||||
if rr > 1.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two-segment opacity (identical to libmypaint calculate_opa)
|
|
||||||
let opa_weight = if rr <= hardness {
|
|
||||||
seg1_offset + rr * seg1_slope
|
|
||||||
} else {
|
|
||||||
seg2_offset + rr * seg2_slope
|
|
||||||
}
|
|
||||||
.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
let dab_alpha = opa_weight * opacity * color[3];
|
|
||||||
if dab_alpha <= 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pixel = buffer.get_pixel_mut(px, py);
|
|
||||||
let dst = [
|
|
||||||
pixel[0] as f32 / 255.0,
|
|
||||||
pixel[1] as f32 / 255.0,
|
|
||||||
pixel[2] as f32 / 255.0,
|
|
||||||
pixel[3] as f32 / 255.0,
|
|
||||||
];
|
|
||||||
|
|
||||||
let (out_r, out_g, out_b, out_a) = match blend_mode {
|
|
||||||
RasterBlendMode::Normal | RasterBlendMode::Smudge => {
|
|
||||||
// Standard "over" operator (smudge pre-computes its color upstream)
|
|
||||||
let oa = dab_alpha;
|
|
||||||
let ba = 1.0 - oa;
|
|
||||||
let out_a = oa + ba * dst[3];
|
|
||||||
let out_r = oa * color[0] + ba * dst[0];
|
|
||||||
let out_g = oa * color[1] + ba * dst[1];
|
|
||||||
let out_b = oa * color[2] + ba * dst[2];
|
|
||||||
(out_r, out_g, out_b, out_a)
|
|
||||||
}
|
|
||||||
RasterBlendMode::Erase => {
|
|
||||||
// Multiplicative erase: each dab removes dab_alpha *fraction* of remaining
|
|
||||||
// alpha. This prevents dense overlapping dabs from summing past 1.0 and
|
|
||||||
// fully erasing at low opacity — opacity now controls the per-dab fraction
|
|
||||||
// removed rather than an absolute amount.
|
|
||||||
let new_a = dst[3] * (1.0 - dab_alpha);
|
|
||||||
let scale = if dst[3] > 1e-6 { new_a / dst[3] } else { 0.0 };
|
|
||||||
(dst[0] * scale, dst[1] * scale, dst[2] * scale, new_a)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pixel[0] = (out_r.clamp(0.0, 1.0) * 255.0) as u8;
|
|
||||||
pixel[1] = (out_g.clamp(0.0, 1.0) * 255.0) as u8;
|
|
||||||
pixel[2] = (out_b.clamp(0.0, 1.0) * 255.0) as u8;
|
|
||||||
pixel[3] = (out_a.clamp(0.0, 1.0) * 255.0) as u8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a smudge dab using directional per-pixel warp.
|
|
||||||
///
|
|
||||||
/// Each pixel in the dab footprint samples from the canvas at a position offset
|
|
||||||
/// backwards along `(ndx, ndy)` by `smudge_dist` pixels, then blends that
|
|
||||||
/// sampled color over the current pixel weighted by the dab opacity.
|
|
||||||
///
|
|
||||||
/// Because each pixel samples its own source position, lateral color structure
|
|
||||||
/// is preserved: dragging over a 1-pixel dot with a 20-pixel brush produces a
|
|
||||||
/// narrow streak rather than a uniform smear.
|
|
||||||
///
|
|
||||||
/// Updates are collected before any writes to avoid read/write aliasing.
|
|
||||||
fn render_smudge_dab(
|
|
||||||
buffer: &mut RgbaImage,
|
|
||||||
x: f32,
|
|
||||||
y: f32,
|
|
||||||
radius: f32,
|
|
||||||
hardness: f32,
|
|
||||||
opacity: f32,
|
|
||||||
ndx: f32, // normalized stroke direction x
|
|
||||||
ndy: f32, // normalized stroke direction y
|
|
||||||
smudge_dist: f32,
|
|
||||||
) {
|
|
||||||
if radius < 0.5 || opacity <= 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hardness = hardness.clamp(1e-3, 1.0);
|
|
||||||
let seg1_offset = 1.0f32;
|
|
||||||
let seg1_slope = -(1.0 / hardness - 1.0);
|
|
||||||
let seg2_offset = hardness / (1.0 - hardness);
|
|
||||||
let seg2_slope = -hardness / (1.0 - hardness);
|
|
||||||
|
|
||||||
let r_fringe = radius + 1.0;
|
|
||||||
let x0 = ((x - r_fringe).floor() as i32).max(0) as u32;
|
|
||||||
let y0 = ((y - r_fringe).floor() as i32).max(0) as u32;
|
|
||||||
let x1 = ((x + r_fringe).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32;
|
|
||||||
let y1 = ((y + r_fringe).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32;
|
|
||||||
|
|
||||||
let one_over_r2 = 1.0 / (radius * radius);
|
|
||||||
|
|
||||||
// Collect updates before writing to avoid aliasing between source and dest reads
|
|
||||||
let mut updates: Vec<(u32, u32, [u8; 4])> = Vec::new();
|
|
||||||
|
|
||||||
for py in y0..=y1 {
|
|
||||||
for px in x0..=x1 {
|
|
||||||
let fdx = px as f32 + 0.5 - x;
|
|
||||||
let fdy = py as f32 + 0.5 - y;
|
|
||||||
let rr = (fdx * fdx + fdy * fdy) * one_over_r2;
|
|
||||||
|
|
||||||
if rr > 1.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let opa_weight = if rr <= hardness {
|
|
||||||
seg1_offset + rr * seg1_slope
|
|
||||||
} else {
|
|
||||||
seg2_offset + rr * seg2_slope
|
|
||||||
}
|
|
||||||
.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
let alpha = opa_weight * opacity;
|
|
||||||
if alpha <= 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample from one dab-spacing behind the current position along stroke
|
|
||||||
let src_x = px as f32 + 0.5 - ndx * smudge_dist;
|
|
||||||
let src_y = py as f32 + 0.5 - ndy * smudge_dist;
|
|
||||||
let src = Self::sample_bilinear(buffer, src_x, src_y);
|
|
||||||
|
|
||||||
let dst = buffer.get_pixel(px, py);
|
|
||||||
let da = 1.0 - alpha;
|
|
||||||
let out = [
|
|
||||||
((alpha * src[0] + da * dst[0] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
|
|
||||||
((alpha * src[1] + da * dst[1] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
|
|
||||||
((alpha * src[2] + da * dst[2] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
|
|
||||||
((alpha * src[3] + da * dst[3] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
|
|
||||||
];
|
|
||||||
updates.push((px, py, out));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (px, py, rgba) in updates {
|
|
||||||
let p = buffer.get_pixel_mut(px, py);
|
|
||||||
p[0] = rgba[0];
|
|
||||||
p[1] = rgba[1];
|
|
||||||
p[2] = rgba[2];
|
|
||||||
p[3] = rgba[3];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bilinearly sample a floating-point position from the buffer, clamped to bounds.
|
|
||||||
fn sample_bilinear(buffer: &RgbaImage, x: f32, y: f32) -> [f32; 4] {
|
|
||||||
let w = buffer.width() as i32;
|
|
||||||
let h = buffer.height() as i32;
|
|
||||||
let x0 = (x.floor() as i32).clamp(0, w - 1);
|
|
||||||
let y0 = (y.floor() as i32).clamp(0, h - 1);
|
|
||||||
let x1 = (x0 + 1).min(w - 1);
|
|
||||||
let y1 = (y0 + 1).min(h - 1);
|
|
||||||
let fx = (x - x0 as f32).clamp(0.0, 1.0);
|
|
||||||
let fy = (y - y0 as f32).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
let p00 = buffer.get_pixel(x0 as u32, y0 as u32);
|
|
||||||
let p10 = buffer.get_pixel(x1 as u32, y0 as u32);
|
|
||||||
let p01 = buffer.get_pixel(x0 as u32, y1 as u32);
|
|
||||||
let p11 = buffer.get_pixel(x1 as u32, y1 as u32);
|
|
||||||
|
|
||||||
let mut out = [0.0f32; 4];
|
|
||||||
for i in 0..4 {
|
|
||||||
let top = p00[i] as f32 * (1.0 - fx) + p10[i] as f32 * fx;
|
|
||||||
let bot = p01[i] as f32 * (1.0 - fx) + p11[i] as f32 * fx;
|
|
||||||
out[i] = (top * (1.0 - fy) + bot * fy) / 255.0;
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Create an `RgbaImage` from a raw RGBA pixel buffer.
|
/// Create an `RgbaImage` from a raw RGBA pixel buffer.
|
||||||
///
|
///
|
||||||
/// If `raw` is empty a blank (transparent) image of the given dimensions is returned.
|
/// If `raw` is empty a blank (transparent) image of the given dimensions is returned.
|
||||||
|
|
@ -542,46 +231,6 @@ pub fn decode_png(data: &[u8]) -> Result<RgbaImage, String> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::raster_layer::{StrokePoint, StrokeRecord, RasterBlendMode};
|
|
||||||
use crate::brush_settings::BrushSettings;
|
|
||||||
|
|
||||||
fn make_stroke(color: [f32; 4]) -> StrokeRecord {
|
|
||||||
StrokeRecord {
|
|
||||||
brush_settings: BrushSettings::default_round_hard(),
|
|
||||||
color,
|
|
||||||
blend_mode: RasterBlendMode::Normal,
|
|
||||||
points: vec![
|
|
||||||
StrokePoint { x: 10.0, y: 10.0, pressure: 0.8, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 },
|
|
||||||
StrokePoint { x: 50.0, y: 10.0, pressure: 0.8, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.1 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_stroke_modifies_buffer() {
|
|
||||||
let mut img = RgbaImage::new(100, 100);
|
|
||||||
let stroke = make_stroke([1.0, 0.0, 0.0, 1.0]); // red
|
|
||||||
BrushEngine::apply_stroke(&mut img, &stroke);
|
|
||||||
// The center pixel should have some red
|
|
||||||
let px = img.get_pixel(30, 10);
|
|
||||||
assert!(px[0] > 0, "expected red paint");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_erase_reduces_alpha() {
|
|
||||||
let mut img = RgbaImage::from_pixel(100, 100, image::Rgba([200, 100, 50, 255]));
|
|
||||||
let stroke = StrokeRecord {
|
|
||||||
brush_settings: BrushSettings::default_round_hard(),
|
|
||||||
color: [0.0, 0.0, 0.0, 1.0],
|
|
||||||
blend_mode: RasterBlendMode::Erase,
|
|
||||||
points: vec![
|
|
||||||
StrokePoint { x: 50.0, y: 50.0, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
BrushEngine::apply_stroke(&mut img, &stroke);
|
|
||||||
let px = img.get_pixel(50, 50);
|
|
||||||
assert!(px[3] < 255, "alpha should be reduced by erase");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_png_roundtrip() {
|
fn test_png_roundtrip() {
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ impl CanvasPair {
|
||||||
/// in `raw_pixels` / PNG files). The values are decoded to linear premultiplied
|
/// in `raw_pixels` / PNG files). The values are decoded to linear premultiplied
|
||||||
/// before being written to the canvas, which operates entirely in linear space.
|
/// before being written to the canvas, which operates entirely in linear space.
|
||||||
pub fn upload(&self, queue: &wgpu::Queue, pixels: &[u8]) {
|
pub fn upload(&self, queue: &wgpu::Queue, pixels: &[u8]) {
|
||||||
eprintln!("[CANVAS] upload: {}x{} pixels={}", self.width, self.height, pixels.len());
|
|
||||||
// Decode sRGB-premultiplied → linear premultiplied for the GPU canvas.
|
// Decode sRGB-premultiplied → linear premultiplied for the GPU canvas.
|
||||||
let linear: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| {
|
let linear: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| {
|
||||||
let r = (srgb_to_linear(p[0] as f32 / 255.0) * 255.0 + 0.5) as u8;
|
let r = (srgb_to_linear(p[0] as f32 / 255.0) * 255.0 + 0.5) as u8;
|
||||||
|
|
@ -269,10 +268,8 @@ impl GpuBrushEngine {
|
||||||
let needs_new = self.canvases.get(&keyframe_id)
|
let needs_new = self.canvases.get(&keyframe_id)
|
||||||
.map_or(true, |c| c.width != width || c.height != height);
|
.map_or(true, |c| c.width != width || c.height != height);
|
||||||
if needs_new {
|
if needs_new {
|
||||||
eprintln!("[CANVAS] ensure_canvas: creating new CanvasPair for kf={:?} {}x{}", keyframe_id, width, height);
|
|
||||||
self.canvases.insert(keyframe_id, CanvasPair::new(device, width, height));
|
self.canvases.insert(keyframe_id, CanvasPair::new(device, width, height));
|
||||||
} else {
|
} else {
|
||||||
eprintln!("[CANVAS] ensure_canvas: reusing existing CanvasPair for kf={:?}", keyframe_id);
|
|
||||||
}
|
}
|
||||||
self.canvases.get_mut(&keyframe_id).unwrap()
|
self.canvases.get_mut(&keyframe_id).unwrap()
|
||||||
}
|
}
|
||||||
|
|
@ -306,7 +303,6 @@ impl GpuBrushEngine {
|
||||||
depth_or_array_layers: 1,
|
depth_or_array_layers: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
eprintln!("[DAB] render_dabs keyframe={:?} count={}", keyframe_id, dabs.len());
|
|
||||||
for dab in dabs {
|
for dab in dabs {
|
||||||
// Per-dab bounding box
|
// Per-dab bounding box
|
||||||
let r_fringe = dab.radius + 1.0;
|
let r_fringe = dab.radius + 1.0;
|
||||||
|
|
@ -415,9 +411,7 @@ impl GpuBrushEngine {
|
||||||
queue.submit(Some(compute_enc.finish()));
|
queue.submit(Some(compute_enc.finish()));
|
||||||
|
|
||||||
// Swap: the just-written dst becomes src for the next dab.
|
// Swap: the just-written dst becomes src for the next dab.
|
||||||
eprintln!("[DAB] dispatched bbox=({},{},{},{}) current_before_swap={}", x0, y0, x1, y1, canvas.current);
|
|
||||||
canvas.swap();
|
canvas.swap();
|
||||||
eprintln!("[DAB] after swap: current={}", canvas.current);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,7 @@ impl FocusIconCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_or_load(&mut self, icon: FocusIcon, icon_color: egui::Color32, ctx: &egui::Context) -> Option<&egui::TextureHandle> {
|
fn get_or_load(&mut self, icon: FocusIcon, icon_color: egui::Color32, display_size: f32, ctx: &egui::Context) -> Option<&egui::TextureHandle> {
|
||||||
if !self.icons.contains_key(&icon) {
|
if !self.icons.contains_key(&icon) {
|
||||||
let (svg_bytes, svg_filename) = match icon {
|
let (svg_bytes, svg_filename) = match icon {
|
||||||
FocusIcon::Animation => (focus_icons::ANIMATION, "focus-animation.svg"),
|
FocusIcon::Animation => (focus_icons::ANIMATION, "focus-animation.svg"),
|
||||||
|
|
@ -435,7 +435,8 @@ impl FocusIconCache {
|
||||||
);
|
);
|
||||||
let svg_with_color = svg_data.replace("currentColor", &color_hex);
|
let svg_with_color = svg_data.replace("currentColor", &color_hex);
|
||||||
|
|
||||||
if let Some(texture) = rasterize_svg(svg_with_color.as_bytes(), svg_filename, 120, ctx) {
|
let render_size = (display_size * ctx.pixels_per_point()).ceil() as u32;
|
||||||
|
if let Some(texture) = rasterize_svg(svg_with_color.as_bytes(), svg_filename, render_size, ctx) {
|
||||||
self.icons.insert(icon, texture);
|
self.icons.insert(icon, texture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1310,12 +1311,13 @@ impl EditorApp {
|
||||||
|
|
||||||
// Icon area - render SVG texture
|
// Icon area - render SVG texture
|
||||||
let icon_color = egui::Color32::from_gray(200);
|
let icon_color = egui::Color32::from_gray(200);
|
||||||
let icon_center = rect.center_top() + egui::vec2(0.0, 50.0);
|
let title_area_height = 40.0;
|
||||||
let icon_display_size = 60.0;
|
let icon_display_size = rect.width() - 16.0;
|
||||||
|
let icon_center = egui::pos2(rect.center().x, rect.min.y + (rect.height() - title_area_height) * 0.5);
|
||||||
|
|
||||||
// Get or load the SVG icon texture
|
// Get or load the SVG icon texture
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
if let Some(texture) = self.focus_icon_cache.get_or_load(icon, icon_color, &ctx) {
|
if let Some(texture) = self.focus_icon_cache.get_or_load(icon, icon_color, icon_display_size, &ctx) {
|
||||||
let texture_size = texture.size_vec2();
|
let texture_size = texture.size_vec2();
|
||||||
let scale = icon_display_size / texture_size.x.max(texture_size.y);
|
let scale = icon_display_size / texture_size.x.max(texture_size.y);
|
||||||
let scaled_size = texture_size * scale;
|
let scaled_size = texture_size * scale;
|
||||||
|
|
|
||||||
|
|
@ -79,15 +79,19 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
|
||||||
let rr = (dx * dx + dy * dy) / (dab.radius * dab.radius);
|
let rr = (dx * dx + dy * dy) / (dab.radius * dab.radius);
|
||||||
if rr > 1.0 { return current; }
|
if rr > 1.0 { return current; }
|
||||||
|
|
||||||
// Two-segment linear falloff (identical to libmypaint calculate_opa)
|
// Quadratic falloff: flat inner core, smooth quadratic outer zone.
|
||||||
let h = clamp(dab.hardness, 0.001, 1.0);
|
// r is the actual normalised distance [0,1]; h controls the hard-core radius.
|
||||||
|
// Inner zone (r ≤ h): fully opaque.
|
||||||
|
// Outer zone (r > h): opa = ((1-r)/(1-h))^2, giving a smooth bell-shaped dab.
|
||||||
|
let h = clamp(dab.hardness, 0.0, 1.0);
|
||||||
|
let r = sqrt(rr);
|
||||||
var opa_weight: f32;
|
var opa_weight: f32;
|
||||||
if rr <= h {
|
if h >= 1.0 || r <= h {
|
||||||
opa_weight = 1.0 + rr * (-(1.0 / h - 1.0));
|
opa_weight = 1.0;
|
||||||
} else {
|
} else {
|
||||||
opa_weight = h / (1.0 - h) + rr * (-h / (1.0 - h));
|
let t = (1.0 - r) / (1.0 - h);
|
||||||
|
opa_weight = t * t;
|
||||||
}
|
}
|
||||||
opa_weight = clamp(opa_weight, 0.0, 1.0);
|
|
||||||
|
|
||||||
if dab.blend_mode == 0u {
|
if dab.blend_mode == 0u {
|
||||||
// Normal: "over" operator
|
// Normal: "over" operator
|
||||||
|
|
|
||||||
|
|
@ -508,7 +508,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
|
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
|
||||||
if let Ok(mut gpu_brush) = shared.gpu_brush.lock() {
|
if let Ok(mut gpu_brush) = shared.gpu_brush.lock() {
|
||||||
if !gpu_brush.canvases.contains_key(&float_sel.canvas_id) {
|
if !gpu_brush.canvases.contains_key(&float_sel.canvas_id) {
|
||||||
eprintln!("[CANVAS] lazy-init float canvas id={:?}", float_sel.canvas_id);
|
|
||||||
gpu_brush.ensure_canvas(device, float_sel.canvas_id, float_sel.width, float_sel.height);
|
gpu_brush.ensure_canvas(device, float_sel.canvas_id, float_sel.width, float_sel.height);
|
||||||
if let Some(canvas) = gpu_brush.canvases.get(&float_sel.canvas_id) {
|
if let Some(canvas) = gpu_brush.canvases.get(&float_sel.canvas_id) {
|
||||||
let pixels = if float_sel.pixels.is_empty() {
|
let pixels = if float_sel.pixels.is_empty() {
|
||||||
|
|
@ -537,7 +536,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
);
|
);
|
||||||
// On stroke start, upload the pre-stroke pixel data to both textures
|
// On stroke start, upload the pre-stroke pixel data to both textures
|
||||||
if let Some(ref pixels) = pending.initial_pixels {
|
if let Some(ref pixels) = pending.initial_pixels {
|
||||||
eprintln!("[STROKE] uploading initial_pixels for kf={:?} painting_float={}", pending.keyframe_id, self.ctx.painting_float);
|
|
||||||
if let Some(canvas) = gpu_brush.canvases.get(&pending.keyframe_id) {
|
if let Some(canvas) = gpu_brush.canvases.get(&pending.keyframe_id) {
|
||||||
canvas.upload(queue, pixels);
|
canvas.upload(queue, pixels);
|
||||||
}
|
}
|
||||||
|
|
@ -594,14 +592,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
);
|
);
|
||||||
drop(image_cache);
|
drop(image_cache);
|
||||||
|
|
||||||
// Debug frame counter (only active during strokes)
|
|
||||||
static FRAME: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
||||||
let dbg_frame = FRAME.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let dbg_stroke = self.ctx.painting_canvas.is_some();
|
|
||||||
if dbg_stroke {
|
|
||||||
eprintln!("[FRAME {}] painting_canvas={:?} painting_float={}", dbg_frame, self.ctx.painting_canvas, self.ctx.painting_float);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get buffer pool for layer rendering
|
// Get buffer pool for layer rendering
|
||||||
let mut buffer_pool = shared.buffer_pool.lock().unwrap();
|
let mut buffer_pool = shared.buffer_pool.lock().unwrap();
|
||||||
|
|
||||||
|
|
@ -641,7 +631,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
antialiasing_method: vello::AaConfig::Msaa16,
|
antialiasing_method: vello::AaConfig::Msaa16,
|
||||||
};
|
};
|
||||||
|
|
||||||
if dbg_stroke { eprintln!("[DRAW] background Vello render"); }
|
|
||||||
if let Ok(mut renderer) = shared.renderer.lock() {
|
if let Ok(mut renderer) = shared.renderer.lock() {
|
||||||
renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &bg_render_params).ok();
|
renderer.render_to_texture(device, queue, &composite_result.background, bg_srgb_view, &bg_render_params).ok();
|
||||||
}
|
}
|
||||||
|
|
@ -661,7 +650,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
// Clear to dark gray (stage background outside document bounds)
|
// Clear to dark gray (stage background outside document bounds)
|
||||||
// Note: stage_bg values are already in linear space for HDR compositing
|
// Note: stage_bg values are already in linear space for HDR compositing
|
||||||
let stage_bg = [45.0 / 255.0, 45.0 / 255.0, 48.0 / 255.0, 1.0];
|
let stage_bg = [45.0 / 255.0, 45.0 / 255.0, 48.0 / 255.0, 1.0];
|
||||||
if dbg_stroke { eprintln!("[COMPOSITE] background onto HDR"); }
|
|
||||||
shared.compositor.composite(
|
shared.compositor.composite(
|
||||||
device,
|
device,
|
||||||
queue,
|
queue,
|
||||||
|
|
@ -776,7 +764,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
let mut used = false;
|
let mut used = false;
|
||||||
if let Ok(gpu_brush) = shared.gpu_brush.lock() {
|
if let Ok(gpu_brush) = shared.gpu_brush.lock() {
|
||||||
if let Some(canvas) = gpu_brush.canvases.get(&kf_id) {
|
if let Some(canvas) = gpu_brush.canvases.get(&kf_id) {
|
||||||
if dbg_stroke { eprintln!("[DRAW] GPU canvas blit layer={:?} kf={:?} canvas.current={}", rendered_layer.layer_id, kf_id, canvas.current); }
|
|
||||||
let camera = crate::gpu_brush::CameraParams {
|
let camera = crate::gpu_brush::CameraParams {
|
||||||
pan_x: self.ctx.pan_offset.x,
|
pan_x: self.ctx.pan_offset.x,
|
||||||
pan_y: self.ctx.pan_offset.y,
|
pan_y: self.ctx.pan_offset.y,
|
||||||
|
|
@ -804,7 +791,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
|
|
||||||
if !used_gpu_canvas {
|
if !used_gpu_canvas {
|
||||||
// Render layer scene to sRGB buffer, then convert to HDR
|
// Render layer scene to sRGB buffer, then convert to HDR
|
||||||
if dbg_stroke { eprintln!("[DRAW] Vello render layer={:?} opacity={}", rendered_layer.layer_id, rendered_layer.opacity); }
|
|
||||||
if let Ok(mut renderer) = shared.renderer.lock() {
|
if let Ok(mut renderer) = shared.renderer.lock() {
|
||||||
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params).ok();
|
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params).ok();
|
||||||
}
|
}
|
||||||
|
|
@ -822,7 +808,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
rendered_layer.blend_mode,
|
rendered_layer.blend_mode,
|
||||||
);
|
);
|
||||||
|
|
||||||
if dbg_stroke { eprintln!("[COMPOSITE] layer={:?} opacity={} blend={:?} used_gpu_canvas={}", rendered_layer.layer_id, rendered_layer.opacity, rendered_layer.blend_mode, used_gpu_canvas); }
|
|
||||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
label: Some("layer_composite_encoder"),
|
label: Some("layer_composite_encoder"),
|
||||||
});
|
});
|
||||||
|
|
@ -1017,7 +1002,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
|
|
||||||
// Blit the float GPU canvas on top of all composited layers.
|
// Blit the float GPU canvas on top of all composited layers.
|
||||||
// The float_mask_view clips to the selection shape (None = full float visible).
|
// The float_mask_view clips to the selection shape (None = full float visible).
|
||||||
if dbg_stroke { eprintln!("[FRAME {}] float blit section: raster_floating={}", dbg_frame, self.ctx.selection.raster_floating.is_some()); }
|
|
||||||
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
|
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
|
||||||
let float_canvas_id = float_sel.canvas_id;
|
let float_canvas_id = float_sel.canvas_id;
|
||||||
let float_x = float_sel.x;
|
let float_x = float_sel.x;
|
||||||
|
|
@ -1026,7 +1010,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
let float_h = float_sel.height;
|
let float_h = float_sel.height;
|
||||||
if let Ok(gpu_brush) = shared.gpu_brush.lock() {
|
if let Ok(gpu_brush) = shared.gpu_brush.lock() {
|
||||||
if let Some(canvas) = gpu_brush.canvases.get(&float_canvas_id) {
|
if let Some(canvas) = gpu_brush.canvases.get(&float_canvas_id) {
|
||||||
if dbg_stroke { eprintln!("[DRAW] float canvas blit canvas_id={:?} canvas.current={}", float_canvas_id, canvas.current); }
|
|
||||||
let float_hdr_handle = buffer_pool.acquire(device, hdr_spec);
|
let float_hdr_handle = buffer_pool.acquire(device, hdr_spec);
|
||||||
if let (Some(fhdr_view), Some(hdr_view)) = (
|
if let (Some(fhdr_view), Some(hdr_view)) = (
|
||||||
buffer_pool.get_view(float_hdr_handle),
|
buffer_pool.get_view(float_hdr_handle),
|
||||||
|
|
@ -1051,7 +1034,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
float_mask_view.as_ref(),
|
float_mask_view.as_ref(),
|
||||||
);
|
);
|
||||||
let float_layer = lightningbeam_core::gpu::CompositorLayer::normal(float_hdr_handle, 1.0);
|
let float_layer = lightningbeam_core::gpu::CompositorLayer::normal(float_hdr_handle, 1.0);
|
||||||
if dbg_stroke { eprintln!("[COMPOSITE] float canvas onto HDR"); }
|
|
||||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
label: Some("float_canvas_composite"),
|
label: Some("float_canvas_composite"),
|
||||||
});
|
});
|
||||||
|
|
@ -2189,7 +2171,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
antialiasing_method: vello::AaConfig::Msaa16,
|
antialiasing_method: vello::AaConfig::Msaa16,
|
||||||
};
|
};
|
||||||
|
|
||||||
if self.ctx.painting_canvas.is_some() { eprintln!("[DRAW] overlay Vello render"); }
|
|
||||||
if let Ok(mut renderer) = shared.renderer.lock() {
|
if let Ok(mut renderer) = shared.renderer.lock() {
|
||||||
renderer.render_to_texture(device, queue, &scene, overlay_srgb_view, &overlay_params).ok();
|
renderer.render_to_texture(device, queue, &scene, overlay_srgb_view, &overlay_params).ok();
|
||||||
}
|
}
|
||||||
|
|
@ -2203,7 +2184,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
|
|
||||||
// Composite overlay onto HDR texture
|
// Composite overlay onto HDR texture
|
||||||
let overlay_layer = lightningbeam_core::gpu::CompositorLayer::normal(overlay_hdr_handle, 1.0);
|
let overlay_layer = lightningbeam_core::gpu::CompositorLayer::normal(overlay_hdr_handle, 1.0);
|
||||||
if self.ctx.painting_canvas.is_some() { eprintln!("[COMPOSITE] overlay onto HDR"); }
|
|
||||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
label: Some("overlay_composite_encoder"),
|
label: Some("overlay_composite_encoder"),
|
||||||
});
|
});
|
||||||
|
|
@ -4758,7 +4738,11 @@ impl StagePane {
|
||||||
[1.0f32, 1.0, 1.0, 1.0]
|
[1.0f32, 1.0, 1.0, 1.0]
|
||||||
} else {
|
} else {
|
||||||
let c = if *shared.brush_use_fg { *shared.stroke_color } else { *shared.fill_color };
|
let c = if *shared.brush_use_fg { *shared.stroke_color } else { *shared.fill_color };
|
||||||
[c.r() as f32 / 255.0, c.g() as f32 / 255.0, c.b() as f32 / 255.0, c.a() as f32 / 255.0]
|
let s2l = |v: u8| -> f32 {
|
||||||
|
let f = v as f32 / 255.0;
|
||||||
|
if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) }
|
||||||
|
};
|
||||||
|
[s2l(c.r()), s2l(c.g()), s2l(c.b()), c.a() as f32 / 255.0]
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue