vello_cpu: Add `ImageResolver` for resolving opaque image IDs at rasterization time
diff --git a/sparse_strips/vello_bench/src/fine/fill.rs b/sparse_strips/vello_bench/src/fine/fill.rs
index e2d6d9c..e462349 100644
--- a/sparse_strips/vello_bench/src/fine/fill.rs
+++ b/sparse_strips/vello_bench/src/fine/fill.rs
@@ -6,7 +6,7 @@
 use vello_common::color::palette::css::ROYAL_BLUE;
 use vello_common::encode::EncodedPaint;
 use vello_common::fearless_simd::Simd;
-use vello_common::paint::{Paint, PremulColor};
+use vello_common::paint::{NoOpImageResolver, Paint, PremulColor};
 use vello_common::peniko::BlendMode;
 use vello_cpu::fine::{Fine, FineKernel};
 use vello_dev_macros::vello_bench;
@@ -59,7 +59,16 @@
     fine: &mut Fine<S, N>,
 ) {
     b.iter(|| {
-        fine.fill(0, width, paint, blend_mode, encoded_paints, None, None);
+        fine.fill(
+            0,
+            width,
+            paint,
+            blend_mode,
+            encoded_paints,
+            &NoOpImageResolver,
+            None,
+            None,
+        );
 
         std::hint::black_box(&fine);
     });
diff --git a/sparse_strips/vello_bench/src/fine/strip.rs b/sparse_strips/vello_bench/src/fine/strip.rs
index cbf04b4..43f4ec1 100644
--- a/sparse_strips/vello_bench/src/fine/strip.rs
+++ b/sparse_strips/vello_bench/src/fine/strip.rs
@@ -10,7 +10,7 @@
 use vello_common::color::palette::css::ROYAL_BLUE;
 use vello_common::encode::EncodedPaint;
 use vello_common::fearless_simd::Simd;
-use vello_common::paint::{Paint, PremulColor};
+use vello_common::paint::{NoOpImageResolver, Paint, PremulColor};
 use vello_common::tile::Tile;
 use vello_cpu::fine::{Fine, FineKernel};
 use vello_dev_macros::vello_bench;
@@ -57,6 +57,7 @@
             paint,
             default_blend(),
             encoded_paints,
+            &NoOpImageResolver,
             Some(&alphas),
             None,
         );
diff --git a/sparse_strips/vello_common/src/paint.rs b/sparse_strips/vello_common/src/paint.rs
index 4f4c0ed..04e1083 100644
--- a/sparse_strips/vello_common/src/paint.rs
+++ b/sparse_strips/vello_common/src/paint.rs
@@ -139,6 +139,29 @@
 /// An image.
 pub type Image = peniko::ImageBrush<ImageSource>;
 
+/// Trait for resolving opaque image IDs to pixmaps at rasterization time.
+///
+/// This allows delaying the resolution of `ImageSource::OpaqueId` until the
+/// 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 {
+    /// Resolve an `ImageId` to its pixmap data.
+    ///
+    /// Returns `None` if the image ID is not found in the registry.
+    fn resolve(&self, id: ImageId) -> Option<Arc<Pixmap>>;
+}
+
+/// A no-op image resolver that always returns `None`.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct NoOpImageResolver;
+
+impl ImageResolver for NoOpImageResolver {
+    fn resolve(&self, _id: ImageId) -> Option<Arc<Pixmap>> {
+        None
+    }
+}
+
 /// A premultiplied color.
 #[derive(Debug, Clone, PartialEq, Copy)]
 pub struct PremulColor {
diff --git a/sparse_strips/vello_cpu/src/dispatch/mod.rs b/sparse_strips/vello_cpu/src/dispatch/mod.rs
index 63aa85d..dd7dc94 100644
--- a/sparse_strips/vello_cpu/src/dispatch/mod.rs
+++ b/sparse_strips/vello_cpu/src/dispatch/mod.rs
@@ -13,7 +13,7 @@
 use vello_common::encode::EncodedPaint;
 use vello_common::filter_effects::Filter;
 use vello_common::mask::Mask;
-use vello_common::paint::Paint;
+use vello_common::paint::{ImageResolver, Paint};
 use vello_common::strip::Strip;
 use vello_common::strip_generator::StripStorage;
 
@@ -77,6 +77,7 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     );
     fn composite_at_offset(
         &self,
@@ -89,6 +90,7 @@
         dst_buffer_height: u16,
         render_mode: RenderMode,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     );
     fn strip_storage_mut(&mut self) -> &mut StripStorage;
 }
diff --git a/sparse_strips/vello_cpu/src/dispatch/multi_threaded.rs b/sparse_strips/vello_cpu/src/dispatch/multi_threaded.rs
index c2137be..5826368 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::Paint;
+use vello_common::paint::{ImageResolver, NoOpImageResolver, Paint};
 use vello_common::render_graph::RenderGraph;
 use vello_common::strip::Strip;
 use vello_common::strip_generator::{StripGenerator, StripStorage};
@@ -170,9 +170,10 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         use crate::fine::F32Kernel;
-        dispatch!(self.level, simd => self.rasterize_with::<_, F32Kernel>(simd, buffer, width, height, encoded_paints));
+        dispatch!(self.level, simd => self.rasterize_with::<_, F32Kernel>(simd, buffer, width, height, encoded_paints, image_resolver));
     }
 
     #[cfg(feature = "u8_pipeline")]
@@ -182,9 +183,10 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         use crate::fine::U8Kernel;
-        dispatch!(self.level, simd => self.rasterize_with::<_, U8Kernel>(simd, buffer, width, height, encoded_paints));
+        dispatch!(self.level, simd => self.rasterize_with::<_, U8Kernel>(simd, buffer, width, height, encoded_paints, image_resolver));
     }
 
     fn init(&mut self) {
@@ -379,7 +381,12 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        _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;
@@ -408,7 +415,7 @@
                     let alphas = thread_idx
                         .map(|i| alpha_slots[i as usize].as_slice())
                         .unwrap_or(&[]);
-                    fine.run_cmd(cmd, alphas, encoded_paints, &wide.attrs);
+                    fine.run_cmd(cmd, alphas, encoded_paints, &noop_resolver, &wide.attrs);
                 }
 
                 fine.pack(region);
@@ -578,6 +585,7 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         assert!(self.flushed, "attempted to rasterize before flushing");
 
@@ -585,21 +593,23 @@
         #[cfg(all(feature = "u8_pipeline", not(feature = "f32_pipeline")))]
         {
             let _ = render_mode;
-            self.rasterize_u8(buffer, width, height, encoded_paints);
+            self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver);
         }
         // Only f32 pipeline enabled
         #[cfg(all(feature = "f32_pipeline", not(feature = "u8_pipeline")))]
         {
             let _ = render_mode;
-            self.rasterize_f32(buffer, width, height, encoded_paints);
+            self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver);
         }
 
         // Both pipelines enabled
         #[cfg(all(feature = "f32_pipeline", feature = "u8_pipeline"))]
         match render_mode {
-            RenderMode::OptimizeSpeed => self.rasterize_u8(buffer, width, height, encoded_paints),
+            RenderMode::OptimizeSpeed => {
+                self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver);
+            }
             RenderMode::OptimizeQuality => {
-                self.rasterize_f32(buffer, width, height, encoded_paints);
+                self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver);
             }
         }
     }
@@ -615,6 +625,7 @@
         _dst_buffer_height: u16,
         _render_mode: RenderMode,
         _encoded_paints: &[EncodedPaint],
+        _image_resolver: &dyn ImageResolver,
     ) {
         // TODO: Implement composite_at_offset for multi-threaded dispatcher.
         unimplemented!("composite_at_offset is not implemented for multi-threaded dispatcher");
diff --git a/sparse_strips/vello_cpu/src/dispatch/single_threaded.rs b/sparse_strips/vello_cpu/src/dispatch/single_threaded.rs
index 1467db2..5eccdba 100644
--- a/sparse_strips/vello_cpu/src/dispatch/single_threaded.rs
+++ b/sparse_strips/vello_cpu/src/dispatch/single_threaded.rs
@@ -15,7 +15,7 @@
 use vello_common::fearless_simd::{Level, Simd};
 use vello_common::filter_effects::Filter;
 use vello_common::mask::Mask;
-use vello_common::paint::{Paint, PremulColor};
+use vello_common::paint::{ImageResolver, Paint, PremulColor};
 use vello_common::pixmap::Pixmap;
 use vello_common::render_graph::{RenderGraph, RenderNodeKind};
 use vello_common::strip::Strip;
@@ -94,10 +94,11 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         use crate::fine::F32Kernel;
         use vello_common::fearless_simd::dispatch;
-        dispatch!(self.level, simd => self.rasterize_with::<_, F32Kernel>(simd, buffer, width, height, encoded_paints));
+        dispatch!(self.level, simd => self.rasterize_with::<_, F32Kernel>(simd, buffer, width, height, encoded_paints, image_resolver));
     }
 
     /// Rasterizes the scene using u8 precision (fast).
@@ -111,10 +112,11 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         use crate::fine::U8Kernel;
         use vello_common::fearless_simd::dispatch;
-        dispatch!(self.level, simd => self.rasterize_with::<_, U8Kernel>(simd, buffer, width, height, encoded_paints));
+        dispatch!(self.level, simd => self.rasterize_with::<_, U8Kernel>(simd, buffer, width, height, encoded_paints, image_resolver));
     }
 
     /// Core rasterization dispatcher that chooses between simple and filter-aware paths.
@@ -132,6 +134,7 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         let mut layer_manager = LayerManager::new();
 
@@ -143,11 +146,19 @@
                 width,
                 height,
                 encoded_paints,
+                image_resolver,
                 &mut layer_manager,
             );
         } else {
             // Use simple direct rasterization for scenes without filters.
-            self.rasterize_simple::<S, F>(simd, buffer, width, height, encoded_paints);
+            self.rasterize_simple::<S, F>(
+                simd,
+                buffer,
+                width,
+                height,
+                encoded_paints,
+                image_resolver,
+            );
         }
     }
 
@@ -168,6 +179,7 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
         layer_manager: &mut LayerManager,
     ) {
         let mut fine = Fine::<S, F>::new(simd);
@@ -206,6 +218,7 @@
                             PremulColor::from_alpha_color(TRANSPARENT),
                             layer_manager,
                             encoded_paints,
+                            image_resolver,
                         );
 
                         debug_assert_eq!(
@@ -244,6 +257,7 @@
                             bg,
                             layer_manager,
                             encoded_paints,
+                            image_resolver,
                         );
 
                         debug_assert_eq!(
@@ -274,6 +288,7 @@
     /// * `clear_color` - Initial color for the tile.
     /// * `layer_manager` - Storage for filtered layer buffers.
     /// * `encoded_paints` - Paint definitions for the scene.
+    /// * `image_resolver` - Resolver for looking up opaque image IDs.
     fn process_layer_tile<S: Simd, F: FineKernel<S>>(
         &self,
         fine: &mut Fine<S, F>,
@@ -283,6 +298,7 @@
         clear_color: PremulColor,
         layer_manager: &mut LayerManager,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         let wtile = &self.wide.get(x, y);
         fine.set_coords(x, y);
@@ -300,6 +316,7 @@
                 cmd,
                 &self.strip_storage.alphas,
                 encoded_paints,
+                image_resolver,
                 &self.wide.attrs,
             );
 
@@ -330,6 +347,7 @@
                             &wtile.cmds[cmd_idx + 1],
                             &self.strip_storage.alphas,
                             encoded_paints,
+                            image_resolver,
                             &self.wide.attrs,
                         );
                         cmd_idx += 1;
@@ -372,6 +390,7 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         let mut regions = Regions::new(width, height, buffer);
         let mut fine = Fine::<S, F>::new(simd);
@@ -390,6 +409,7 @@
                     cmd,
                     &self.strip_storage.alphas,
                     encoded_paints,
+                    image_resolver,
                     &self.wide.attrs,
                 );
             }
@@ -415,11 +435,12 @@
         dst_buffer_width: u16,
         dst_buffer_height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         use crate::fine::F32Kernel;
         use vello_common::fearless_simd::dispatch;
         dispatch!(self.level, simd => self.composite_at_offset_with::<_, F32Kernel>(
-            simd, buffer, width, height, dst_x, dst_y, dst_buffer_width, dst_buffer_height, encoded_paints
+            simd, buffer, width, height, dst_x, dst_y, dst_buffer_width, dst_buffer_height, encoded_paints, image_resolver
         ));
     }
 
@@ -435,11 +456,12 @@
         dst_buffer_width: u16,
         dst_buffer_height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         use crate::fine::U8Kernel;
         use vello_common::fearless_simd::dispatch;
         dispatch!(self.level, simd => self.composite_at_offset_with::<_, U8Kernel>(
-            simd, buffer, width, height, dst_x, dst_y, dst_buffer_width, dst_buffer_height, encoded_paints
+            simd, buffer, width, height, dst_x, dst_y, dst_buffer_width, dst_buffer_height, encoded_paints, image_resolver
         ));
     }
 
@@ -458,6 +480,7 @@
         dst_buffer_width: u16,
         dst_buffer_height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         let mut regions = Regions::new_at_offset(
             width,
@@ -485,6 +508,7 @@
                     cmd,
                     &self.strip_storage.alphas,
                     encoded_paints,
+                    image_resolver,
                     &self.wide.attrs,
                 );
             }
@@ -651,19 +675,20 @@
         width: u16,
         height: u16,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         // If only the u8 pipeline is enabled, then use it
         #[cfg(all(feature = "u8_pipeline", not(feature = "f32_pipeline")))]
         {
             let _ = render_mode;
-            self.rasterize_u8(buffer, width, height, encoded_paints);
+            self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver);
         }
 
         // If only the f32 pipeline is enabled, then use it
         #[cfg(all(feature = "f32_pipeline", not(feature = "u8_pipeline")))]
         {
             let _ = render_mode;
-            self.rasterize_f32(buffer, width, height, encoded_paints);
+            self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver);
         }
 
         // If both pipelines are enabled, select precision based on render mode parameter.
@@ -671,11 +696,11 @@
         match render_mode {
             RenderMode::OptimizeSpeed => {
                 // Use u8 precision for faster rendering.
-                self.rasterize_u8(buffer, width, height, encoded_paints);
+                self.rasterize_u8(buffer, width, height, encoded_paints, image_resolver);
             }
             RenderMode::OptimizeQuality => {
                 // Use f32 precision for higher quality.
-                self.rasterize_f32(buffer, width, height, encoded_paints);
+                self.rasterize_f32(buffer, width, height, encoded_paints, image_resolver);
             }
         }
 
@@ -683,7 +708,14 @@
         {
             // This case never gets hit because there is a compile_error in the root.
             // But have this code disables some warnings and makes the compile error easier to read
-            let _ = (buffer, render_mode, width, height, encoded_paints);
+            let _ = (
+                buffer,
+                render_mode,
+                width,
+                height,
+                encoded_paints,
+                image_resolver,
+            );
         }
     }
 
@@ -698,6 +730,7 @@
         dst_buffer_height: u16,
         render_mode: RenderMode,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
     ) {
         #[cfg(all(feature = "u8_pipeline", not(feature = "f32_pipeline")))]
         {
@@ -711,6 +744,7 @@
                 dst_buffer_width,
                 dst_buffer_height,
                 encoded_paints,
+                image_resolver,
             );
         }
 
@@ -726,6 +760,7 @@
                 dst_buffer_width,
                 dst_buffer_height,
                 encoded_paints,
+                image_resolver,
             );
         }
 
@@ -741,6 +776,7 @@
                     dst_buffer_width,
                     dst_buffer_height,
                     encoded_paints,
+                    image_resolver,
                 );
             }
             RenderMode::OptimizeQuality => {
@@ -753,6 +789,7 @@
                     dst_buffer_width,
                     dst_buffer_height,
                     encoded_paints,
+                    image_resolver,
                 );
             }
         }
@@ -769,6 +806,7 @@
                 dst_buffer_height,
                 render_mode,
                 encoded_paints,
+                image_resolver,
             );
         }
     }
diff --git a/sparse_strips/vello_cpu/src/fine/mod.rs b/sparse_strips/vello_cpu/src/fine/mod.rs
index 71c4da3..8ae7cc7 100644
--- a/sparse_strips/vello_cpu/src/fine/mod.rs
+++ b/sparse_strips/vello_cpu/src/fine/mod.rs
@@ -36,7 +36,7 @@
 use vello_common::filter_effects::Filter;
 use vello_common::kurbo::Affine;
 use vello_common::mask::Mask;
-use vello_common::paint::{ImageSource, Paint, PremulColor};
+use vello_common::paint::{ImageResolver, ImageSource, Paint, PremulColor};
 use vello_common::pixmap::Pixmap;
 use vello_common::simd::Splat4thExt;
 use vello_common::tile::Tile;
@@ -536,6 +536,7 @@
         cmd: &Cmd,
         alphas: &[u8],
         paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
         attrs: &CommandAttrs,
     ) {
         match cmd {
@@ -547,6 +548,7 @@
                     &fill_attrs.paint,
                     fill_attrs.blend_mode,
                     paints,
+                    image_resolver,
                     None,
                     fill_attrs.mask.as_ref(),
                 );
@@ -560,6 +562,7 @@
                     &fill_attrs.paint,
                     fill_attrs.blend_mode,
                     paints,
+                    image_resolver,
                     Some(&alphas[alpha_idx..]),
                     fill_attrs.mask.as_ref(),
                 );
@@ -661,6 +664,7 @@
         fill: &Paint,
         blend_mode: BlendMode,
         encoded_paints: &[EncodedPaint],
+        image_resolver: &dyn ImageResolver,
         alphas: Option<&[u8]>,
         mask: Option<&Mask>,
     ) {
@@ -813,8 +817,11 @@
                         }
                     }
                     EncodedPaint::Image(i) => {
-                        let ImageSource::Pixmap(pixmap) = &i.source else {
-                            panic!("vello_cpu doesn't support the opaque image source.");
+                        let pixmap = match &i.source {
+                            ImageSource::Pixmap(p) => p.clone(),
+                            ImageSource::OpaqueId(id) => image_resolver
+                                .resolve(*id)
+                                .unwrap_or_else(|| panic!("Image {:?} not found in registry", id)),
                         };
 
                         match (i.has_skew(), i.nearest_neighbor()) {
@@ -824,14 +831,14 @@
                                     fill_complex_paint!(
                                         i.may_have_opacities,
                                         T::plain_medium_quality_image_painter(
-                                            self.simd, i, pixmap, start_x, start_y
+                                            self.simd, i, &pixmap, start_x, start_y
                                         )
                                     );
                                 } else {
                                     fill_complex_paint!(
                                         i.may_have_opacities,
                                         T::high_quality_image_painter(
-                                            self.simd, i, pixmap, start_x, start_y
+                                            self.simd, i, &pixmap, start_x, start_y
                                         )
                                     );
                                 }
@@ -842,14 +849,14 @@
                                     fill_complex_paint!(
                                         i.may_have_opacities,
                                         T::medium_quality_image_painter(
-                                            self.simd, i, pixmap, start_x, start_y
+                                            self.simd, i, &pixmap, start_x, start_y
                                         )
                                     );
                                 } else {
                                     fill_complex_paint!(
                                         i.may_have_opacities,
                                         T::high_quality_image_painter(
-                                            self.simd, i, pixmap, start_x, start_y
+                                            self.simd, i, &pixmap, start_x, start_y
                                         )
                                     );
                                 }
@@ -858,14 +865,14 @@
                                 fill_complex_paint!(
                                     i.may_have_opacities,
                                     T::plain_nn_image_painter(
-                                        self.simd, i, pixmap, start_x, start_y
+                                        self.simd, i, &pixmap, start_x, start_y
                                     )
                                 );
                             }
                             (true, true) => {
                                 fill_complex_paint!(
                                     i.may_have_opacities,
-                                    T::nn_image_painter(self.simd, i, pixmap, start_x, start_y)
+                                    T::nn_image_painter(self.simd, i, &pixmap, start_x, start_y)
                                 );
                             }
                         }
diff --git a/sparse_strips/vello_cpu/src/lib.rs b/sparse_strips/vello_cpu/src/lib.rs
index 7e5ba41..9ae475a 100644
--- a/sparse_strips/vello_cpu/src/lib.rs
+++ b/sparse_strips/vello_cpu/src/lib.rs
@@ -153,7 +153,7 @@
 #[doc(hidden)]
 pub mod region;
 
-pub use render::{RenderContext, RenderSettings};
+pub use render::{RenderContext, RenderSettings, RenderState};
 pub use vello_common::fearless_simd::Level;
 #[cfg(feature = "text")]
 pub use vello_common::glyph::Glyph;
diff --git a/sparse_strips/vello_cpu/src/render.rs b/sparse_strips/vello_cpu/src/render.rs
index 58f14fa..72d82c8 100644
--- a/sparse_strips/vello_cpu/src/render.rs
+++ b/sparse_strips/vello_cpu/src/render.rs
@@ -11,17 +11,19 @@
 use crate::dispatch::single_threaded::SingleThreadedDispatcher;
 use crate::kurbo::{PathEl, Point};
 use alloc::boxed::Box;
-#[cfg(feature = "text")]
 use alloc::sync::Arc;
 use alloc::vec;
 use alloc::vec::Vec;
+use hashbrown::HashMap;
 use vello_common::blurred_rounded_rect::BlurredRoundedRectangle;
 use vello_common::encode::{EncodeExt, EncodedPaint};
 use vello_common::fearless_simd::Level;
 use vello_common::filter_effects::Filter;
 use vello_common::kurbo::{Affine, BezPath, Cap, Join, Rect, Stroke};
 use vello_common::mask::Mask;
-use vello_common::paint::{Paint, PaintType};
+#[cfg(feature = "text")]
+use vello_common::paint::{Image, ImageSource};
+use vello_common::paint::{ImageId, ImageResolver, Paint, PaintType};
 use vello_common::peniko::color::palette::css::BLACK;
 use vello_common::peniko::{BlendMode, Fill};
 use vello_common::pixmap::Pixmap;
@@ -33,7 +35,6 @@
     color::{AlphaColor, Srgb},
     colr::{ColrPainter, ColrRenderer},
     glyph::{GlyphCaches, GlyphRenderer, GlyphRunBuilder, GlyphType, PreparedGlyph},
-    paint::ImageSource,
 };
 
 /// A render context for CPU-based 2D graphics rendering.
@@ -75,6 +76,13 @@
     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,
 }
 
 /// Settings to apply to the render context.
@@ -170,6 +178,8 @@
             filter: None,
             #[cfg(feature = "text")]
             glyph_caches: Some(GlyphCaches::default()),
+            image_registry: HashMap::new(),
+            next_image_id: 0,
         }
     }
 
@@ -424,6 +434,10 @@
     }
 
     /// Set the current paint.
+    ///
+    /// If the paint is an image with `ImageSource::OpaqueId`, it will be
+    /// resolved to the corresponding pixmap at rasterization time.
+    /// Make sure to register images with [`register_image`](Self::register_image) first.
     pub fn set_paint(&mut self, paint: impl Into<PaintType>) {
         self.paint = paint.into();
     }
@@ -525,6 +539,7 @@
         #[cfg(feature = "text")]
         self.glyph_caches.as_mut().unwrap().maintain();
         self.blend_mode = BlendMode::default();
+        self.clear_images();
     }
 
     /// Push a new clip path to the clip stack.
@@ -578,8 +593,14 @@
             buffer.len(),
         );
 
-        self.dispatcher
-            .rasterize(buffer, render_mode, width, height, &self.encoded_paints);
+        self.dispatcher.rasterize(
+            buffer,
+            render_mode,
+            width,
+            height,
+            &self.encoded_paints,
+            self,
+        );
     }
 
     /// Render the current context into a pixmap.
@@ -624,6 +645,7 @@
             dst_buffer_height,
             self.render_settings.render_mode,
             &self.encoded_paints,
+            self,
         );
     }
 
@@ -655,6 +677,68 @@
             f(self);
         }
     }
+
+    /// Save the current rendering state.
+    pub fn take_current_state(&mut self) -> RenderState {
+        RenderState {
+            paint: self.paint.clone(),
+            paint_transform: self.paint_transform,
+            transform: self.transform,
+            fill_rule: self.fill_rule,
+            stroke: core::mem::take(&mut self.stroke),
+        }
+    }
+
+    /// Restore the saved rendering state.
+    pub fn restore_state(&mut self, state: RenderState) {
+        self.transform = state.transform;
+        self.fill_rule = state.fill_rule;
+        self.stroke = state.stroke;
+        self.paint = state.paint;
+        self.paint_transform = state.paint_transform;
+    }
+}
+
+/// Image registry implementation.
+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);
+    }
+
+    /// Remove an image from the registry.
+    pub fn destroy_image(&mut self, id: ImageId) -> bool {
+        self.image_registry.remove(&id.as_u32()).is_some()
+    }
+
+    /// 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()
+    }
+
+    /// 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()
+    }
 }
 
 #[cfg(feature = "text")]
@@ -692,7 +776,7 @@
                     crate::peniko::ImageQuality::Medium
                 };
 
-                let image = vello_common::paint::Image {
+                let image = Image {
                     image: ImageSource::Pixmap(Arc::new(glyph.pixmap)),
                     sampler: ImageSampler {
                         x_extend: crate::peniko::Extend::Pad,
@@ -747,7 +831,7 @@
                 let has_skew = prepared_glyph.transform.as_coeffs()[1] != 0.0
                     || prepared_glyph.transform.as_coeffs()[2] != 0.0;
 
-                let image = vello_common::paint::Image {
+                let image = Image {
                     image: ImageSource::Pixmap(Arc::new(glyph_pixmap)),
                     sampler: ImageSampler {
                         x_extend: crate::peniko::Extend::Pad,
@@ -954,7 +1038,7 @@
 
 /// Saved state for recording operations.
 #[derive(Debug)]
-struct RenderState {
+pub struct RenderState {
     transform: Affine,
     fill_rule: Fill,
     stroke: Stroke,
@@ -1138,26 +1222,6 @@
             })
             .collect()
     }
-
-    /// Save the current rendering state.
-    fn take_current_state(&mut self) -> RenderState {
-        RenderState {
-            paint: self.paint.clone(),
-            paint_transform: self.paint_transform,
-            transform: self.transform,
-            fill_rule: self.fill_rule,
-            stroke: core::mem::take(&mut self.stroke),
-        }
-    }
-
-    /// Restore the saved rendering state.
-    fn restore_state(&mut self, state: RenderState) {
-        self.transform = state.transform;
-        self.fill_rule = state.fill_rule;
-        self.stroke = state.stroke;
-        self.paint = state.paint;
-        self.paint_transform = state.paint_transform;
-    }
 }
 
 #[cfg(test)]
diff --git a/sparse_strips/vello_sparse_tests/snapshots/image_spritesheet.png b/sparse_strips/vello_sparse_tests/snapshots/image_spritesheet.png
new file mode 100644
index 0000000..cd0fa4e
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/image_spritesheet.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5c939bbb46cd4cfd247c945967f48f064321b06ca3577d774b54d0c0a8e800ec
+size 289
diff --git a/sparse_strips/vello_sparse_tests/tests/assets/glyph_atlas.png b/sparse_strips/vello_sparse_tests/tests/assets/glyph_atlas.png
new file mode 100644
index 0000000..0aba633
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/tests/assets/glyph_atlas.png
Binary files differ
diff --git a/sparse_strips/vello_sparse_tests/tests/image.rs b/sparse_strips/vello_sparse_tests/tests/image.rs
index 33b50f3..d538dcc 100644
--- a/sparse_strips/vello_sparse_tests/tests/image.rs
+++ b/sparse_strips/vello_sparse_tests/tests/image.rs
@@ -562,3 +562,79 @@
     ctx.pop_layer();
     ctx.pop_layer();
 }
+
+/// Parameters for a glyph sprite from an atlas.
+#[derive(Clone, Copy)]
+struct Sprite {
+    /// X position of the sprite within the atlas
+    atlas_x: f64,
+    /// Y position of the sprite within the atlas
+    atlas_y: f64,
+    /// Width of the sprite
+    width: f64,
+    /// Height of the sprite
+    height: f64,
+    /// Vertical offset from baseline (positive = render lower)
+    y_offset: f64,
+}
+
+impl Sprite {
+    const fn new(atlas_x: f64, atlas_y: f64, width: f64, height: f64, y_offset: f64) -> Self {
+        Self {
+            atlas_x,
+            atlas_y,
+            width,
+            height,
+            y_offset,
+        }
+    }
+}
+
+/// Sprites for "hello" from the glyph atlas.
+const HELLO_WORLD: &[Sprite] = &[
+    Sprite::new(1.0, 46.0, 10.0, 14.0, 0.0), // 'h'
+    Sprite::new(68.0, 0.0, 10.0, 12.0, 2.0), // 'e'
+    Sprite::new(27.0, 53.0, 5.0, 14.0, 0.0), // 'l'
+    Sprite::new(27.0, 53.0, 5.0, 14.0, 0.0), // 'l'
+    Sprite::new(80.0, 0.0, 10.0, 12.0, 2.0), // 'o'
+];
+
+/// 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)]
+fn image_spritesheet(ctx: &mut impl Renderer) {
+    let atlas_id = ctx.register_image(load_image!("glyph_atlas"));
+    let atlas_src = ImageSource::OpaqueId(atlas_id);
+
+    let start_x = 10.0;
+    let start_y = 8.0;
+
+    let mut cursor_x = start_x;
+
+    for glyph in HELLO_WORLD {
+        render_sprite(ctx, &atlas_src, glyph, cursor_x, start_y);
+        cursor_x += glyph.width;
+    }
+}
+
+/// Render a sprite from an atlas/spritesheet at a screen position.
+fn render_sprite(
+    ctx: &mut impl Renderer,
+    atlas_src: &ImageSource,
+    glyph: &Sprite,
+    screen_x: f64,
+    screen_y: f64,
+) {
+    ctx.set_transform(Affine::translate((screen_x, screen_y + glyph.y_offset)));
+    ctx.set_paint_transform(Affine::translate((-glyph.atlas_x, -glyph.atlas_y)));
+    ctx.set_paint(Image {
+        image: atlas_src.clone(),
+        sampler: ImageSampler {
+            x_extend: Extend::Pad,
+            y_extend: Extend::Pad,
+            quality: ImageQuality::Low,
+            alpha: 1.0,
+        },
+    });
+    ctx.fill_rect(&Rect::new(0.0, 0.0, glyph.width, glyph.height));
+}
diff --git a/sparse_strips/vello_sparse_tests/tests/renderer.rs b/sparse_strips/vello_sparse_tests/tests/renderer.rs
index 4827b0a..a0ddc2c 100644
--- a/sparse_strips/vello_sparse_tests/tests/renderer.rs
+++ b/sparse_strips/vello_sparse_tests/tests/renderer.rs
@@ -8,7 +8,7 @@
 use vello_common::glyph::{GlyphRenderer, GlyphRunBuilder};
 use vello_common::kurbo::{Affine, BezPath, Rect, Stroke};
 use vello_common::mask::Mask;
-use vello_common::paint::{ImageSource, PaintType};
+use vello_common::paint::{ImageId, ImageSource, PaintType};
 use vello_common::peniko::{BlendMode, Fill, FontData};
 use vello_common::pixmap::Pixmap;
 use vello_common::recording::{Recordable, Recorder, Recording};
@@ -64,6 +64,7 @@
     fn width(&self) -> u16;
     fn height(&self) -> u16;
     fn get_image_source(&mut self, pixmap: Arc<Pixmap>) -> ImageSource;
+    fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId;
     fn record(&mut self, recording: &mut Recording, f: impl FnOnce(&mut Recorder<'_>));
     fn prepare_recording(&mut self, recording: &mut Recording);
     fn execute_recording(&mut self, recording: &Recording);
@@ -215,6 +216,10 @@
         ImageSource::Pixmap(pixmap)
     }
 
+    fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
+        Self::register_image(self, pixmap)
+    }
+
     fn record(&mut self, recording: &mut Recording, f: impl FnOnce(&mut Recorder<'_>)) {
         Recordable::record(self, recording, f);
     }
@@ -550,6 +555,25 @@
         ImageSource::OpaqueId(image_id)
     }
 
+    fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
+        let mut encoder = self
+            .device
+            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+                label: Some("Register Test Image"),
+            });
+
+        let image_id = self.renderer.borrow_mut().upload_image(
+            &self.device,
+            &self.queue,
+            &mut encoder,
+            &pixmap,
+        );
+
+        self.queue.submit([encoder.finish()]);
+
+        image_id
+    }
+
     fn record(&mut self, recording: &mut Recording, f: impl FnOnce(&mut Recorder<'_>)) {
         Recordable::record(&mut self.scene, recording, f);
     }
@@ -772,6 +796,10 @@
         ImageSource::OpaqueId(image_id)
     }
 
+    fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
+        self.renderer.borrow_mut().upload_image(&pixmap)
+    }
+
     fn record(&mut self, recording: &mut Recording, f: impl FnOnce(&mut Recorder<'_>)) {
         self.scene.record(recording, f);
     }