From ab07593adeef3c548116b3e7c167dae3a32bb6d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hansen Date: Thu, 7 May 2026 00:40:55 -0700 Subject: [PATCH] brush-paint: skip-when-not-tracing + precomputed disk offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two perf wins in the walker's per-step inner loop, both bit-exact against the alphabet report. 1. Skip lookahead breakdown for rejected candidates when not tracing. Walker's per-direction loop unconditionally called lookahead_score_breakdown even when rejected_back or rejected_off_ink was true — the score was only used to record would-be values in the debug viewer. When trace is None (every optimizer call), this was wasted work on ~30% of candidates. 2. Precompute the disk's pixel-offset list once per brush radius (set_brush populates Grid::disk_offsets) and reuse for evaluate_disk, paint_disk, and measure_sweep_full. Iteration shrinks from a (2·⌈r+1⌉+1)² bounding box to ~π·(r+0.5)² cells — roughly half for typical radii. The mask is a superset of any actual fractional disk (uses the closest-point-of-pixel-square criterion to ensure no in-disk pixel can ever be missed), so the inner-loop fractional distance check produces exactly the same accepted set as the original bounding-box scan. --- src/brush_paint.rs | 156 ++++++++++++++++++++++++++++----------------- 1 file changed, 97 insertions(+), 59 deletions(-) diff --git a/src/brush_paint.rs b/src/brush_paint.rs index d4d02489..dfe05cb2 100644 --- a/src/brush_paint.rs +++ b/src/brush_paint.rs @@ -286,13 +286,12 @@ fn sdf_percentile(dist: &std::collections::HashMap<(u32, u32), f32>, q: f32) -> /// baseline single-pass sweep has some repaint from adjacent-sample /// disk overlap (~4× per pixel at step_size_factor=0.5); higher /// values mean the path is doubling back over itself. -fn measure_sweep_full(strokes: &[Vec<(f32, f32)>], grid: &Grid, brush_radius: f32) +fn measure_sweep_full(strokes: &[Vec<(f32, f32)>], grid: &Grid) -> (u32, u32, u32) { if strokes.is_empty() { return (0, 0, 0); } let mut count = vec![0u32; grid.was_ink.len()]; - let r = (brush_radius + 1.0).ceil() as i32; - let r2 = brush_radius * brush_radius; + let r2 = grid.brush_radius_sq; for stroke in strokes { for win in stroke.windows(2) { let (a, b) = (win[0], win[1]); @@ -305,16 +304,14 @@ fn measure_sweep_full(strokes: &[Vec<(f32, f32)>], grid: &Grid, brush_radius: f3 let cy = a.1 + dy * t; let cx_i = cx.round() as i32; let cy_i = cy.round() as i32; - for ddy in -r..=r { - for ddx in -r..=r { - let dxr = (cx_i + ddx) as f32 - cx; - let dyr = (cy_i + ddy) as f32 - cy; - if dxr * dxr + dyr * dyr > r2 { continue; } - let lx = cx_i + ddx - grid.bx; - let ly = cy_i + ddy - grid.by; - if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; } - count[(ly * grid.width + lx) as usize] += 1; - } + for &(ddx, ddy) in &grid.disk_offsets { + let dxr = (cx_i + ddx) as f32 - cx; + let dyr = (cy_i + ddy) as f32 - cy; + if dxr * dxr + dyr * dyr > r2 { continue; } + let lx = cx_i + ddx - grid.bx; + let ly = cy_i + ddy - grid.by; + if lx < 0 || ly < 0 || lx >= grid.width || ly >= grid.height { continue; } + count[(ly * grid.width + lx) as usize] += 1; } } } @@ -460,6 +457,18 @@ struct Grid { ink_total: i32, /// Currently unpainted ink pixel count. ink_remaining: i32, + /// Brush radius used for disk operations. Set via `set_brush` + /// before any disk evaluation/painting; populates `disk_offsets`. + brush_radius: f32, + /// brush_radius² — precomputed for the inner disk-membership check. + brush_radius_sq: f32, + /// Pixel offsets (dx, dy) from a rounded disk center such that the + /// pixel might land within `brush_radius` of any sub-pixel point + /// that rounds to that center. The list is a small superset of any + /// actual disk; the per-pixel distance check inside the disk loops + /// then prunes to the exact fractional disk for waypoint p. Saves + /// ~50% of inner-loop iterations vs the bare bounding-box scan. + disk_offsets: Vec<(i32, i32)>, } impl Grid { @@ -514,7 +523,34 @@ impl Grid { let skeleton_length = skel.len() as u32; Self { bx, by, width, height, unpainted, was_ink, sdf, skel_endpoints, - skeleton_length, ink_total: count, ink_remaining: count } + skeleton_length, ink_total: count, ink_remaining: count, + brush_radius: 0.0, brush_radius_sq: 0.0, disk_offsets: Vec::new() } + } + + /// Configure the disk shape used for evaluate_disk / paint_disk / + /// measure_sweep_full. Must be called before any of those run. + /// Computed offsets are a superset of any actual fractional-disk + /// at this radius: a pixel (dx, dy) is included iff the closest + /// point of the [-0.5, 0.5)² square around it to the origin is + /// within `brush_radius`. The inner-loop fractional check then + /// prunes to the exact disk for waypoint p, so the result is + /// bit-exact w.r.t. iterating the full bounding box. + fn set_brush(&mut self, brush_radius: f32) { + self.brush_radius = brush_radius; + self.brush_radius_sq = brush_radius * brush_radius; + let r2 = brush_radius * brush_radius; + let mask_r = (brush_radius + 0.5).ceil() as i32; + let mut offsets: Vec<(i32, i32)> = Vec::with_capacity(((2 * mask_r + 1) * (2 * mask_r + 1)) as usize); + for dy in -mask_r..=mask_r { + for dx in -mask_r..=mask_r { + let nx = ((dx.abs() as f32) - 0.5).max(0.0); + let ny = ((dy.abs() as f32) - 0.5).max(0.0); + if nx * nx + ny * ny <= r2 { + offsets.push((dx, dy)); + } + } + } + self.disk_offsets = offsets; } /// Look up SDF at an integer pixel. @@ -604,34 +640,31 @@ impl Grid { /// bg: pixels that were never ink (heavy penalty — these /// become visible off-glyph paint on the actual plot) /// Does NOT mutate the grid. - fn evaluate_disk(&self, p: (f32, f32), radius: f32) -> (i32, i32, i32) { + fn evaluate_disk(&self, p: (f32, f32)) -> (i32, i32, i32) { let cx_i = p.0.round() as i32; let cy_i = p.1.round() as i32; - let r = (radius + 1.0).ceil() as i32; - let r2 = radius * radius; + let r2 = self.brush_radius_sq; let mut new_ink = 0; let mut repaint_ink = 0; let mut bg = 0; - for dy in -r..=r { - for dx in -r..=r { - // True distance from float waypoint to integer pixel - // center — keeps the brush's footprint shifting smoothly - // with sub-pixel waypoint motion (without this, small - // brushes paint the same pixels for any sub-pixel step). - let ddx = (cx_i + dx) as f32 - p.0; - let ddy = (cy_i + dy) as f32 - p.1; - if ddx * ddx + ddy * ddy > r2 { continue; } - let lx = cx_i + dx - self.bx; - let ly = cy_i + dy - self.by; - if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; } - let idx = (ly * self.width + lx) as usize; - if self.unpainted[idx] { - new_ink += 1; - } else if self.was_ink[idx] { - repaint_ink += 1; - } else { - bg += 1; - } + for &(dx, dy) in &self.disk_offsets { + // True distance from float waypoint to integer pixel + // center — keeps the brush's footprint shifting smoothly + // with sub-pixel waypoint motion (without this, small + // brushes paint the same pixels for any sub-pixel step). + let ddx = (cx_i + dx) as f32 - p.0; + let ddy = (cy_i + dy) as f32 - p.1; + if ddx * ddx + ddy * ddy > r2 { continue; } + let lx = cx_i + dx - self.bx; + let ly = cy_i + dy - self.by; + if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; } + let idx = (ly * self.width + lx) as usize; + if self.unpainted[idx] { + new_ink += 1; + } else if self.was_ink[idx] { + repaint_ink += 1; + } else { + bg += 1; } } (new_ink, repaint_ink, bg) @@ -639,25 +672,22 @@ impl Grid { /// Paint a disk: marks ink pixels under it as painted. Returns the /// number of ink pixels newly painted. - fn paint_disk(&mut self, p: (f32, f32), radius: f32) -> i32 { + fn paint_disk(&mut self, p: (f32, f32)) -> i32 { let cx_i = p.0.round() as i32; let cy_i = p.1.round() as i32; - let r = (radius + 1.0).ceil() as i32; - let r2 = radius * radius; + let r2 = self.brush_radius_sq; let mut newly = 0; - for dy in -r..=r { - for dx in -r..=r { - let ddx = (cx_i + dx) as f32 - p.0; - let ddy = (cy_i + dy) as f32 - p.1; - if ddx * ddx + ddy * ddy > r2 { continue; } - let lx = cx_i + dx - self.bx; - let ly = cy_i + dy - self.by; - if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; } - let idx = (ly * self.width + lx) as usize; - if self.unpainted[idx] { - self.unpainted[idx] = false; - newly += 1; - } + for &(dx, dy) in &self.disk_offsets { + let ddx = (cx_i + dx) as f32 - p.0; + let ddy = (cy_i + dy) as f32 - p.1; + if ddx * ddx + ddy * ddy > r2 { continue; } + let lx = cx_i + dx - self.bx; + let ly = cy_i + dy - self.by; + if lx < 0 || ly < 0 || lx >= self.width || ly >= self.height { continue; } + let idx = (ly * self.width + lx) as usize; + if self.unpainted[idx] { + self.unpainted[idx] = false; + newly += 1; } } self.ink_remaining -= newly; @@ -815,7 +845,7 @@ fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>, let mut p = start; let mut path = vec![p]; - grid.paint_disk(p, brush_radius); + grid.paint_disk(p); let mut prev_dir: Option<(f32, f32)> = init_dir.map(vec_unit); @@ -852,6 +882,12 @@ fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>, // Compute breakdown either way (cheap-ish; lets the viz show // even rejected directions' would-be score). + // Skip the disk evaluation entirely for rejected candidates + // when not tracing. The breakdown is only needed to record + // would-be scores in the debug viewer; the walker never + // picks a rejected candidate as `best`. + if !recording && (rejected_back || rejected_off_ink) { continue; } + let (new_ink, repaint, bg) = lookahead_score_breakdown( p, dir, grid, params, brush_radius, step_size); let momentum_bonus = if has_momentum { @@ -920,7 +956,7 @@ fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>, p = new_p; path.push(p); prev_dir = Some(dir); - grid.paint_disk(p, brush_radius); + grid.paint_disk(p); step_idx += 1; } @@ -945,7 +981,7 @@ fn lookahead_score_breakdown(start: (f32, f32), dir: (f32, f32), for k in 1..=params.lookahead_steps { let p = (start.0 + dir.0 * step_size * k as f32, start.1 + dir.1 * step_size * k as f32); - let (new, repaint, bg) = grid.evaluate_disk(p, brush_radius); + let (new, repaint, bg) = grid.evaluate_disk(p); let weight = 1.0 / (k as f32); total_new += new as f32 * weight; total_repaint += repaint as f32 * weight; @@ -1031,6 +1067,7 @@ pub fn paint_fill_with(hull: &Hull, params: &PaintParams) -> FillResult { let brush_radius = params.brush_radius_factor * effective_sdf + params.brush_radius_offset_px; let mut grid = Grid::from_hull(hull); + grid.set_brush(brush_radius); let mut strokes: Vec> = Vec::new(); let brush_area = std::f32::consts::PI * brush_radius * brush_radius; @@ -1048,7 +1085,7 @@ pub fn paint_fill_with(hull: &Hull, params: &PaintParams) -> FillResult { } else { path }; strokes.push(simplified); } else { - grid.paint_disk(start, brush_radius); + grid.paint_disk(start); } } @@ -1383,6 +1420,7 @@ pub fn paint_fill_debug(hull: &Hull, params: &PaintParams) -> PaintDebug { let brush_radius = params.brush_radius_factor * effective_sdf + params.brush_radius_offset_px; let mut grid = Grid::from_hull(hull); + grid.set_brush(brush_radius); let mut trajectories: Vec> = Vec::new(); let mut starts: Vec<(f32, f32)> = Vec::new(); @@ -1403,7 +1441,7 @@ pub fn paint_fill_debug(hull: &Hull, params: &PaintParams) -> PaintDebug { starts.push(path[0]); trajectories.push(path); } else { - grid.paint_disk(start, brush_radius); + grid.paint_disk(start); } } @@ -1416,7 +1454,7 @@ pub fn paint_fill_debug(hull: &Hull, params: &PaintParams) -> PaintDebug { let (sdf_b64, _) = encode_sdf_b64(hull); let ink_unpainted = grid.ink_remaining.max(0) as u32; - let (bg_painted, total_swept, repaint) = measure_sweep_full(&strokes, &grid, brush_radius); + let (bg_painted, total_swept, repaint) = measure_sweep_full(&strokes, &grid); let skeleton_length = grid.skeleton_length; let unpainted_clusters = grid.unpainted_cluster_sizes(); PaintDebug {