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.txt
diff --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()));
+            }
+        }
+    }
+}
