Browse / github.com/storytold/photocraft / patch
perf(paint): cache round-tip geometry per dab
perf(paint): cache round-tip geometry per dab Computed round tips recalculated their core radius and Gaussian shoulder width for every pixel. The fallback `dab_coverage` path calculated them twice, and small tips repeated the calculation for each of their 16 subpixel samples, although radius and hardness are constant throughout a dab. ## Change - `TipProfile` caches the core radius, shoulder width and reach once per dab. The dense `RoundTip` rows, transformed/feature fallback and small-tip supersampling reuse it. - The public `dab_coverage` helper also calculates the shape only once per call. - Falloff keeps division by the shoulder wid
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- wanikhawar (imported from pull request #9850, open)
- Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035- Size
- +188 −32 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4e298d565395819de30ed7f2
How it went
No other operator has reported using it yet.
Diff
raw · .github/workflows/ci.yml crates/paint/examples/big_dab_bench.rs crates/paint/src/lib.rs crates/paint/src/render.rs crates/paint/src/render/reference.rs
@@ -11,6 +11,14 @@ on: types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs:+ brush_tip:+ description: "Compare cached round-tip rasterization on 24 MP"+ type: boolean+ default: false+ brush_tip_base:+ description: "Baseline commit for the round-tip comparison"+ type: string+ default: "main" selection_morphology: description: "Compare selection morphology on 36 MP (Linux, exact outputs)" type: boolean@@ -459,7 +467,8 @@ jobs: path: selection-morphology-perf.txt selection-morphology-quick-perf:- if: inputs.selection_morphology == true+ if: inputs.selection_morphology == true || inputs.brush_tip == true+ name: quick performance suite # Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by # bevel_bench, so its required GPU rows cannot be measured there. runs-on: macos-15@@ -481,3 +490,48 @@ jobs: path: | target/perf/*.json target/perf/summary.md++ brush-tip:+ if: inputs.brush_tip == true+ runs-on: ubuntu-24.04+ timeout-minutes: 30+ env:+ RAYON_NUM_THREADS: "4"+ steps:+ - uses: actions/checkout@v7+ with:+ persist-credentials: false+ path: after+ - uses: actions/checkout@v7+ with:+ persist-credentials: false+ ref: ${{ inputs.brush_tip_base }}+ path: before+ - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable+ - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3+ with:+ workspaces: |+ before -> target+ after -> target+ key: brush-tip-release+ - name: Build the same benchmark against both versions+ run: |+ cp after/crates/paint/examples/big_dab_bench.rs before/crates/paint/examples/big_dab_bench.rs+ cargo build --release --locked --manifest-path before/Cargo.toml -p photocraft-paint --example big_dab_bench+ cargo build --release --locked --manifest-path after/Cargo.toml -p photocraft-paint --example big_dab_bench+ - name: Compare round and transformed tips on 24 MP+ run: |+ set -o pipefail+ for size in 200 2000; do+ for angle in 0 0.7; do+ for version in before after; do+ echo "$version: size=$size angle=$angle" | tee -a brush-tip.txt+ "$version/target/release/examples/big_dab_bench" "$size" 0.37 "$angle" | tee -a brush-tip.txt+ done+ done+ done+ - uses: actions/upload-artifact@v7+ if: always()+ with:+ name: brush-tip-performance+ path: brush-tip.txt@@ -1,5 +1,5 @@ //! Where a large brush's time goes: one dab of a 2000 px tip, by stage.-//! `cargo run --release -p photocraft-paint --example big_dab_bench [size] [hardness]`+//! `cargo run --release -p photocraft-paint --example big_dab_bench [size] [hardness] [angle]` use photocraft_color::PixelFormat; use photocraft_geom::Rect; use photocraft_paint::{BrushContext, BrushSettings, Dab, StrokePoint, StrokeRenderer};@@ -10,26 +10,41 @@ fn main() { let args: Vec<String> = std::env::args().collect(); let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(2000.0); let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0);- let brush = BrushSettings { size, hardness, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() };+ let angle: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(0.0);+ let brush = BrushSettings { size, hardness, angle, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }; let fmt = PixelFormat::RGBA8; let mut pre = Surface::new(fmt);- pre.fill_rect(Rect::new(0, 0, 4928, 3280), &[0.5, 0.5, 0.5, 1.0]);+ pre.fill_rect(Rect::new(0, 0, 6000, 4000), &[0.5, 0.5, 0.5, 1.0]); let mut target = pre.clone(); // Stage 1: the tip's coverage alone. let ctx = BrushContext::new(&brush);- let dab = Dab::round(photocraft_geom::Point::new(2464.0, 1640.0), size / 2.0, 1.0);+ let mut dab = Dab::round(photocraft_geom::Point::new(3000.0, 2000.0), size / 2.0, 1.0);+ dab.angle = angle; let rect = ctx.dab_rect(&dab, false); let mut cov = Vec::new();- let t = Instant::now();+ // Warm the reusable coverage buffer and take the median of seven rasterizations. ctx.rasterize(&dab, false, rect, &mut cov);- let raster = t.elapsed();- println!("tip {size} px, hardness {hardness}: dab rect {}x{} = {:.1} MP", rect.width(), rect.height(), rect.width() as f64 * rect.height() as f64 / 1e6);- println!(" rasterize (coverage): {:>7.1} ms", raster.as_secs_f64() * 1e3);+ let mut samples = Vec::new();+ for _ in 0..7 {+ let t = Instant::now();+ ctx.rasterize(std::hint::black_box(&dab), false, rect, &mut cov);+ std::hint::black_box(&cov);+ samples.push(t.elapsed());+ }+ samples.sort();+ let raster = samples.get(3).copied().unwrap_or_default();+ println!(+ "tip {size} px, hardness {hardness}, angle {angle} (24 MP document): dab rect {}x{} = {:.1} MP",+ rect.width(),+ rect.height(),+ rect.width() as f64 * rect.height() as f64 / 1e6+ );+ println!(" rasterize (coverage): {:>7.3} ms", raster.as_secs_f64() * 1e3); // Stage 2+3: push through the renderer (rasterize + accumulate), then composite. let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0); let t = Instant::now(); // Two points a pixel apart: the generator places the first dab at once (a lone point waits).- r.push(&[StrokePoint::new(2464.0, 1640.0, 1.0), StrokePoint::new(2465.0, 1640.0, 1.0)]);+ r.push(&[StrokePoint::new(3000.0, 2000.0, 1.0), StrokePoint::new(3001.0, 2000.0, 1.0)]); let push = t.elapsed(); println!(" push (rasterize+accumulate): {:>5.1} ms (accumulate ≈ {:.1} ms)", push.as_secs_f64() * 1e3, push.saturating_sub(raster).as_secs_f64() * 1e3); let t = Instant::now();@@ -38,7 +53,7 @@ fn main() { println!(" composite (dirty tiles): {:>7.1} ms over {}x{}", comp.as_secs_f64() * 1e3, dmg.width(), dmg.height()); // A second dab half a spacing away, as a stroke does: the same again. let t = Instant::now();- r.push(&[StrokePoint::new(2464.0 + f64::from(size) * 0.25, 1640.0, 1.0), StrokePoint::new(2466.0 + f64::from(size) * 0.25, 1640.0, 1.0)]);+ r.push(&[StrokePoint::new(3000.0 + f64::from(size) * 0.25, 2000.0, 1.0), StrokePoint::new(3002.0 + f64::from(size) * 0.25, 2000.0, 1.0)]); let dmg = r.composite(&pre, &mut target, None, false, false); println!(" next dab push+composite: {:>7.1} ms over {}x{}", t.elapsed().as_secs_f64() * 1e3, dmg.width(), dmg.height()); }@@ -166,6 +166,45 @@ pub fn tip_shape(radius: f32, hardness: f32) -> (f32, f32) { ((r0 * radius).min(radius - edge).max(0.0), (s * radius).max(edge).max(1e-3)) } +/// Dab-invariant round-tip geometry, shared by all pixels and subpixel samples.+#[derive(Clone, Copy)]+pub(crate) struct TipProfile {+ core: f32,+ shoulder: f32,+ reach: f32,+}++impl TipProfile {+ #[inline]+ pub(crate) fn new(radius: f32, hardness: f32) -> Self {+ let (core, shoulder) = tip_shape(radius, hardness);+ Self { core, shoulder, reach: core + 2.6 * shoulder }+ }++ #[inline]+ pub(crate) fn reach(self) -> f32 {+ self.reach+ }++ #[inline]+ pub(crate) fn falloff(self, d: f32) -> f32 {+ if d <= self.core {+ return 1.0;+ }+ // Keep division: multiplying by a cached reciprocal changes coverage bits.+ let x = (d - self.core) / self.shoulder;+ soft_exp(x * x)+ }++ #[inline]+ pub(crate) fn coverage(self, d: f32) -> f32 {+ if d.is_nan() || d >= self.reach + 0.5 {+ return 0.0;+ }+ self.falloff(d)+ }+}+ /// `exp(-u)` as `(1 − u/64)⁶⁴` by six squarings: within 1/255 of `exp` where it matters, exact /// arithmetic (the same on every platform) and exactly 0 from `u = 64`. #[inline]@@ -180,12 +219,7 @@ fn soft_exp(u: f32) -> f32 { /// Opacity of a round tip at distance `d` from its centre, edge included (see [`tip_shape`]). #[inline] pub fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 {- let (r0, s) = tip_shape(radius, hardness);- if d <= r0 {- return 1.0;- }- let x = (d - r0) / s;- soft_exp(x * x)+ TipProfile::new(radius, hardness).falloff(d) } /// How far from its centre a round tip still paints (where [`tip_falloff`] drops below ~0.1 %).@@ -207,11 +241,7 @@ pub fn half_coverage_radius(radius: f32, hardness: f32) -> f32 { /// Coverage of a round dab at distance `d` from its centre. #[inline] pub fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 {- // NaN distances paint nothing.- if d.is_nan() || d >= tip_reach(radius, hardness) + 0.5 {- return 0.0;- }- tip_falloff(d, radius, hardness)+ TipProfile::new(radius, hardness).coverage(d) } /// Rasterize a stroke onto `target`, optionally limited by a selection (grayscale coverage surface).@@ -19,7 +19,7 @@ use crate::dynamics::{DabGenerator, grid_cell}; use crate::retouch::{Footprint, alpha_index, over_native}; use crate::rng::hash2; use crate::tile::{Mips, PatternImage};-use crate::{Dab, StrokePoint, dab_coverage, tip_falloff, tip_reach};+use crate::{Dab, StrokePoint, TipProfile}; /// Salt of the 8-bit dither's per-pixel noise. const DITHER_SALT: u64 = 0x0000_6469_7468_6572;@@ -311,7 +311,8 @@ impl BrushContext { let r = d.radius; let ro = d.roundness.max(0.5 / r).min(1.0); let rm = (r * ro).max(0.5);- let reach = if aliased || mips.is_some() { rm } else { crate::tip_reach(rm, hardness).max(rm) };+ let profile = TipProfile::new(rm, hardness);+ let reach = if aliased || mips.is_some() { rm } else { profile.reach().max(rm) }; let reach2 = (reach + 0.5) * (reach + 0.5); // Sampled-tip mapping. let (level, inv_scale, tw, th) = match mips {@@ -352,7 +353,7 @@ impl BrushContext { && d.alpha.is_finite() && w > 0 {- rasterize_round(d, rect, out, hardness, aliased);+ rasterize_round(d, rect, out, profile, aliased); return; } for yy in 0..h {@@ -382,7 +383,7 @@ impl BrushContext { let sd = ((su * ro).powi(2) + sv * sv).sqrt(); // Each sample takes the tip's own edge, so the covered area // changes smoothly with the dab's position.- sum += tip_falloff(sd, rm, hardness);+ sum += profile.falloff(sd); } } (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm)@@ -393,7 +394,7 @@ impl BrushContext { continue; } let dd = d2.sqrt();- let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { dab_coverage(dd, rm, hardness) };+ let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; (val, dd / rm) } Some(m) => {@@ -1231,7 +1232,7 @@ pub fn render_stroke( // The zero-angle transform only changes signs; squaring gives the same radial distance. #[inline(never)]-fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: bool) {+fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], profile: TipProfile, aliased: bool) { let (cx, cy) = if aliased { grid_center(d.center.x, d.center.y, 2.0 * d.radius) } else { (d.center.x as f32, d.center.y as f32) }; let ro = d.roundness.max(0.5 / d.radius).min(1.0); let rm = (d.radius * ro).max(0.5);@@ -1239,8 +1240,8 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: if w == 0 { return; }- let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) };- let tip = RoundTip { cx, cy, ro, rm, reach2: (reach + 0.5) * (reach + 0.5), hardness, alpha: d.alpha, aliased, x0: rect.x0, y0: rect.y0 };+ let reach = if aliased { rm } else { profile.reach().max(rm) };+ let tip = RoundTip { cx, cy, ro, rm, reach2: (reach + 0.5) * (reach + 0.5), profile, alpha: d.alpha, aliased, x0: rect.x0, y0: rect.y0 }; // Rows are independent: a large tip's rows run in parallel, to the same values. The serial // path stays a plain loop over an inlined row: the same code as before the threads (a closure // passed to a helper cost a small tip half its rasterization time).@@ -1263,7 +1264,7 @@ struct RoundTip { ro: f32, rm: f32, reach2: f32,- hardness: f32,+ profile: TipProfile, alpha: f32, aliased: bool, x0: i32,@@ -1285,7 +1286,7 @@ impl RoundTip { for (xx, dst) in row.iter_mut().enumerate() { let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx; let d2 = (dx * self.ro).powi(2) + dy * dy;- let val = if d2 >= self.reach2 { 0.0 } else { tip_falloff(d2.sqrt(), self.rm, self.hardness) };+ let val = if d2 >= self.reach2 { 0.0 } else { self.profile.falloff(d2.sqrt()) }; *dst = if val <= 0.0 { 0.0 } else { val * self.alpha }; } }@@ -1,6 +1,7 @@ //! Frozen scalar rasterizer used only as a pixel oracle for the computed-tip fast path. use super::*; use crate::tile::GrayTile;+use crate::{soft_exp, tip_reach, tip_shape}; use photocraft_geom::Point; impl BrushContext {@@ -243,3 +244,58 @@ fn computed_tips_match_at_pixel_and_float_boundaries() { } } }++#[test]+fn cached_profiles_match_scalar_for_small_and_transformed_dabs() {+ for hardness in [-0.5, 0.0, 0.035, 0.37, 0.95, 1.0, 1.5, f32::NAN, f32::INFINITY] {+ let ctx = BrushContext::new(&BrushSettings { hardness, ..Default::default() });+ for radius in [0.5, 1.25, 2.99, 3.0, 7.5, 100.0] {+ for dual in [false, true] {+ for with_depth in [false, true] {+ for angle in [0.0, 0.7] {+ let mut d = Dab::round(Point::new(4.25, -3.75), radius, 0.73);+ d.angle = angle;+ d.roundness = 0.6;+ compare(&ctx, &d, dual, ctx.dab_rect(&d, dual), with_depth);+ }+ }+ }+ }+ }+}++// Keep the pre-cache scalar coverage equations independent of TipProfile.+fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 {+ let (r0, s) = tip_shape(radius, hardness);+ if d <= r0 {+ return 1.0;+ }+ let x = (d - r0) / s;+ soft_exp(x * x)+}++fn dab_coverage(d: f32, radius: f32, hardness: f32) -> f32 {+ // NaN distances paint nothing.+ if d.is_nan() || d >= tip_reach(radius, hardness) + 0.5 {+ return 0.0;+ }+ tip_falloff(d, radius, hardness)+}++#[test]+fn cached_profile_preserves_public_coverage_at_boundaries() {+ for radius in [-1.0, 0.0, 0.5, 3.0, 100.0, f32::NAN, f32::INFINITY] {+ for hardness in [-1.0, 0.0, 0.035, 0.37, 1.0, 2.0, f32::NAN, f32::INFINITY] {+ let profile = TipProfile::new(radius, hardness);+ let (core, shoulder) = tip_shape(radius, hardness);+ for d in [-1.0, -0.0, core, core + shoulder, profile.reach(), profile.reach() + 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {+ let expected = dab_coverage(d, radius, hardness);+ assert_eq!(profile.coverage(d).to_bits(), expected.to_bits());+ assert_eq!(crate::dab_coverage(d, radius, hardness).to_bits(), expected.to_bits());+ let expected = tip_falloff(d, radius, hardness);+ let actual = crate::tip_falloff(d, radius, hardness);+ assert!(actual.to_bits() == expected.to_bits() || (actual.is_nan() && expected.is_nan()));+ }+ }+ }+}