mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
perf(paint): big imported brushes draw about twice as fast while painting
A large sampled-tip brush (an imported .abr with a Dual Brush, at 800 px on a 4K canvas) spent most of each live frame on pixels that could never be painted: - A sampled dab was rasterised over the square that fits the tip at any angle. It now uses the rotated tip's own box, and in each row only the run of pixels whose tip coordinates can land on the tip (solved from the affine pixel-to-tip map, with slack; every pixel in the run still takes the exact test). - The live preview composited the smoothing tail's tiles twice and restored the previous tail over tiles that get rewritten anyway. `composite_live` does each tile once. - The composite looked up the Dual Brush tile per pixel; it now does it per tile. - Tip, texture and pattern sampling use a truncating floor (the baseline x86-64 build makes `f32::floor` a libm call) and an interior fast path without edge checks, with the same arithmetic. Pixels are unchanged: new tests compare the tight box and row runs with a full scan at many angles, flips, roundness and projection, and the live preview with the finished stroke after every chunk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8580ff0ca
commit
2d74ffb004
5 files changed
+323
-20
No files matched your search
@@ -0,0 +1,101 @@
|
||||
//! Live painting with a big brush on a 4K document: the time each frame of a live stroke spends
|
||||
//! in `LiveStroke::push` (the engine's half of a frame; the canvas refresh comes after it).
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-engine --example bench_big_brush -- [brush.json] [--frames N] [--sizes 300,800,1500]
|
||||
//! ```
|
||||
//!
|
||||
//! `brush.json` is a brush as Preferences saves it (`lastBrush.brush`, without its `tip`s). Without
|
||||
//! it the brush is a typical imported painting brush: a sampled tip at 5 % spacing with a Dual
|
||||
//! Brush twice its size, pressure size, opacity and flow, and 17 % smoothing. Then a soft round.
|
||||
//! Sampled tips are stand-ins of the imported brush's sizes.
|
||||
use photocraft_engine::{Session, brush_cmds::LiveStroke};
|
||||
use photocraft_paint::{StrokePoint, brush::BrushSettings, brush::TipShape, tile::GrayTile};
|
||||
use serde_json::json;
|
||||
use std::time::Instant;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
/// A rough, grainy blob like a scanned bristle tip.
|
||||
fn tip(w: u32, h: u32, salt: u32) -> GrayTile {
|
||||
GrayTile::from_fn(w, h, |x, y| {
|
||||
let (dx, dy) = (x as f32 / w as f32 - 0.5, y as f32 / h as f32 - 0.5);
|
||||
let r = (dx * dx + dy * dy).sqrt() * 2.0;
|
||||
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663) ^ salt) % 1000) as f32 / 1000.0;
|
||||
((1.0 - r).max(0.0) * (0.4 + 0.6 * n)).min(1.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn pct(v: &mut [f64], p: f64) -> f64 {
|
||||
v.sort_by(f64::total_cmp);
|
||||
v[((v.len() - 1) as f64 * p).round() as usize]
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let frames: usize = arg(&args, "--frames").and_then(|v| v.parse().ok()).unwrap_or(90);
|
||||
let sizes: Vec<f64> = arg(&args, "--sizes").unwrap_or("300,800,1500".into()).split(',').filter_map(|v| v.parse().ok()).collect();
|
||||
let file = args.get(1).filter(|a| !a.starts_with("--"));
|
||||
let pressure = json!({"control": "penPressure"});
|
||||
let mut base: BrushSettings = match file {
|
||||
Some(f) => serde_json::from_str(&std::fs::read_to_string(f)?)?,
|
||||
None => serde_json::from_value(json!({
|
||||
"size": 143, "spacing": 0.05, "hardness": 1.0,
|
||||
"smoothing": {"enabled": true, "amount": 0.17, "catchUp": true, "catchUpOnEnd": true, "adjustForZoom": true},
|
||||
"dualBrush": {"enabled": true, "mode": "overlay", "size": 288, "spacing": 0.1, "roundness": 0.72, "angle": 180, "scatter": 0.47},
|
||||
"shapeDynamics": {"enabled": true, "size": {"control": "penPressure", "minimum": 0.69}, "angle": {"control": "initialDirection"}},
|
||||
"transfer": {"enabled": true, "opacity": pressure, "flow": pressure},
|
||||
}))?,
|
||||
};
|
||||
base.tip = TipShape::Sampled(tip(70, 73, 1));
|
||||
base.dual_brush.tip = TipShape::Sampled(tip(100, 97, 2));
|
||||
let mut no_dual = base.clone();
|
||||
no_dual.dual_brush.enabled = false;
|
||||
let mut no_smooth = base.clone();
|
||||
no_smooth.smoothing.enabled = false;
|
||||
let mut neither = no_dual.clone();
|
||||
neither.smoothing.enabled = false;
|
||||
let round = BrushSettings { hardness: 0.0, ..BrushSettings::default() };
|
||||
let variants = [("as saved", base), ("no dual", no_dual), ("no smoothing", no_smooth), ("no dual, no smoothing", neither), ("soft round", round)];
|
||||
let (w, h) = (3840.0, 2160.0);
|
||||
for size in &sizes {
|
||||
for (name, brush) in &variants {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h, "background": "white"}))?;
|
||||
s.execute("layer.new.layer", json!({"name": "paint"}))?;
|
||||
s.tools.brush = brush.clone();
|
||||
// A 4K canvas fit to a 4K screen with panels: about 45 %.
|
||||
let zoom = 0.45;
|
||||
// Each frame brings four pen samples, 12 px apart (a brisk stroke at 120 Hz).
|
||||
let at = |k: usize| {
|
||||
let t = k as f64 / (frames * 4) as f64;
|
||||
let p = StrokePoint::new(w * (0.15 + 0.7 * t), h * (0.5 + 0.25 * (t * 9.0).sin()), (0.4 + 0.6 * (t * 5.0).sin().abs()) as f32);
|
||||
StrokePoint { time: k as f64 * 2.0, ..p }
|
||||
};
|
||||
let first = at(0);
|
||||
let p = json!({"points": [[first.x, first.y, first.pressure]], "size": size, "zoom": zoom, "seed": 7});
|
||||
let t = Instant::now();
|
||||
let mut live = LiveStroke::begin(&s, &p)?;
|
||||
let begin = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut ms = Vec::with_capacity(frames);
|
||||
for f in 0..frames {
|
||||
let pts: Vec<StrokePoint> = (1..=4).map(|i| at(f * 4 + i)).collect();
|
||||
let t = Instant::now();
|
||||
let dmg = live.push(&pts)?;
|
||||
std::hint::black_box(dmg);
|
||||
ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let total: f64 = ms.iter().sum();
|
||||
println!(
|
||||
"{size:>5} px {name:<22} begin {begin:6.1} ms frame p50 {:6.1} p95 {:6.1} max {:6.1} ms ({:.0} ms over {frames} frames)",
|
||||
pct(&mut ms.clone(), 0.5),
|
||||
pct(&mut ms.clone(), 0.95),
|
||||
pct(&mut ms, 1.0),
|
||||
total
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -708,19 +708,9 @@ impl LiveStroke {
|
||||
self.union_live = Some((live, passes));
|
||||
return Ok(dmg);
|
||||
}
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||||
if !old.is_empty() {
|
||||
// Back to the stroke without the previous tail.
|
||||
surf.write_region(old, &self.pre.read_region(old));
|
||||
self.renderer.mark_dirty(old);
|
||||
dmg = old;
|
||||
}
|
||||
dmg = dmg.union(&self.renderer.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false));
|
||||
if let Some(mut tail) = self.renderer.tail_preview() {
|
||||
self.tail = tail.composite(&self.pre, surf, self.sel.as_ref(), self.lock, false);
|
||||
dmg = dmg.union(&self.tail);
|
||||
}
|
||||
let (dmg, tail) = self.renderer.composite_live(old, &self.pre, surf, self.sel.as_ref(), self.lock);
|
||||
self.tail = tail;
|
||||
Ok(dmg)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1260,6 +1260,67 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
|
||||
assert!(r.tail_preview().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_dabs_are_the_same_in_their_tight_box_with_rows_cut_to_the_tip() {
|
||||
// A sampled dab is computed over the rotated tip's own box, and in each row only where the
|
||||
// tip can land; at any angle (just off a right angle too), flip, roundness and projection,
|
||||
// the pixels match scanning the whole square that fits any rotation, which is 0 elsewhere.
|
||||
let tip = GrayTile::from_fn(37, 23, |x, y| 0.05 + ((x * 7 + y * 13) % 11) as f32 / 11.0);
|
||||
let dual = DualBrush { enabled: true, tip: TipShape::Sampled(GrayTile::from_fn(15, 40, |x, _| 0.1 + (x % 3) as f32 / 3.0)), ..Default::default() };
|
||||
let b = BrushSettings { tip: TipShape::Sampled(tip), dual_brush: dual, ..brush() };
|
||||
let ctx = BrushContext::new(&b);
|
||||
let half_pi = std::f32::consts::FRAC_PI_2;
|
||||
for angle in [0.0, 0.3, -1.1, half_pi, half_pi + 1e-6, half_pi - 3e-7, 3.0, std::f32::consts::PI] {
|
||||
for (roundness, flip, proj) in [(1.0, false, 1.0), (0.35, true, 1.0), (0.8, false, 0.4)] {
|
||||
let d = Dab { angle, roundness, flip_x: flip, proj_scale: proj, proj_angle: 0.7, ..Dab::round(Point::new(60.3, 41.7), 40.0, 0.9) };
|
||||
for dual in [false, true] {
|
||||
let what = format!("angle {angle}, roundness {roundness}, flip {flip}, projection {proj}, dual {dual}");
|
||||
let r = ctx.dab_rect(&d, dual);
|
||||
let square = crate::render::rect_around(&d, d.radius * std::f32::consts::SQRT_2);
|
||||
let (mut cut, mut full) = (Vec::new(), Vec::new());
|
||||
ctx.rasterize(&d, dual, r, &mut cut);
|
||||
crate::render::FULL_SCAN.with(|f| f.set(true));
|
||||
ctx.rasterize(&d, dual, square, &mut full);
|
||||
crate::render::FULL_SCAN.with(|f| f.set(false));
|
||||
assert!(full.iter().any(|&v| v > 0.0), "{what}");
|
||||
let (sw, rw) = (square.width() as i32, r.width() as i32);
|
||||
for y in square.y0..square.y1 {
|
||||
for x in square.x0..square.x1 {
|
||||
let v = full[((y - square.y0) * sw + (x - square.x0)) as usize];
|
||||
let got = if r.contains(x, y) { cut[((y - r.y0) * rw + (x - r.x0)) as usize] } else { 0.0 };
|
||||
assert!(v == got, "{what}: pixel ({x}, {y}) is {got}, scanning the square gives {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_live_shows_the_finished_stroke_after_every_chunk() {
|
||||
// A big smoothed Dual Brush stroke: its tail spans many coverage tiles and moves every step,
|
||||
// so the tiles it redraws, the ones it leaves and the ones the stroke covers all occur. The
|
||||
// zigzag's corners are cut by the smoothing, so earlier tails reach where the stroke never
|
||||
// paints and must be taken back.
|
||||
let dual = DualBrush { enabled: true, size: 90.0, spacing: 0.3, mode: MaskMode::Overlay, roundness: 0.7, ..Default::default() };
|
||||
let b = BrushSettings { size: 70.0, hardness: 0.3, spacing: 0.05, dual_brush: dual, smoothing: Smoothing { amount: 0.9, ..Default::default() }, ..brush() };
|
||||
let pts: Vec<StrokePoint> = (0..40).map(|i| StrokePoint::new(60.0 + i as f64 * 8.0, if i % 8 < 4 { 60.0 } else { 160.0 }, 1.0)).collect();
|
||||
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
let pre = Surface::with_default(fmt, &[0.2, 0.5, 0.9, 1.0]);
|
||||
let mut live = pre.clone();
|
||||
let mut r = StrokeRenderer::new(&b, Some(fmt), 1.0);
|
||||
let mut tail = Rect::EMPTY;
|
||||
for (n, chunk) in pts.chunks(3).enumerate() {
|
||||
r.push(chunk);
|
||||
tail = r.composite_live(tail, &pre, &mut live, None, false).1;
|
||||
let mut done = pre.clone();
|
||||
render_stroke(&mut done, &b, &pts[..(n * 3 + chunk.len())], None, false, 1.0);
|
||||
let same = (0..220).all(|y| (0..480).all(|x| live.rgba(x, y) == done.rgba(x, y)));
|
||||
assert!(same, "{fmt:?}: after chunk {n} the live preview differs from the finished stroke");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_union_of_passes_matches_merging_the_finished_passes_after_every_chunk() {
|
||||
// Symmetric painting previews the stroke and its mirrors with `composite_union_live`, which
|
||||
|
||||
+134
-4
@@ -90,6 +90,13 @@ fn map_items<T: Sync, R: Send>(items: &[T], pixels: usize, f: impl Fn(&T) -> R +
|
||||
let _ = pixels;
|
||||
items.iter().map(&f).collect()
|
||||
}
|
||||
// Tests: scan every pixel of a sampled dab's square on this thread, as before rows were cut to
|
||||
// the tip's run (`BrushContext::rasterize_into`).
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static FULL_SCAN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Computed tips with a smaller radius (in pixels) are rasterised by area, not at pixel centres.
|
||||
const SMALL_TIP_RADIUS: f32 = 3.0;
|
||||
/// Sub-pixel sample offsets (a 4×4 grid) for the area coverage of small tips.
|
||||
@@ -254,7 +261,7 @@ impl BrushContext {
|
||||
return p.data[y.rem_euclid(p.height as i32) as usize * p.width + x.rem_euclid(p.width as i32) as usize];
|
||||
}
|
||||
let (fx, fy) = ((x as f32 + 0.5) / s - 0.5, (y as f32 + 0.5) / s - 0.5);
|
||||
let (x0, y0) = (fx.floor(), fy.floor());
|
||||
let (x0, y0) = (crate::tile::fast_floor(fx), crate::tile::fast_floor(fy));
|
||||
let (tx, ty) = (fx - x0, fy - y0);
|
||||
let a = p.sample_wrap(x0, y0) + (p.sample_wrap(x0 + 1.0, y0) - p.sample_wrap(x0, y0)) * tx;
|
||||
let b = p.sample_wrap(x0, y0 + 1.0) + (p.sample_wrap(x0 + 1.0, y0 + 1.0) - p.sample_wrap(x0, y0 + 1.0)) * tx;
|
||||
@@ -274,7 +281,39 @@ impl BrushContext {
|
||||
} else {
|
||||
crate::tip_reach(d.radius, hardness).max(d.radius)
|
||||
};
|
||||
rect_around(d, reach)
|
||||
let square = rect_around(d, reach);
|
||||
let mips = if dual { self.dual_tip.as_deref() } else { self.tip.as_deref() };
|
||||
match mips.and_then(|m| self.sampled_extent(m, d, dual)) {
|
||||
// The square fits the tip at any angle; the rotated tip's own box is often far smaller
|
||||
// (a long or squashed tip, an angle near a right angle). Every pixel outside it
|
||||
// rasterises to 0, so it only trims the empty part of the square.
|
||||
Some((rx, ry)) => {
|
||||
let (rx, ry) = ((rx.ceil() as i32).saturating_add(2), (ry.ceil() as i32).saturating_add(2));
|
||||
let (cx, cy) = (d.center.x.floor() as i32, d.center.y.floor() as i32);
|
||||
Rect::new(cx.saturating_sub(rx), cy.saturating_sub(ry), cx.saturating_add(rx).saturating_add(1), cy.saturating_add(ry).saturating_add(1))
|
||||
.intersect(&square)
|
||||
}
|
||||
None => square,
|
||||
}
|
||||
}
|
||||
|
||||
/// Half the width and height of the pixels a sampled dab can paint: the tip's sampled area
|
||||
/// (its texels and one more each side, see `rasterize_into`) rotated and flipped as the dab is.
|
||||
/// `None` with Brush Projection or values that aren't finite (the dab keeps its square).
|
||||
fn sampled_extent(&self, m: &Mips, d: &Dab, dual: bool) -> Option<(f32, f32)> {
|
||||
if !dual && d.proj_scale < 0.999 {
|
||||
return None;
|
||||
}
|
||||
let (w0, h0, _) = &m.levels[0];
|
||||
let (tw, th) = (*w0 as f32, *h0 as f32);
|
||||
let r = d.radius;
|
||||
let ro = d.roundness.max(0.5 / r).min(1.0);
|
||||
let inv_scale = tw.max(th) / (2.0 * r);
|
||||
// |u| and |v| in the dab's pixels (`px`, `py` of `rasterize_into` within [-1, size + 1]).
|
||||
let (u, v) = ((tw / 2.0 + 1.0) / inv_scale, (th / 2.0 + 1.0) * ro / inv_scale);
|
||||
let (sn, cs) = d.angle.sin_cos();
|
||||
let (rx, ry) = (u * cs.abs() + v * sn.abs(), u * sn.abs() + v * cs.abs());
|
||||
(rx.is_finite() && ry.is_finite()).then_some((rx, ry))
|
||||
}
|
||||
|
||||
/// Rasterise a dab over `rect` into `out` (tip shape × noise × per-tip texture × wet edges × flow).
|
||||
@@ -355,10 +394,55 @@ impl BrushContext {
|
||||
rasterize_round(d, rect, out, hardness, aliased);
|
||||
return;
|
||||
}
|
||||
// A sampled tip covers only part of its dab's square (the square fits any rotation): the
|
||||
// pixels of a row whose tip coordinates can land on the tip. Pixel to tip is affine, so
|
||||
// they are one run, solved from two points of the row. The run is solved for a tip grown
|
||||
// by `SLACK` texels and two more pixels each side (rounding, at any angle), and every
|
||||
// pixel in it still takes the exact per-pixel test below.
|
||||
const SLACK: f64 = 0.05;
|
||||
let sampled_run = |dy: f32| -> (usize, usize) {
|
||||
#[cfg(test)]
|
||||
if FULL_SCAN.with(std::cell::Cell::get) {
|
||||
return (0, w);
|
||||
}
|
||||
if mips.is_none() || (aliased && b.square) {
|
||||
return (0, w);
|
||||
}
|
||||
let at = |dx: f32| {
|
||||
let (u, v) = to_tip(dx, dy);
|
||||
(f64::from(u * inv_scale + tw / 2.0), f64::from(-(v / ro) * inv_scale + th / 2.0))
|
||||
};
|
||||
let ((p0, q0), (p1, q1)) = (at(0.0), at(1.0));
|
||||
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
|
||||
let (min, maxs) = (-1.0 - SLACK, [f64::from(tw) + 1.0 + SLACK, f64::from(th) + 1.0 + SLACK]);
|
||||
for ((a, slope), max) in [(p0, p1 - p0), (q0, q1 - q0)].into_iter().zip(maxs) {
|
||||
if !(a.is_finite() && slope.is_finite()) {
|
||||
return (0, w);
|
||||
}
|
||||
if slope == 0.0 {
|
||||
if a < min || a > max {
|
||||
return (0, 0);
|
||||
}
|
||||
} else {
|
||||
let (t0, t1) = ((min - a) / slope, (max - a) / slope);
|
||||
lo = lo.max(t0.min(t1));
|
||||
hi = hi.min(t0.max(t1));
|
||||
}
|
||||
}
|
||||
if lo > hi {
|
||||
return (0, 0);
|
||||
}
|
||||
// dx = x + 0.5 − cx with x = rect.x0 + xx.
|
||||
let off = f64::from(cx) - 0.5 - f64::from(rect.x0);
|
||||
let first = (lo + off).floor() - 2.0;
|
||||
let end = (hi + off).ceil() + 3.0;
|
||||
(first.clamp(0.0, w as f64) as usize, end.clamp(0.0, w as f64) as usize)
|
||||
};
|
||||
for yy in 0..h {
|
||||
let y = rect.y0 + yy as i32;
|
||||
let dy = y as f32 + 0.5 - cy;
|
||||
for xx in 0..w {
|
||||
let (first, end) = sampled_run(dy);
|
||||
for xx in first..end {
|
||||
let x = rect.x0 + xx as i32;
|
||||
let dx = x as f32 + 0.5 - cx;
|
||||
let (u, v) = to_tip(dx, dy);
|
||||
@@ -1000,6 +1084,49 @@ impl StrokeRenderer {
|
||||
Some(t)
|
||||
}
|
||||
|
||||
/// One step of a live preview that shows the stroke as finishing it now would (see
|
||||
/// [`tail_preview`](Self::tail_preview)): takes back the previous step's tail `old`, composites
|
||||
/// the tiles that changed, then the new tail. Returns the damage and the new tail's rectangle
|
||||
/// (the next step's `old`).
|
||||
///
|
||||
/// A tile the tail redraws is composited once, by the tail: it starts from `pre` and covers
|
||||
/// at least what the stroke's own composite would write there. And `old` is copied back from
|
||||
/// `pre` only where no tile composite rewrites it anyway. Big brushes' tails span hundreds
|
||||
/// of tiles, so doing both twice cost as much as the rest of the frame.
|
||||
pub fn composite_live(&mut self, old: Rect, pre: &Surface, target: &mut Surface, selection: Option<&Surface>, lock_transparency: bool) -> (Rect, Rect) {
|
||||
let mut tail = self.tail_preview();
|
||||
let redrawn_by_tail: HashSet<(i32, i32)> = tail.as_ref().map(|t| t.cov.tiles.keys().copied().collect()).unwrap_or_default();
|
||||
if !old.is_empty() {
|
||||
self.mark_dirty(old);
|
||||
let bounds = self.bounds();
|
||||
let tail_bounds = tail.as_ref().map_or(Rect::EMPTY, |t| t.bounds());
|
||||
let mut region = Vec::new();
|
||||
for ty in old.y0.div_euclid(COV_TILE)..=(old.y1 - 1).div_euclid(COV_TILE) {
|
||||
for tx in old.x0.div_euclid(COV_TILE)..=(old.x1 - 1).div_euclid(COV_TILE) {
|
||||
let tile = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE);
|
||||
let part = tile.intersect(&old);
|
||||
let written = if redrawn_by_tail.contains(&(tx, ty)) {
|
||||
tile.intersect(&tail_bounds)
|
||||
} else if self.cov.tiles.contains_key(&(tx, ty)) {
|
||||
tile.intersect(&bounds)
|
||||
} else {
|
||||
Rect::EMPTY
|
||||
};
|
||||
if part.is_empty() || written.contains_rect(&part) {
|
||||
continue;
|
||||
}
|
||||
pre.read_region_into(part, &mut region);
|
||||
target.write_region(part, ®ion);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.cov.dirty.retain(|k| !redrawn_by_tail.contains(k));
|
||||
let mut dmg = old.union(&self.composite(pre, target, selection, lock_transparency, false));
|
||||
let tail_rect = tail.as_mut().map_or(Rect::EMPTY, |t| t.composite(pre, target, selection, lock_transparency, false));
|
||||
dmg = dmg.union(&tail_rect);
|
||||
(dmg, tail_rect)
|
||||
}
|
||||
|
||||
/// Mark the coverage tiles over `r` for the next `composite` (e.g. to redraw over a preview).
|
||||
pub fn mark_dirty(&mut self, r: Rect) {
|
||||
if r.is_empty() {
|
||||
@@ -1140,6 +1267,9 @@ impl StrokeRenderer {
|
||||
}
|
||||
pre.read_region_into(tr, region);
|
||||
let sel = selection.map(|s| (s.channels(), s.read_region(tr)));
|
||||
// The Dual Brush's coverage lies on the same tile grid: one lookup per tile, not
|
||||
// one per pixel (`CoverageMap::get` reads 0 where it has no tile).
|
||||
let dual = self.dual.as_ref().map(|d| d.tiles.get(&(tx, ty)));
|
||||
// Per-dab colour rewrites the source per pixel, so each tile works on its own copy
|
||||
// that starts from the brush colour. (Before the tiles ran in parallel, one `src`
|
||||
// carried over from tile to tile; a tile without per-dab colour then painted with
|
||||
@@ -1157,7 +1287,7 @@ impl StrokeRenderer {
|
||||
continue;
|
||||
}
|
||||
let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize;
|
||||
let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y);
|
||||
let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), dual.map(|t| t.map_or(0.0, |t| t.cov[ti])), x, y);
|
||||
let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]);
|
||||
let mut k = (m * opacity * s).min(1.0);
|
||||
if k <= 0.0 {
|
||||
|
||||
@@ -225,6 +225,19 @@ pub fn b64_decode(s: &str) -> Option<Vec<u8>> {
|
||||
|
||||
// ---------- sampling ----------
|
||||
|
||||
/// `x.floor()` without the libm call the baseline x86-64 build makes for it (it has no rounding
|
||||
/// instruction), for the per-pixel tip samples: the same value for every `x` (NaN and values
|
||||
/// beyond `i32` take `floor` itself).
|
||||
#[inline(always)]
|
||||
pub(crate) fn fast_floor(x: f32) -> f32 {
|
||||
if x.abs() < 2_000_000_000.0 {
|
||||
let t = x as i32 as f32;
|
||||
if t > x { t - 1.0 } else { t }
|
||||
} else {
|
||||
x.floor()
|
||||
}
|
||||
}
|
||||
|
||||
/// A float mip pyramid of a [`GrayTile`] for sampling at any scale without aliasing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mips {
|
||||
@@ -273,14 +286,22 @@ impl Mips {
|
||||
}
|
||||
|
||||
/// Bilinear sample at normalised `u, v` in 0..1 (outside = 0).
|
||||
#[inline]
|
||||
#[inline(always)]
|
||||
pub fn sample_clamped(&self, level: usize, u: f32, v: f32) -> f32 {
|
||||
let (w, h, d) = &self.levels[level];
|
||||
let fx = u * *w as f32 - 0.5;
|
||||
let fy = v * *h as f32 - 0.5;
|
||||
let (x0, y0) = (fx.floor(), fy.floor());
|
||||
let (x0, y0) = (fast_floor(fx), fast_floor(fy));
|
||||
let (tx, ty) = (fx - x0, fy - y0);
|
||||
let (x0, y0) = (x0 as i64, y0 as i64);
|
||||
// Inside the tip (most of a big dab's samples): no edge checks, the same arithmetic.
|
||||
if x0 >= 0 && y0 >= 0 && x0 + 1 < *w as i64 && y0 + 1 < *h as i64 {
|
||||
let i = y0 as usize * w + x0 as usize;
|
||||
let (p00, p10, p01, p11) = (d[i], d[i + 1], d[i + w], d[i + w + 1]);
|
||||
let a = p00 + (p10 - p00) * tx;
|
||||
let b = p01 + (p11 - p01) * tx;
|
||||
return a + (b - a) * ty;
|
||||
}
|
||||
let get = |x: i64, y: i64| if x < 0 || y < 0 || x >= *w as i64 || y >= *h as i64 { 0.0 } else { d[y as usize * w + x as usize] };
|
||||
let a = get(x0, y0) + (get(x0 + 1, y0) - get(x0, y0)) * tx;
|
||||
let b = get(x0, y0 + 1) + (get(x0 + 1, y0 + 1) - get(x0, y0 + 1)) * tx;
|
||||
@@ -299,8 +320,8 @@ pub struct PatternImage {
|
||||
impl PatternImage {
|
||||
#[inline]
|
||||
pub fn sample_wrap(&self, x: f32, y: f32) -> f32 {
|
||||
let xi = (x.floor() as i64).rem_euclid(self.width as i64) as usize;
|
||||
let yi = (y.floor() as i64).rem_euclid(self.height as i64) as usize;
|
||||
let xi = (fast_floor(x) as i64).rem_euclid(self.width as i64) as usize;
|
||||
let yi = (fast_floor(y) as i64).rem_euclid(self.height as i64) as usize;
|
||||
self.data[yi * self.width + xi]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user