diff --git a/crates/engine/examples/bench_brush_mips.rs b/crates/engine/examples/bench_brush_mips.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/examples/bench_brush_mips.rs
@@ -0,0 +1,117 @@
+//! Sampled-tip preparation and live strokes on a materialized 24 MP layer. This harness also
+//! builds against the pre-cache revision for alternating before/after runs on the same runner.
+//! `cargo run --release -p photocraft-engine --example bench_brush_mips`
+use std::hint::black_box;
+use std::time::Instant;
+
+use photocraft_color::{PixelFormat, SampleType};
+use photocraft_engine::{EngineError, Session, brush_cmds::LiveStroke};
+use photocraft_geom::Rect;
+use photocraft_paint::{BrushContext, GrayTile, StrokePoint, TipShape};
+use serde_json::json;
+
+fn ms(start: Instant) -> f64 {
+    start.elapsed().as_secs_f64() * 1e3
+}
+
+fn median(v: &mut [f64]) -> f64 {
+    v.sort_by(f64::total_cmp);
+    v.get(v.len() / 2).copied().unwrap_or(0.0)
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    for depth in [8, 16, 32] {
+        let sample = match depth {
+            16 => SampleType::U16,
+            32 => SampleType::F32,
+            _ => SampleType::U8,
+        };
+        let fmt = PixelFormat::RGBA8.with_sample(sample);
+        let mut s = Session::new();
+        s.execute("file.new", json!({"width":6000,"height":4000,"depth":depth}))?;
+        s.execute("layer.new.layer", json!({}))?;
+        s.edit("materialize", |doc, active| {
+            let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?;
+            let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no pixel layer".into()))?;
+            surf.fill_rect(Rect::new(0, 0, 6000, 4000), &photocraft_raster::from_rgba(&fmt, [0.3, 0.6, 0.8, 0.7]));
+            Ok(())
+        })?;
+        for side in [0, 1024, 2048, 5000] {
+            let original = (side > 0).then(|| GrayTile::from_fn(side, side, |x, y| ((x * 13 + y * 7) % 31) as f32 / 30.0));
+            for symmetry in ["off", "dual"] {
+                if symmetry == "off" {
+                    if s.is_enabled("paint.symmetryDisable") {
+                        s.execute("paint.symmetryDisable", json!({}))?;
+                    }
+                } else {
+                    s.execute("paint.setSymmetry", json!({"mode":symmetry}))?;
+                }
+                let (mut cold, mut warm, mut begin, mut draw, mut release) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
+                let mut fingerprint = None;
+                // A fresh allocation makes every cold preparation a genuine miss. Copying
+                // samples and materializing the document are outside all measured stages.
+                for trial in 0..8 {
+                    s.tools.brush.tip = original
+                        .as_ref()
+                        .map_or(TipShape::Round, |t| TipShape::Sampled(GrayTile { width: t.width, height: t.height, data: t.data.as_ref().to_vec().into() }));
+                    let t = Instant::now();
+                    let ctx = BrushContext::new(&s.tools.brush);
+                    let cold_ms = ms(t);
+                    black_box(&ctx);
+                    drop(ctx);
+                    let t = Instant::now();
+                    let ctx = BrushContext::new(&s.tools.brush);
+                    let warm_ms = ms(t);
+                    black_box(&ctx);
+                    drop(ctx);
+                    let mut points: Vec<[f64; 3]> = (0..65).map(|i| [2600.0 + f64::from(i) * 5.0, 1800.0 + (f64::from(i) / 8.0).sin() * 30.0, 0.8]).collect();
+                    let mut params = json!({"points":[points[0]],"size":60,"flow":0.5,"smoothing":0.0,"seed":42});
+                    let t = Instant::now();
+                    let mut live = LiveStroke::begin(&s, &params)?;
+                    let begin_ms = ms(t);
+                    let t = Instant::now();
+                    for batch in points.get(1..).unwrap_or_default().chunks(4) {
+                        let batch: Vec<_> = batch.iter().map(|p| StrokePoint::new(p[0], p[1], p[2] as f32)).collect();
+                        live.push(&batch)?;
+                    }
+                    let draw_ms = ms(t);
+                    let surface = live.doc.layers.iter().rev().find_map(|l| l.surface()).ok_or("no painted surface")?;
+                    let hash = surface
+                        .read_region(Rect::new(2500, 1700, 3500, 2300))
+                        .iter()
+                        .fold(0xcbf2_9ce4_8422_2325u64, |hash, v| (hash ^ u64::from(v.to_bits())).wrapping_mul(0x100_0000_01b3));
+                    if fingerprint.is_some_and(|previous| previous != hash) {
+                        return Err("stroke fingerprint changed across repeated trials".into());
+                    }
+                    fingerprint = Some(hash);
+                    params["points"] = json!(std::mem::take(&mut points));
+                    let t = Instant::now();
+                    s.offer_live_stroke(live)?;
+                    let result = s.execute("paint.stroke", params)?;
+                    let release_ms = ms(t);
+                    if result.get("fromLive").is_none() {
+                        return Err("commit did not take the live pixels".into());
+                    }
+                    s.execute("edit.undo", json!({}))?;
+                    if trial > 0 {
+                        cold.push(cold_ms);
+                        warm.push(warm_ms);
+                        begin.push(begin_ms);
+                        draw.push(draw_ms);
+                        release.push(release_ms);
+                    }
+                }
+                println!(
+                    "tip={side} depth={depth} symmetry={symmetry} cold_ms={:.6} warm_ms={:.6} begin_ms={:.6} draw_ms={:.6} release_ms={:.6} fingerprint={:016x}",
+                    median(&mut cold),
+                    median(&mut warm),
+                    median(&mut begin),
+                    median(&mut draw),
+                    median(&mut release),
+                    fingerprint.unwrap_or(0)
+                );
+            }
+        }
+    }
+    Ok(())
+}
diff --git a/crates/engine/src/cmd/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs
--- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs
+++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs
@@ -66,6 +66,11 @@ pub(super) fn purge(s: &mut Session, what: &str) -> Result<Value> {
             freed = freed.saturating_add(strokes);
             items.push("shape stroke cache");
         }
+        let mips = photocraft_paint::purge_mip_cache();
+        if mips > 0 {
+            freed = freed.saturating_add(mips);
+            items.push("brush mip cache");
+        }
     }
     s.edit_state.fade = None;
     let msg = if items.is_empty() { "nothing to purge".to_string() } else { format!("purged {}", items.join(", ")) };
@@ -86,7 +91,11 @@ pub(super) fn can_purge_histories(s: &Session) -> std::result::Result<(), String
 
 pub(super) fn can_purge_all(s: &Session) -> std::result::Result<(), String> {
     can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| {
-        if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 || crate::smart_cmds::stack_cache_bytes() > 0 {
+        if photocraft_compose::effect_cache_bytes() > 0
+            || photocraft_compose::masks::cache_bytes() > 0
+            || crate::smart_cmds::stack_cache_bytes() > 0
+            || photocraft_paint::mip_cache_bytes() > 0
+        {
             Ok(())
         } else {
             Err("nothing to purge".into())
diff --git a/crates/engine/src/cmd/edit_menu_cmds/tests.rs b/crates/engine/src/cmd/edit_menu_cmds/tests.rs
--- a/crates/engine/src/cmd/edit_menu_cmds/tests.rs
+++ b/crates/engine/src/cmd/edit_menu_cmds/tests.rs
@@ -1,6 +1,8 @@
 use super::*;
 use photocraft_doc::vector::Subpath;
 
+static PURGE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
 fn session(depth: u32) -> Session {
     let mut s = Session::new();
     s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth, "background": "white"})).unwrap();
@@ -154,6 +156,7 @@ fn fade_ignores_a_target_the_command_doesnt_take() {
 
 #[test]
 fn purge_commands() {
+    let _purge_test_guard = PURGE_TEST_LOCK.lock().unwrap();
     let mut s = session(8);
     assert!(!s.is_enabled("edit.purge.undo"));
     assert!(s.is_enabled("edit.purge.videoCache"));
@@ -180,6 +183,30 @@ fn purge_commands() {
     let _ = s.execute("edit.purge.all", json!({}));
 }
 
+#[test]
+fn purge_all_releases_prepared_brush_tips_even_without_a_document() {
+    let _purge_test_guard = PURGE_TEST_LOCK.lock().unwrap();
+    use photocraft_paint::{BrushContext, BrushSettings, Dab, GrayTile, TipShape};
+    let mut s = Session::new();
+    let brush = BrushSettings { tip: TipShape::Sampled(GrayTile::from_fn(31, 17, |x, y| ((x + y) % 5) as f32 / 4.0)), ..Default::default() };
+    let ctx = BrushContext::new(&brush);
+    let dab = Dab::round(photocraft_geom::Point::new(12.5, 14.5), 10.0, 1.0);
+    let rect = ctx.dab_rect(&dab, false);
+    let mut before = Vec::new();
+    ctx.rasterize(&dab, false, rect, &mut before);
+    assert!(s.is_enabled("edit.purge.all"), "prepared tips alone enable All");
+    let result = s.execute("edit.purge.all", json!({})).unwrap();
+    assert!(result["purged"].as_array().unwrap().contains(&json!("brush mip cache")));
+    assert!(result["bytes"].as_u64().unwrap() > 0);
+    // An already prepared brush remains valid after the cache releases its Arc.
+    let mut after = Vec::new();
+    ctx.rasterize(&dab, false, rect, &mut after);
+    assert_eq!(before, after);
+    let next = BrushContext::new(&brush);
+    next.rasterize(&dab, false, rect, &mut after);
+    assert_eq!(before, after);
+}
+
 /// White canvas with vertical black stripes, a red blob to remove, and a selection around it.
 fn blob_session(depth: u32) -> Session {
     let mut s = session(depth);
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
@@ -22,6 +22,7 @@ pub mod bg_erase;
 pub mod brush;
 pub mod curve;
 pub mod dynamics;
+mod mip_cache;
 pub mod mixer;
 pub mod outline;
 pub mod presets;
@@ -38,6 +39,7 @@ pub use brush::{
     Scattering, SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer,
 };
 pub use curve::ResponseCurve;
+pub use mip_cache::{mip_cache_bytes, purge_mip_cache};
 pub use mixer::MixerSettings;
 pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke};
 pub use tile::{GrayTile, StoredTile};
diff --git a/crates/paint/src/mip_cache.rs b/crates/paint/src/mip_cache.rs
new file mode 100644
--- /dev/null
+++ b/crates/paint/src/mip_cache.rs
@@ -0,0 +1,236 @@
+//! Prepared sampled tips shared across strokes and symmetry passes. Identity follows the
+//! immutable sample allocation, not preset names or brush settings: copy-on-write edits and
+//! loading a stored tip's full bitmap must prepare fresh mips.
+
+use std::collections::VecDeque;
+use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak};
+
+use crate::tile::{GrayTile, Mips};
+
+// One maximum-size 5000² tip and its float pyramid fit (~175 MiB). Count both the mip
+// buffers and source storage: even a weak Arc key reserves its allocation until eviction.
+// Active strokes may keep using evicted pyramids through their own Arcs.
+const MAX_BYTES: usize = 256 * 1024 * 1024;
+const MAX_ENTRIES: usize = 64;
+static CACHE: OnceLock<Mutex<MipCache>> = OnceLock::new();
+
+struct Entry {
+    width: u32,
+    height: u32,
+    // A weak key detects dropped sources and prevents allocation reuse for a different tip.
+    // Arc::make_mut dissociates weak pointers on an in-place edit.
+    samples: Weak<[u16]>,
+    mips: Arc<Mips>,
+    bytes: usize,
+}
+
+struct MipCache {
+    entries: VecDeque<Entry>,
+    bytes: usize,
+    max_bytes: usize,
+    max_entries: usize,
+}
+
+impl MipCache {
+    fn new(max_bytes: usize, max_entries: usize) -> Self {
+        Self { entries: VecDeque::new(), bytes: 0, max_bytes, max_entries }
+    }
+
+    fn get(&mut self, tip: &GrayTile) -> Option<Arc<Mips>> {
+        let samples = Arc::downgrade(&tip.data);
+        let index = self.entries.iter().position(|e| e.width == tip.width && e.height == tip.height && e.samples.ptr_eq(&samples))?;
+        let entry = self.entries.remove(index)?;
+        let mips = Arc::clone(&entry.mips);
+        self.entries.push_back(entry);
+        Some(mips)
+    }
+
+    fn insert(&mut self, tip: &GrayTile, mips: Arc<Mips>) -> Arc<Mips> {
+        // Another caller may have finished preparing the same tip while this one built it.
+        if let Some(existing) = self.get(tip) {
+            return existing;
+        }
+        self.entries.retain(|e| e.samples.strong_count() > 0);
+        self.bytes = self.entries.iter().fold(0usize, |n, e| n.saturating_add(e.bytes));
+        let source_bytes = tip.data.len().saturating_mul(size_of::<u16>());
+        let bytes = mips.levels.iter().fold(source_bytes, |n, (_, _, values)| n.saturating_add(values.capacity().saturating_mul(size_of::<f32>())));
+        // An oversized tip still renders normally, without evicting useful cached tips or
+        // extending the retention budget. Invalid tips retain Mips::new's ordinary fallback.
+        if bytes > self.max_bytes || self.max_entries == 0 || !tip.is_valid() {
+            return mips;
+        }
+        while self.bytes > self.max_bytes - bytes || self.entries.len() >= self.max_entries {
+            let Some(old) = self.entries.pop_front() else { break };
+            self.bytes = self.bytes.saturating_sub(old.bytes);
+        }
+        self.bytes = self.bytes.saturating_add(bytes);
+        self.entries.push_back(Entry { width: tip.width, height: tip.height, samples: Arc::downgrade(&tip.data), mips: Arc::clone(&mips), bytes });
+        mips
+    }
+
+    fn purge(&mut self) -> usize {
+        self.entries.clear();
+        std::mem::take(&mut self.bytes)
+    }
+}
+
+fn cached(cache: &Mutex<MipCache>, tip: &GrayTile) -> Arc<Mips> {
+    if let Some(mips) = cache.lock().unwrap_or_else(PoisonError::into_inner).get(tip) {
+        return mips;
+    }
+    // Preparing a large pyramid must not hold the global lock: hits for other brushes and
+    // unrelated documents can proceed. Concurrent misses may build twice, but publish one Arc.
+    let mips = Arc::new(Mips::new(tip));
+    cache.lock().unwrap_or_else(PoisonError::into_inner).insert(tip, mips)
+}
+
+pub(crate) fn tip_mips(tip: &GrayTile) -> Arc<Mips> {
+    cached(CACHE.get_or_init(|| Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES))), tip)
+}
+
+/// Sample and mip buffer bytes retained by the prepared-tip cache, including weak-key storage.
+pub fn mip_cache_bytes() -> usize {
+    CACHE.get().map_or(0, |cache| cache.lock().unwrap_or_else(PoisonError::into_inner).bytes)
+}
+
+/// Release prepared-tip cache ownership and return its accounted buffer bytes. Active strokes
+/// keep their shared pyramids and continue painting unchanged; the next stroke can prepare anew.
+pub fn purge_mip_cache() -> usize {
+    CACHE.get().map_or(0, |cache| cache.lock().unwrap_or_else(PoisonError::into_inner).purge())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn tip(w: u32, h: u32) -> GrayTile {
+        GrayTile::from_fn(w, h, |x, y| ((x * 13 + y * 7) % 31) as f32 / 30.0)
+    }
+
+    #[test]
+    fn a_repeated_tip_reuses_every_level_after_the_first_stroke_drops() {
+        let cache = Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES));
+        let tip = tip(31, 17);
+        let first = cached(&cache, &tip);
+        let weak = Arc::downgrade(&first);
+        assert_eq!(first.levels, Mips::new(&tip).levels);
+        drop(first);
+        let next = cached(&cache, &tip.clone());
+        assert!(Arc::ptr_eq(&weak.upgrade().unwrap(), &next));
+        assert_eq!(cache.lock().unwrap().entries.len(), 1);
+    }
+
+    #[test]
+    fn dimensions_and_cow_sample_edits_invalidate_cached_mips() {
+        let cache = Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES));
+        let mut original = tip(8, 4);
+        let first = cached(&cache, &original);
+        let reshaped = GrayTile { width: 4, height: 8, data: Arc::clone(&original.data) };
+        let changed_shape = cached(&cache, &reshaped);
+        assert!(!Arc::ptr_eq(&first, &changed_shape));
+        assert_eq!(changed_shape.levels, Mips::new(&reshaped).levels);
+        let saved = original.clone();
+        Arc::make_mut(&mut original.data)[0] = 65535;
+        let changed_samples = cached(&cache, &original);
+        assert!(!Arc::ptr_eq(&first, &changed_samples));
+        assert_eq!(changed_samples.levels, Mips::new(&original).levels);
+        assert!(Arc::ptr_eq(&first, &cached(&cache, &saved)));
+        // With no other strong source owner, make_mut must also invalidate a weak cache key.
+        let mut sole_owner = tip(5, 3);
+        let old = cached(&cache, &sole_owner);
+        Arc::make_mut(&mut sole_owner.data)[0] = 65535;
+        assert!(!Arc::ptr_eq(&old, &cached(&cache, &sole_owner)));
+    }
+
+    #[test]
+    fn retention_is_lru_bounded_by_both_bytes_and_entry_count() {
+        let (a, b, c) = (tip(4, 4), tip(4, 4), tip(4, 4));
+        let bytes = 16 * size_of::<u16>() + (16 + 4 + 1) * size_of::<f32>();
+        let cache = Mutex::new(MipCache::new(2 * bytes, 64));
+        let ma = cached(&cache, &a);
+        let mb = cached(&cache, &b);
+        assert!(Arc::ptr_eq(&ma, &cached(&cache, &a)));
+        cached(&cache, &c);
+        assert!(cache.lock().unwrap().get(&b).is_none());
+        assert!(Arc::ptr_eq(&ma, &cached(&cache, &a)));
+        assert_eq!(cache.lock().unwrap().bytes, 2 * bytes);
+        assert_eq!(mb.levels, Mips::new(&b).levels, "eviction leaves active strokes valid");
+        let cache = Mutex::new(MipCache::new(MAX_BYTES, 1));
+        cached(&cache, &a);
+        cached(&cache, &b);
+        assert_eq!(cache.lock().unwrap().entries.len(), 1);
+        assert!(cache.lock().unwrap().get(&a).is_none());
+    }
+
+    #[test]
+    fn dead_sources_are_pruned_and_oversized_or_invalid_tips_bypass_the_cache() {
+        let cache = Mutex::new(MipCache::new(116, 8));
+        let small = tip(4, 4);
+        let weak = Arc::downgrade(&small.data);
+        cached(&cache, &small);
+        drop(small);
+        assert!(weak.upgrade().is_none());
+        let live = tip(4, 4);
+        let kept = cached(&cache, &live);
+        assert_eq!(cache.lock().unwrap().entries.len(), 1);
+        let large = tip(8, 8);
+        let uncached = cached(&cache, &large);
+        assert!(!Arc::ptr_eq(&uncached, &cached(&cache, &large)));
+        assert!(Arc::ptr_eq(&kept, &cached(&cache, &live)));
+        assert_eq!(cache.lock().unwrap().bytes, 116);
+        let invalid = GrayTile { width: u32::MAX, height: u32::MAX, data: vec![1].into() };
+        assert_eq!(cached(&cache, &invalid).levels, Mips::new(&invalid).levels);
+        assert_eq!(cache.lock().unwrap().entries.len(), 1);
+    }
+
+    #[test]
+    fn purging_releases_retention_without_invalidating_an_active_stroke() {
+        let cache = Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES));
+        let tip = tip(8, 4);
+        let active = cached(&cache, &tip);
+        assert!(cache.lock().unwrap().purge() > 0);
+        assert_eq!(cache.lock().unwrap().bytes, 0);
+        assert!(cache.lock().unwrap().entries.is_empty());
+        let next = cached(&cache, &tip);
+        assert!(!Arc::ptr_eq(&active, &next));
+        assert_eq!(active.levels, next.levels);
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    #[test]
+    fn a_poisoned_cache_remains_usable() {
+        let cache = Arc::new(Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES)));
+        let other = Arc::clone(&cache);
+        assert!(
+            std::thread::spawn(move || {
+                let _guard = other.lock().unwrap();
+                panic!("test cache poison recovery");
+            })
+            .join()
+            .is_err()
+        );
+        let tip = tip(4, 4);
+        let first = cached(&cache, &tip);
+        assert!(Arc::ptr_eq(&first, &cached(&cache, &tip)));
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    #[test]
+    fn simultaneous_callers_publish_and_reuse_one_pyramid() {
+        let cache = Arc::new(Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES)));
+        let barrier = Arc::new(std::sync::Barrier::new(4));
+        let tip = tip(257, 129);
+        let workers: Vec<_> = (0..4)
+            .map(|_| {
+                let (cache, barrier, tip) = (Arc::clone(&cache), Arc::clone(&barrier), tip.clone());
+                std::thread::spawn(move || {
+                    barrier.wait();
+                    cached(&cache, &tip)
+                })
+            })
+            .collect();
+        let results: Vec<_> = workers.into_iter().map(|w| w.join().unwrap()).collect();
+        assert!(results.iter().all(|m| Arc::ptr_eq(m, &results[0])));
+        assert_eq!(cache.lock().unwrap().entries.len(), 1);
+    }
+}
diff --git a/crates/paint/src/mip_cache_render_tests.rs b/crates/paint/src/mip_cache_render_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/paint/src/mip_cache_render_tests.rs
@@ -0,0 +1,107 @@
+//! Exercise the production cache through brush contexts and native-depth stroke rendering.
+use super::*;
+use crate::brush::{DualBrush, Smoothing};
+use crate::tile::{GrayTile, StoredTile};
+use photocraft_geom::Point;
+
+#[test]
+fn contexts_share_primary_and_dual_mips_across_settings_and_symmetry_passes() {
+    let tip = GrayTile::from_fn(31, 17, |x, y| ((x + y) % 5) as f32 / 4.0);
+    let brush = BrushSettings {
+        tip: TipShape::Sampled(tip.clone()),
+        dual_brush: DualBrush { enabled: true, tip: TipShape::Sampled(tip), ..Default::default() },
+        ..Default::default()
+    };
+    let ctx = BrushContext::new(&brush);
+    assert!(Arc::ptr_eq(ctx.tip.as_ref().unwrap(), ctx.dual_tip.as_ref().unwrap()));
+    // Symmetry constructs a renderer per pass; later strokes can change size, angle and flow.
+    for pass in 0..4 {
+        let changed = BrushSettings { size: 10.0 + pass as f32 * 11.0, angle: pass as f32 * 0.3, flow: 0.2, ..brush.clone() };
+        let renderer = StrokeRenderer::new(&changed, Some(PixelFormat::RGBA8), 1.0);
+        assert!(Arc::ptr_eq(ctx.tip.as_ref().unwrap(), renderer.ctx.tip.as_ref().unwrap()));
+        assert!(Arc::ptr_eq(ctx.dual_tip.as_ref().unwrap(), renderer.ctx.dual_tip.as_ref().unwrap()));
+    }
+    assert!(BrushContext::new(&BrushSettings::default()).tip.is_none());
+}
+
+#[test]
+fn loading_or_replacing_a_stored_tip_never_reuses_its_preview_mips() {
+    let preview = GrayTile::from_fn(8, 8, |_, _| 0.1);
+    let stored = StoredTile { key: "unchanged-key".into(), width: 32, height: 32, preview: Arc::new(preview), full: None };
+    let mut brush = BrushSettings { tip: TipShape::Stored(stored.clone()), ..Default::default() };
+    let before = BrushContext::new(&brush);
+    let full = GrayTile::from_fn(32, 32, |_, _| 0.9);
+    brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(full.clone())), ..stored.clone() });
+    let loaded = BrushContext::new(&brush);
+    assert!(!Arc::ptr_eq(before.tip.as_ref().unwrap(), loaded.tip.as_ref().unwrap()));
+    assert_eq!(loaded.tip.as_ref().unwrap().levels, Mips::new(&full).levels);
+    let replacement = GrayTile::from_fn(32, 32, |_, _| 0.3);
+    brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(replacement.clone())), ..stored });
+    let replaced = BrushContext::new(&brush);
+    assert!(!Arc::ptr_eq(loaded.tip.as_ref().unwrap(), replaced.tip.as_ref().unwrap()));
+    assert_eq!(replaced.tip.as_ref().unwrap().levels, Mips::new(&replacement).levels);
+}
+
+#[test]
+fn cached_and_uncached_strokes_match_exactly_at_native_depths_and_modes() {
+    let tip = GrayTile::from_fn(31, 17, |x, y| ((x * 13 + y * 7) % 31) as f32 / 30.0);
+    let brush = BrushSettings {
+        tip: TipShape::Sampled(tip.clone()),
+        dual_brush: DualBrush { enabled: true, size: 19.0, tip: TipShape::Sampled(tip.clone()), ..Default::default() },
+        size: 27.0,
+        angle: 0.4,
+        roundness: 0.7,
+        flow: 0.5,
+        opacity: 0.7,
+        wet_edges: true,
+        noise: true,
+        smoothing: Smoothing { amount: 0.49, ..Default::default() },
+        seed: 42,
+        ..Default::default()
+    };
+    let bounds = Rect::new(-32, -32, 96, 96);
+    let selection = Surface::with_default(PixelFormat::GRAY8, &[0.6]);
+    for fmt in [PixelFormat::RGBA8, PixelFormat::GRAY8, PixelFormat::CMYKA8] {
+        for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
+            let fmt = fmt.with_sample(sample);
+            let mut pre = Surface::new(fmt);
+            pre.fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, [0.3, 0.6, 0.8, 0.7]));
+            let render = |uncached: bool| {
+                let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0);
+                if uncached {
+                    r.ctx.tip = Some(Arc::new(Mips::new(&tip)));
+                    r.ctx.dual_tip = Some(Arc::new(Mips::new(&tip)));
+                }
+                let mut target = pre.clone();
+                for i in 0..8 {
+                    r.push(&[StrokePoint::new(-3.0 + f64::from(i) * 8.0, 24.0 + f64::from(i % 3) * 4.0, 0.4 + i as f32 * 0.07)]);
+                    r.composite(&pre, &mut target, Some(&selection), false, false);
+                    if let Some(mut tail) = r.tail_preview() {
+                        tail.composite(&pre, &mut target, Some(&selection), false, false);
+                    }
+                }
+                r.finish();
+                r.composite(&pre, &mut target, Some(&selection), false, true);
+                (r.bounds(), target.read_region(bounds))
+            };
+            let cold = render(false);
+            let uncached = render(true);
+            let warm = render(false);
+            assert_eq!(uncached.0, cold.0);
+            assert_eq!(cold.0, warm.0);
+            assert!(uncached.1.iter().zip(&cold.1).all(|(a, b)| a.to_bits() == b.to_bits()), "cold {fmt:?}");
+            assert!(cold.1.iter().zip(&warm.1).all(|(a, b)| a.to_bits() == b.to_bits()), "warm {fmt:?}");
+            assert_ne!(cold.1, pre.read_region(bounds), "the regression must paint pixels");
+        }
+    }
+    // A public-context caller still gets the identical sampled coverage at sub-pixel centres.
+    let ctx = BrushContext::new(&brush);
+    let dab = Dab::round(Point::new(31.25, 12.75), 9.5, 0.8);
+    let mut cached = Vec::new();
+    ctx.rasterize(&dab, false, ctx.dab_rect(&dab, false), &mut cached);
+    let mut reference = ctx.clone();
+    reference.tip = Some(Arc::new(Mips::new(&tip)));
+    let mut uncached = Vec::new();
+    reference.rasterize(&dab, false, reference.dab_rect(&dab, false), &mut uncached);
+    assert!(cached.iter().zip(&uncached).all(|(a, b)| a.to_bits() == b.to_bits()));
+}
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
@@ -233,7 +233,7 @@ impl BrushContext {
     pub fn new(brush: &BrushSettings) -> Self {
         let brush = brush.bounded_for_render();
         let mips = |t: &TipShape| match t.bitmap() {
-            Some(g) if g.is_valid() => Some(Arc::new(Mips::new(g))),
+            Some(g) if g.is_valid() => Some(crate::mip_cache::tip_mips(g)),
             _ => None,
         };
         Self {
@@ -1294,3 +1294,7 @@ impl RoundTip {
 
 #[cfg(test)]
 mod reference;
+
+#[cfg(test)]
+#[path = "mip_cache_render_tests.rs"]
+mod mip_cache_tests;
