diff --git a/crates/engine/examples/bench_big_brush.rs b/crates/engine/examples/bench_big_brush.rs new file mode 100644 index 0000000000..c296eee74a --- /dev/null +++ b/crates/engine/examples/bench_big_brush.rs @@ -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 { + 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> { + let args: Vec = std::env::args().collect(); + let frames: usize = arg(&args, "--frames").and_then(|v| v.parse().ok()).unwrap_or(90); + let sizes: Vec = 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 = (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(()) +} diff --git a/crates/engine/src/cmd/brush_cmds.rs b/crates/engine/src/cmd/brush_cmds.rs index f501b27c40..241654247c 100644 --- a/crates/engine/src/cmd/brush_cmds.rs +++ b/crates/engine/src/cmd/brush_cmds.rs @@ -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) }) } diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index f384d527bb..72976b9124 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -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 = (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 diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 24f7259a76..cfec4f1116 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -90,6 +90,13 @@ fn map_items(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 = 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 { diff --git a/crates/paint/src/tile.rs b/crates/paint/src/tile.rs index 8ea0e776b6..b63d24e361 100644 --- a/crates/paint/src/tile.rs +++ b/crates/paint/src/tile.rs @@ -225,6 +225,19 @@ pub fn b64_decode(s: &str) -> Option> { // ---------- 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] } }