brush-paint: skip-when-not-tracing + precomputed disk offsets

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.
This commit is contained in:
Mitchell Hansen
2026-05-07 00:40:55 -07:00
parent 391fda6206
commit ab07593ade

View File

@@ -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 /// baseline single-pass sweep has some repaint from adjacent-sample
/// disk overlap (~4× per pixel at step_size_factor=0.5); higher /// disk overlap (~4× per pixel at step_size_factor=0.5); higher
/// values mean the path is doubling back over itself. /// 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) -> (u32, u32, u32)
{ {
if strokes.is_empty() { return (0, 0, 0); } if strokes.is_empty() { return (0, 0, 0); }
let mut count = vec![0u32; grid.was_ink.len()]; let mut count = vec![0u32; grid.was_ink.len()];
let r = (brush_radius + 1.0).ceil() as i32; let r2 = grid.brush_radius_sq;
let r2 = brush_radius * brush_radius;
for stroke in strokes { for stroke in strokes {
for win in stroke.windows(2) { for win in stroke.windows(2) {
let (a, b) = (win[0], win[1]); let (a, b) = (win[0], win[1]);
@@ -305,8 +304,7 @@ fn measure_sweep_full(strokes: &[Vec<(f32, f32)>], grid: &Grid, brush_radius: f3
let cy = a.1 + dy * t; let cy = a.1 + dy * t;
let cx_i = cx.round() as i32; let cx_i = cx.round() as i32;
let cy_i = cy.round() as i32; let cy_i = cy.round() as i32;
for ddy in -r..=r { for &(ddx, ddy) in &grid.disk_offsets {
for ddx in -r..=r {
let dxr = (cx_i + ddx) as f32 - cx; let dxr = (cx_i + ddx) as f32 - cx;
let dyr = (cy_i + ddy) as f32 - cy; let dyr = (cy_i + ddy) as f32 - cy;
if dxr * dxr + dyr * dyr > r2 { continue; } if dxr * dxr + dyr * dyr > r2 { continue; }
@@ -318,7 +316,6 @@ fn measure_sweep_full(strokes: &[Vec<(f32, f32)>], grid: &Grid, brush_radius: f3
} }
} }
} }
}
let mut bg = 0u32; let mut bg = 0u32;
let mut total = 0u32; let mut total = 0u32;
let mut repaint = 0u32; let mut repaint = 0u32;
@@ -460,6 +457,18 @@ struct Grid {
ink_total: i32, ink_total: i32,
/// Currently unpainted ink pixel count. /// Currently unpainted ink pixel count.
ink_remaining: i32, 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 { impl Grid {
@@ -514,7 +523,34 @@ impl Grid {
let skeleton_length = skel.len() as u32; let skeleton_length = skel.len() as u32;
Self { bx, by, width, height, unpainted, was_ink, sdf, skel_endpoints, 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. /// Look up SDF at an integer pixel.
@@ -604,16 +640,14 @@ impl Grid {
/// bg: pixels that were never ink (heavy penalty — these /// bg: pixels that were never ink (heavy penalty — these
/// become visible off-glyph paint on the actual plot) /// become visible off-glyph paint on the actual plot)
/// Does NOT mutate the grid. /// 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 cx_i = p.0.round() as i32;
let cy_i = p.1.round() as i32; let cy_i = p.1.round() as i32;
let r = (radius + 1.0).ceil() as i32; let r2 = self.brush_radius_sq;
let r2 = radius * radius;
let mut new_ink = 0; let mut new_ink = 0;
let mut repaint_ink = 0; let mut repaint_ink = 0;
let mut bg = 0; let mut bg = 0;
for dy in -r..=r { for &(dx, dy) in &self.disk_offsets {
for dx in -r..=r {
// True distance from float waypoint to integer pixel // True distance from float waypoint to integer pixel
// center — keeps the brush's footprint shifting smoothly // center — keeps the brush's footprint shifting smoothly
// with sub-pixel waypoint motion (without this, small // with sub-pixel waypoint motion (without this, small
@@ -633,20 +667,17 @@ impl Grid {
bg += 1; bg += 1;
} }
} }
}
(new_ink, repaint_ink, bg) (new_ink, repaint_ink, bg)
} }
/// Paint a disk: marks ink pixels under it as painted. Returns the /// Paint a disk: marks ink pixels under it as painted. Returns the
/// number of ink pixels newly painted. /// 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 cx_i = p.0.round() as i32;
let cy_i = p.1.round() as i32; let cy_i = p.1.round() as i32;
let r = (radius + 1.0).ceil() as i32; let r2 = self.brush_radius_sq;
let r2 = radius * radius;
let mut newly = 0; let mut newly = 0;
for dy in -r..=r { for &(dx, dy) in &self.disk_offsets {
for dx in -r..=r {
let ddx = (cx_i + dx) as f32 - p.0; let ddx = (cx_i + dx) as f32 - p.0;
let ddy = (cy_i + dy) as f32 - p.1; let ddy = (cy_i + dy) as f32 - p.1;
if ddx * ddx + ddy * ddy > r2 { continue; } if ddx * ddx + ddy * ddy > r2 { continue; }
@@ -659,7 +690,6 @@ impl Grid {
newly += 1; newly += 1;
} }
} }
}
self.ink_remaining -= newly; self.ink_remaining -= newly;
newly newly
} }
@@ -815,7 +845,7 @@ fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>,
let mut p = start; let mut p = start;
let mut path = vec![p]; 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); 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 // Compute breakdown either way (cheap-ish; lets the viz show
// even rejected directions' would-be score). // 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( let (new_ink, repaint, bg) = lookahead_score_breakdown(
p, dir, grid, params, brush_radius, step_size); p, dir, grid, params, brush_radius, step_size);
let momentum_bonus = if has_momentum { let momentum_bonus = if has_momentum {
@@ -920,7 +956,7 @@ fn walk_brush(start: (f32, f32), init_dir: Option<(f32, f32)>,
p = new_p; p = new_p;
path.push(p); path.push(p);
prev_dir = Some(dir); prev_dir = Some(dir);
grid.paint_disk(p, brush_radius); grid.paint_disk(p);
step_idx += 1; step_idx += 1;
} }
@@ -945,7 +981,7 @@ fn lookahead_score_breakdown(start: (f32, f32), dir: (f32, f32),
for k in 1..=params.lookahead_steps { for k in 1..=params.lookahead_steps {
let p = (start.0 + dir.0 * step_size * k as f32, let p = (start.0 + dir.0 * step_size * k as f32,
start.1 + dir.1 * 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); let weight = 1.0 / (k as f32);
total_new += new as f32 * weight; total_new += new as f32 * weight;
total_repaint += repaint 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 brush_radius = params.brush_radius_factor * effective_sdf + params.brush_radius_offset_px;
let mut grid = Grid::from_hull(hull); let mut grid = Grid::from_hull(hull);
grid.set_brush(brush_radius);
let mut strokes: Vec<Vec<(f32, f32)>> = Vec::new(); let mut strokes: Vec<Vec<(f32, f32)>> = Vec::new();
let brush_area = std::f32::consts::PI * brush_radius * brush_radius; 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 }; } else { path };
strokes.push(simplified); strokes.push(simplified);
} else { } 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 brush_radius = params.brush_radius_factor * effective_sdf + params.brush_radius_offset_px;
let mut grid = Grid::from_hull(hull); let mut grid = Grid::from_hull(hull);
grid.set_brush(brush_radius);
let mut trajectories: Vec<Vec<(f32, f32)>> = Vec::new(); let mut trajectories: Vec<Vec<(f32, f32)>> = Vec::new();
let mut starts: Vec<(f32, f32)> = 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]); starts.push(path[0]);
trajectories.push(path); trajectories.push(path);
} else { } 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 (sdf_b64, _) = encode_sdf_b64(hull);
let ink_unpainted = grid.ink_remaining.max(0) as u32; 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 skeleton_length = grid.skeleton_length;
let unpainted_clusters = grid.unpainted_cluster_sizes(); let unpainted_clusters = grid.unpainted_cluster_sizes();
PaintDebug { PaintDebug {