.
diff --git a/sparse_strips/vello_common/src/paint.rs b/sparse_strips/vello_common/src/paint.rs
index 04e1083..682f2c8 100644
--- a/sparse_strips/vello_common/src/paint.rs
+++ b/sparse_strips/vello_common/src/paint.rs
@@ -145,9 +145,12 @@
 /// image is actually needed during rasterization, enabling patterns like
 /// dynamic sprite atlases where the image data may be updated between
 /// encoding and rendering.
-pub trait ImageResolver {
+pub trait ImageResolver: Send + Sync {
     /// Resolve an `ImageId` to its pixmap data.
     ///
+    /// In `vello_cpu`, this is called during fine rasterization for each wide tile
+    /// command that references an `ImageSource::OpaqueId`.
+    ///
     /// Returns `None` if the image ID is not found in the registry.
     fn resolve(&self, id: ImageId) -> Option<Arc<Pixmap>>;
 }
diff --git a/sparse_strips/vello_cpu/src/dispatch/multi_threaded.rs b/sparse_strips/vello_cpu/src/dispatch/multi_threaded.rs
index 5826368..d64bb31 100644
--- a/sparse_strips/vello_cpu/src/dispatch/multi_threaded.rs
+++ b/sparse_strips/vello_cpu/src/dispatch/multi_threaded.rs
@@ -27,7 +27,7 @@
 use vello_common::fearless_simd::{Level, Simd, dispatch};
 use vello_common::filter_effects::Filter;
 use vello_common::mask::Mask;
-use vello_common::paint::{ImageResolver, NoOpImageResolver, Paint};
+use vello_common::paint::{ImageResolver, Paint};
 use vello_common::render_graph::RenderGraph;
 use vello_common::strip::Strip;
 use vello_common::strip_generator::{StripGenerator, StripStorage};
@@ -381,12 +381,8 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
-        _image_resolver: &dyn ImageResolver,
+        image_resolver: &dyn ImageResolver,
     ) {
-        // Note: Multi-threaded dispatcher does not support ImageSource::OpaqueId.
-        // Images with OpaqueId will panic at rasterization time.
-        let noop_resolver = NoOpImageResolver;
-
         let mut buffer = Regions::new(width, height, buffer);
         let fines = ThreadLocal::new();
         let wide = &self.wide;
@@ -415,7 +411,7 @@
                     let alphas = thread_idx
                         .map(|i| alpha_slots[i as usize].as_slice())
                         .unwrap_or(&[]);
-                    fine.run_cmd(cmd, alphas, encoded_paints, &noop_resolver, &wide.attrs);
+                    fine.run_cmd(cmd, alphas, encoded_paints, image_resolver, &wide.attrs);
                 }
 
                 fine.pack(region);
diff --git a/sparse_strips/vello_cpu/src/render.rs b/sparse_strips/vello_cpu/src/render.rs
index 72d82c8..7912801 100644
--- a/sparse_strips/vello_cpu/src/render.rs
+++ b/sparse_strips/vello_cpu/src/render.rs
@@ -76,13 +76,8 @@
     dispatcher: Box<dyn Dispatcher>,
     #[cfg(feature = "text")]
     pub(crate) glyph_caches: Option<GlyphCaches>,
-    /// Image registry for resolving `ImageSource::OpaqueId` to pixmap data.
-    ///
-    /// This allows decoupling render commands from image data, enabling
-    /// patterns like spritesheet rendering.
-    image_registry: HashMap<u32, Arc<Pixmap>>,
-    /// Counter for generating unique image IDs.
-    next_image_id: u32,
+    /// Registry for resolving `ImageSource::OpaqueId` to pixmap data.
+    image_registry: ImageRegistry,
 }
 
 /// Settings to apply to the render context.
@@ -178,8 +173,7 @@
             filter: None,
             #[cfg(feature = "text")]
             glyph_caches: Some(GlyphCaches::default()),
-            image_registry: HashMap::new(),
-            next_image_id: 0,
+            image_registry: ImageRegistry::new(),
         }
     }
 
@@ -599,7 +593,7 @@
             width,
             height,
             &self.encoded_paints,
-            self,
+            &self.image_registry,
         );
     }
 
@@ -645,7 +639,7 @@
             dst_buffer_height,
             self.render_settings.render_mode,
             &self.encoded_paints,
-            self,
+            &self.image_registry,
         );
     }
 
@@ -703,41 +697,22 @@
 impl RenderContext {
     /// Register a pixmap in the image registry and return its [`ImageId`].
     pub fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
-        let id = self.next_image_id;
-        self.next_image_id += 1;
-        self.image_registry.insert(id, pixmap);
-        ImageId::new(id)
-    }
-
-    /// Update an existing image in the registry with new pixmap data.
-    pub fn update_image(&mut self, id: ImageId, pixmap: Arc<Pixmap>) {
-        debug_assert!(
-            self.image_registry.contains_key(&id.as_u32()),
-            "Cannot update unregistered image {id:?}"
-        );
-        self.image_registry.insert(id.as_u32(), pixmap);
+        self.image_registry.register(pixmap)
     }
 
     /// Remove an image from the registry.
     pub fn destroy_image(&mut self, id: ImageId) -> bool {
-        self.image_registry.remove(&id.as_u32()).is_some()
+        self.image_registry.destroy(id)
     }
 
     /// Resolve an `ImageId` to its pixmap data.
     pub fn resolve_image(&self, id: ImageId) -> Option<Arc<Pixmap>> {
-        self.image_registry.get(&id.as_u32()).cloned()
+        self.image_registry.resolve(id)
     }
 
     /// Clear the image registry.
     pub fn clear_images(&mut self) {
         self.image_registry.clear();
-        self.next_image_id = 0;
-    }
-}
-
-impl ImageResolver for RenderContext {
-    fn resolve(&self, id: ImageId) -> Option<Arc<Pixmap>> {
-        self.image_registry.get(&id.as_u32()).cloned()
     }
 }
 
@@ -1036,7 +1011,51 @@
     }
 }
 
-/// Saved state for recording operations.
+/// Registry that maps opaque [`ImageId`]s to [`Pixmap`] data.
+///
+/// Used by [`RenderContext`] to resolve `ImageSource::OpaqueId` at rasterization time.
+#[derive(Debug)]
+struct ImageRegistry {
+    images: HashMap<u32, Arc<Pixmap>>,
+    next_id: u32,
+}
+
+impl ImageRegistry {
+    fn new() -> Self {
+        Self {
+            images: HashMap::new(),
+            next_id: 0,
+        }
+    }
+
+    fn register(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
+        let id = self.next_id;
+        self.next_id += 1;
+        self.images.insert(id, pixmap);
+        ImageId::new(id)
+    }
+
+    fn destroy(&mut self, id: ImageId) -> bool {
+        self.images.remove(&id.as_u32()).is_some()
+    }
+
+    fn resolve(&self, id: ImageId) -> Option<Arc<Pixmap>> {
+        self.images.get(&id.as_u32()).cloned()
+    }
+
+    fn clear(&mut self) {
+        self.images.clear();
+        self.next_id = 0;
+    }
+}
+
+impl ImageResolver for ImageRegistry {
+    fn resolve(&self, id: ImageId) -> Option<Arc<Pixmap>> {
+        self.images.get(&id.as_u32()).cloned()
+    }
+}
+
+/// Saved state for recording operations.¬
 #[derive(Debug)]
 pub struct RenderState {
     transform: Affine,
diff --git a/sparse_strips/vello_sparse_tests/tests/image.rs b/sparse_strips/vello_sparse_tests/tests/image.rs
index d538dcc..eb81bfe 100644
--- a/sparse_strips/vello_sparse_tests/tests/image.rs
+++ b/sparse_strips/vello_sparse_tests/tests/image.rs
@@ -601,7 +601,7 @@
 
 /// Test rendering "hello" from a glyph atlas (spritesheet-style).
 /// Uses `ImageSource::OpaqueId` to demonstrate the image registry pattern.
-#[vello_test(width = 60, height = 30, skip_hybrid, skip_multithreaded)]
+#[vello_test(width = 60, height = 30, skip_hybrid)]
 fn image_spritesheet(ctx: &mut impl Renderer) {
     let atlas_id = ctx.register_image(load_image!("glyph_atlas"));
     let atlas_src = ImageSource::OpaqueId(atlas_id);