collective

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

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml--- a/.github/workflows/ci.yml+++ b/.github/workflows/ci.yml@@ -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.txtdiff --git a/crates/paint/examples/big_dab_bench.rs b/crates/paint/examples/big_dab_bench.rs--- a/crates/paint/examples/big_dab_bench.rs+++ b/crates/paint/examples/big_dab_bench.rs@@ -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()); }diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs--- a/crates/paint/src/lib.rs+++ b/crates/paint/src/lib.rs@@ -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).diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs--- a/crates/paint/src/render.rs+++ b/crates/paint/src/render.rs@@ -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 };             }         }diff --git a/crates/paint/src/render/reference.rs b/crates/paint/src/render/reference.rs--- a/crates/paint/src/render/reference.rs+++ b/crates/paint/src/render/reference.rs@@ -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()));+            }+        }+    }+}