vello_hybrid: Replace the 2D texture array with individual atlas textures
diff --git a/sparse_strips/vello_hybrid/src/draw.rs b/sparse_strips/vello_hybrid/src/draw.rs
index 96431d4..cd72526 100644
--- a/sparse_strips/vello_hybrid/src/draw.rs
+++ b/sparse_strips/vello_hybrid/src/draw.rs
@@ -6,13 +6,13 @@
 use crate::GpuStrip;
 use crate::paint::{
     COLOR_SOURCE_LAYER, COLOR_SOURCE_SHIFT, EXTERNAL_TEXTURE_SLOT_SHIFT, PaintResolver,
+    TextureSourceId,
 };
 use crate::rect::{RectPart, split_rect};
 use crate::scene::{RecordedDraw, RecordedPath};
 use crate::target::{DrawTarget, LayerTextureRegion};
 use crate::util::{Ranges, VecExt, pack_opacity, pack_u16_pair};
 use alloc::vec::Vec;
-use vello_common::TextureId;
 use vello_common::geometry::RectU16;
 use vello_common::kurbo::Rect;
 use vello_common::paint::Paint;
@@ -27,7 +27,7 @@
 pub(crate) struct Draw {
     /// Ranges selecting this draw's strips from [`DrawBuffers::strips`].
     pub(crate) strip_ranges: Ranges,
-    /// Runs that require an externally supplied texture binding.
+    /// Runs that require external texture bindings.
     pub(crate) external_texture_runs: Vec<ExternalTextureRun>,
     /// Whether any strip in this draw samples a child layer.
     pub(crate) has_child_layer: bool,
@@ -39,12 +39,12 @@
         &mut self,
         strips: &mut Vec<GpuStrip>,
         mut gpu_strip: GpuStrip,
-        external_texture_id: Option<TextureId>,
+        texture_source: Option<TextureSourceId>,
     ) {
         if let Some(slot) = assign_external_texture_slot(
             &mut self.external_texture_runs,
             self.strip_ranges.len(),
-            external_texture_id,
+            texture_source,
         ) {
             gpu_strip.paint_and_rect_flag |= u32::from(slot) << EXTERNAL_TEXTURE_SLOT_SHIFT;
         }
@@ -69,11 +69,11 @@
 }
 
 impl OpaqueDraw {
-    fn push(&mut self, mut strip: GpuStrip, external_texture_id: Option<TextureId>) {
+    fn push(&mut self, mut strip: GpuStrip, texture_source: Option<TextureSourceId>) {
         if let Some(slot) = assign_external_texture_slot(
             &mut self.external_texture_runs,
             self.strips.len(),
-            external_texture_id,
+            texture_source,
         ) {
             strip.paint_and_rect_flag |= u32::from(slot) << EXTERNAL_TEXTURE_SLOT_SHIFT;
         }
@@ -156,12 +156,12 @@
         }
     }
 
-    fn push_opaque(&mut self, strip: GpuStrip, external_texture_id: Option<TextureId>) -> bool {
+    fn push_opaque(&mut self, strip: GpuStrip, texture_source: Option<TextureSourceId>) -> bool {
         if !self.state.use_depth_buffer || !self.state.target.enable_depth() {
             return false;
         }
 
-        self.opaque.push(strip, external_texture_id);
+        self.opaque.push(strip, texture_source);
         true
     }
 
@@ -202,7 +202,7 @@
 
                 builder
                     .draw
-                    .push(builder.strips, strip, paint.external_texture_id);
+                    .push(builder.strips, strip, paint.texture_source);
             },
             |builder, segment| {
                 let shifted = segment.shift(geometry_shift);
@@ -216,10 +216,10 @@
                     depth_index,
                 );
 
-                if !paint.opaque || !builder.push_opaque(strip, paint.external_texture_id) {
+                if !paint.opaque || !builder.push_opaque(strip, paint.texture_source) {
                     builder
                         .draw
-                        .push(builder.strips, strip, paint.external_texture_id);
+                        .push(builder.strips, strip, paint.texture_source);
                 }
             },
         );
@@ -261,12 +261,8 @@
                 depth_index,
             );
 
-            if !(paint.opaque
-                && part.frac == 0
-                && self.push_opaque(strip, paint.external_texture_id))
-            {
-                self.draw
-                    .push(self.strips, strip, paint.external_texture_id);
+            if !(paint.opaque && part.frac == 0 && self.push_opaque(strip, paint.texture_source)) {
+                self.draw.push(self.strips, strip, paint.texture_source);
             }
         }
     }
@@ -450,31 +446,33 @@
     }
 }
 
-/// Number of externally supplied textures that can be sampled by one strip draw.
+/// Number of external textures that can be sampled by one strip draw.
 pub(crate) const EXTERNAL_TEXTURE_SLOT_COUNT: usize = 4;
 
 /// External texture bindings for one strip draw.
 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
 pub(crate) struct ExternalTextureBindings {
-    texture_ids: [Option<TextureId>; EXTERNAL_TEXTURE_SLOT_COUNT],
+    texture_sources: [Option<TextureSourceId>; EXTERNAL_TEXTURE_SLOT_COUNT],
 }
 
 impl ExternalTextureBindings {
     pub(crate) const EMPTY: Self = Self {
-        texture_ids: [None; EXTERNAL_TEXTURE_SLOT_COUNT],
+        texture_sources: [None; EXTERNAL_TEXTURE_SLOT_COUNT],
     };
 
-    /// Return the existing slot for `texture_id`, or insert it into the first empty slot.
+    /// Return the existing slot for `texture_source`, or insert it into the first empty slot.
     /// Returns `None` when all four slots are occupied by other textures.
     #[inline]
-    fn get_or_insert(&mut self, texture_id: TextureId) -> Option<u8> {
+    fn get_or_insert(&mut self, texture_source: TextureSourceId) -> Option<u8> {
         // This iteration order assumes slots are assigned without leaving "holes" in-between,
         // i.e. if we hit `None`, any later slot is also guaranteed to be `None`.
-        for (slot, candidate) in self.texture_ids.iter_mut().enumerate() {
+        for (slot, candidate) in self.texture_sources.iter_mut().enumerate() {
             match *candidate {
-                Some(id) if id == texture_id => return Some(u8::try_from(slot).unwrap()),
+                Some(source) if source == texture_source => {
+                    return Some(u8::try_from(slot).unwrap());
+                }
                 None => {
-                    *candidate = Some(texture_id);
+                    *candidate = Some(texture_source);
                     return Some(u8::try_from(slot).unwrap());
                 }
                 _ => {}
@@ -485,8 +483,8 @@
     }
 
     #[inline]
-    pub(crate) fn as_array(self) -> [Option<TextureId>; EXTERNAL_TEXTURE_SLOT_COUNT] {
-        self.texture_ids
+    pub(crate) fn as_array(self) -> [Option<TextureSourceId>; EXTERNAL_TEXTURE_SLOT_COUNT] {
+        self.texture_sources
     }
 }
 
@@ -503,19 +501,19 @@
 fn assign_external_texture_slot(
     runs: &mut Vec<ExternalTextureRun>,
     strips_len: usize,
-    external_texture_id: Option<TextureId>,
+    texture_source: Option<TextureSourceId>,
 ) -> Option<u8> {
-    let texture_id = external_texture_id?;
+    let texture_source = texture_source?;
 
     if let Some(slot) = runs
         .last_mut()
-        .and_then(|run| run.bindings.get_or_insert(texture_id))
+        .and_then(|run| run.bindings.get_or_insert(texture_source))
     {
         return Some(slot);
     }
 
     let mut bindings = ExternalTextureBindings::EMPTY;
-    let slot = bindings.get_or_insert(texture_id).unwrap();
+    let slot = bindings.get_or_insert(texture_source).unwrap();
     let strips_start = if runs.is_empty() { 0 } else { strips_len };
     runs.push(ExternalTextureRun {
         bindings,
@@ -579,7 +577,7 @@
 mod tests {
     use super::{Draw, DrawBuffers, DrawBuilder, DrawState, ExternalTextureRun, OpaqueDraw};
     use crate::GpuStrip;
-    use crate::paint::{EXTERNAL_TEXTURE_SLOT_SHIFT, PaintResolver};
+    use crate::paint::{EXTERNAL_TEXTURE_SLOT_SHIFT, PaintResolver, TextureSourceId};
     use crate::scene::{RecordedDraw, RecordedRect};
     use crate::target::{
         DrawTarget, LayerTextureId, LayerTextureRegion, RootTarget, TextureParity, TextureRegion,
@@ -589,7 +587,9 @@
     use vello_common::TextureId;
     use vello_common::encode::{EncodedImage, EncodedPaint};
     use vello_common::geometry::RectU16;
+    use vello_common::image_cache::ImageCache;
     use vello_common::kurbo::{Affine, Rect, Vec2};
+    use vello_common::multi_atlas::{AtlasConfig, AtlasId};
     use vello_common::paint::{ImageId, ImageSource, IndexedPaint, Paint, PremulColor};
     use vello_common::peniko::color::palette::css::BLUE;
     use vello_common::peniko::{Extend, ImageQuality, ImageSampler};
@@ -676,7 +676,15 @@
             .collect()
     }
 
-    fn run_states(runs: &[ExternalTextureRun]) -> Vec<([Option<TextureId>; 4], usize)> {
+    fn texture_ids<const N: usize>() -> [TextureId; N] {
+        core::array::from_fn(|index| TextureId(index as u64))
+    }
+
+    fn external_sources<const N: usize>() -> [TextureSourceId; N] {
+        texture_ids().map(TextureSourceId::External)
+    }
+
+    fn run_states(runs: &[ExternalTextureRun]) -> Vec<([Option<TextureSourceId>; 4], usize)> {
         runs.iter()
             .map(|run| (run.bindings.as_array(), run.strips_start))
             .collect()
@@ -699,9 +707,9 @@
         })
     }
 
-    fn atlas_image() -> EncodedPaint {
+    fn atlas_image(image_id: ImageId) -> EncodedPaint {
         EncodedPaint::Image(EncodedImage {
-            source: ImageSource::opaque_id(ImageId::new(0)),
+            source: ImageSource::opaque_id(image_id),
             sampler: ImageSampler {
                 x_extend: Extend::Pad,
                 y_extend: Extend::Pad,
@@ -728,8 +736,7 @@
 
     #[test]
     fn texture_runs() {
-        let texture_a = TextureId(10);
-        let texture_b = TextureId(20);
+        let [texture_a, texture_b] = texture_ids();
         let encoded = [external(texture_a), external(texture_b)];
         let offsets = [0, 0];
         let resolver = PaintResolver::new(&encoded, &offsets);
@@ -747,19 +754,21 @@
 
         assert_eq!(
             run_states(&draw.external_texture_runs),
-            [([Some(texture_a), Some(texture_b), None, None], 0)]
+            [(
+                [
+                    Some(TextureSourceId::External(texture_a)),
+                    Some(TextureSourceId::External(texture_b)),
+                    None,
+                    None,
+                ],
+                0,
+            )]
         );
     }
 
     #[test]
     fn fifth_external_texture_starts_a_fresh_run() {
-        let textures = [
-            TextureId(10),
-            TextureId(20),
-            TextureId(30),
-            TextureId(40),
-            TextureId(50),
-        ];
+        let textures: [TextureId; 5] = texture_ids();
         let encoded = textures.map(external);
         let offsets = [0; 5];
         let resolver = PaintResolver::new(&encoded, &offsets);
@@ -780,14 +789,22 @@
             [
                 (
                     [
-                        Some(textures[0]),
-                        Some(textures[1]),
-                        Some(textures[2]),
-                        Some(textures[3])
+                        Some(TextureSourceId::External(textures[0])),
+                        Some(TextureSourceId::External(textures[1])),
+                        Some(TextureSourceId::External(textures[2])),
+                        Some(TextureSourceId::External(textures[3]))
                     ],
                     0
                 ),
-                ([Some(textures[4]), Some(textures[0]), None, None], 4),
+                (
+                    [
+                        Some(TextureSourceId::External(textures[4])),
+                        Some(TextureSourceId::External(textures[0])),
+                        None,
+                        None,
+                    ],
+                    4,
+                ),
             ]
         );
         assert_eq!(
@@ -802,7 +819,7 @@
 
     #[test]
     fn texture_runs_coalesce_distinct_paints_for_same_texture() {
-        let texture = TextureId(10);
+        let [texture] = texture_ids();
         let encoded = [external(texture), external(texture)];
         let resolver = PaintResolver::new(&encoded, &[0, 3]);
         let mut case = DrawCase::new(RootTarget::UserSurface, RectU16::new(0, 0, 8, 8));
@@ -813,15 +830,20 @@
 
         assert_eq!(
             run_states(&draw.external_texture_runs),
-            [([Some(texture), None, None, None], 0)]
+            [(
+                [Some(TextureSourceId::External(texture)), None, None, None],
+                0,
+            )]
         );
     }
 
     #[test]
-    fn texture_runs_collapse_across_atlas_images() {
-        let texture = TextureId(10);
-        let encoded = [external(texture), atlas_image()];
-        let resolver = PaintResolver::new(&encoded, &[0, 0]);
+    fn texture_runs_include_atlas_images() {
+        let [texture] = texture_ids();
+        let mut image_cache = ImageCache::new_with_config(AtlasConfig::default());
+        let image_id = image_cache.allocate(1, 1, 0).unwrap();
+        let encoded = [external(texture), atlas_image(image_id)];
+        let resolver = PaintResolver::new(&encoded, &[0, 0]).with_image_cache(&image_cache);
         let mut case = DrawCase::new(RootTarget::UserSurface, RectU16::new(0, 0, 16, 8));
         let mut draw = Draw::default();
 
@@ -830,26 +852,21 @@
         }
 
         assert_eq!(draw.strip_ranges.len(), 3);
-        // Images in the atlas are handled separately from external textures, so
-        // it's fine to collapse them.
         assert_eq!(
-            run_states(&draw.external_texture_runs),
-            [([Some(texture), None, None, None], 0)]
+            draw.external_texture_runs[0].bindings.as_array(),
+            [
+                Some(TextureSourceId::External(texture)),
+                Some(TextureSourceId::Atlas(AtlasId::new(0))),
+                None,
+                None,
+            ]
         );
+        assert_eq!(draw.external_texture_runs[0].strips_start, 0);
     }
 
     #[test]
     fn opaque_reverse_rebases_texture_runs() {
-        let textures = [
-            TextureId(10),
-            TextureId(20),
-            TextureId(30),
-            TextureId(40),
-            TextureId(50),
-            TextureId(60),
-            TextureId(70),
-            TextureId(80),
-        ];
+        let textures: [TextureSourceId; 8] = external_sources();
         let mut draw = OpaqueDraw::default();
 
         for (x, texture_id) in [
@@ -962,7 +979,7 @@
 
     #[test]
     fn draw_clear() {
-        let texture_id = TextureId(10);
+        let [texture_id] = texture_ids();
         let encoded = [external(texture_id)];
         let offsets = [0];
         let resolver = PaintResolver::new(&encoded, &offsets);
diff --git a/sparse_strips/vello_hybrid/src/lib.rs b/sparse_strips/vello_hybrid/src/lib.rs
index ae2d520..2281b8c 100644
--- a/sparse_strips/vello_hybrid/src/lib.rs
+++ b/sparse_strips/vello_hybrid/src/lib.rs
@@ -128,6 +128,9 @@
     /// A draw referenced a [`TextureId`] that was not provided at render time.
     #[error("Missing texture binding for {0:?}")]
     MissingTextureBinding(TextureId),
+    /// A texture binding aliases the active render target.
+    #[error("Texture binding {0:?} aliases the active render target")]
+    TextureFeedbackLoop(TextureId),
     /// An intermediate texture allocation failed.
     #[error(transparent)]
     IntermediateTexture(#[from] IntermediateTextureError),
diff --git a/sparse_strips/vello_hybrid/src/paint.rs b/sparse_strips/vello_hybrid/src/paint.rs
index 135dc46..69c1a7c 100644
--- a/sparse_strips/vello_hybrid/src/paint.rs
+++ b/sparse_strips/vello_hybrid/src/paint.rs
@@ -6,6 +6,8 @@
 use crate::util::pack_u16_pair;
 use vello_common::TextureId;
 use vello_common::encode::{EncodedKind, EncodedPaint};
+use vello_common::image_cache::ImageCache;
+use vello_common::multi_atlas::AtlasId;
 use vello_common::paint::{ImageSource, Paint};
 
 const COLOR_SOURCE_PAYLOAD: u32 = 0;
@@ -24,6 +26,15 @@
 pub(crate) const EXTERNAL_TEXTURE_SLOT_SHIFT: u32 = 24;
 const PAINT_TEXTURE_INDEX_MASK: u32 = (1 << EXTERNAL_TEXTURE_SLOT_SHIFT) - 1;
 
+/// Texture sampled by an image paint.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) enum TextureSourceId {
+    /// A renderer-owned image atlas.
+    Atlas(AtlasId),
+    /// A texture supplied through the render-time bindings.
+    External(TextureId),
+}
+
 /// Shader-ready paint metadata for a strip.
 #[derive(Clone, Copy)]
 pub(crate) struct PackedPaint {
@@ -31,8 +42,8 @@
     payload: PaintPayload,
     /// Packed paint kind, source, and data offset.
     pub(crate) paint: u32,
-    /// External texture required by this paint, if any.
-    pub(crate) external_texture_id: Option<TextureId>,
+    /// Texture required by this paint, if any.
+    pub(crate) texture_source: Option<TextureSourceId>,
     /// Whether the paint is fully opaque.
     pub(crate) opaque: bool,
 }
@@ -62,6 +73,8 @@
     encoded: &'a [EncodedPaint],
     /// GPU data offset corresponding to each encoded paint.
     gpu_offsets: &'a [u32],
+    /// Resolves internal images to their atlas textures.
+    image_cache: Option<&'a ImageCache>,
 }
 
 impl<'a> PaintResolver<'a> {
@@ -69,9 +82,16 @@
         Self {
             encoded,
             gpu_offsets,
+            image_cache: None,
         }
     }
 
+    /// Add the image cache needed to resolve internal image paints.
+    pub(crate) fn with_image_cache(mut self, image_cache: &'a ImageCache) -> Self {
+        self.image_cache = Some(image_cache);
+        self
+    }
+
     #[inline]
     pub(crate) fn pack(self, paint: &Paint) -> PackedPaint {
         match paint {
@@ -79,7 +99,7 @@
                 payload: PaintPayload::Solid(color.as_premul_rgba8().to_u32()),
                 paint: (COLOR_SOURCE_PAYLOAD << COLOR_SOURCE_SHIFT)
                     | (PAINT_TYPE_SOLID << PAINT_TYPE_SHIFT),
-                external_texture_id: None,
+                texture_source: None,
                 opaque: color.is_opaque(),
             },
             Paint::Indexed(indexed_paint) => {
@@ -87,10 +107,18 @@
                 let gpu_offset = self.gpu_offsets[paint_id];
                 let encoded_paint = &self.encoded[paint_id];
 
-                let (paint_type, external_texture_id) = match encoded_paint {
+                let (paint_type, texture_source) = match encoded_paint {
                     EncodedPaint::Image(encoded_image) => match &encoded_image.source {
-                        ImageSource::ExternalTexture { id, .. } => (PAINT_TYPE_IMAGE, Some(*id)),
-                        ImageSource::OpaqueId { .. } => (PAINT_TYPE_IMAGE, None),
+                        ImageSource::ExternalTexture { id, .. } => {
+                            (PAINT_TYPE_IMAGE, Some(TextureSourceId::External(*id)))
+                        }
+                        ImageSource::OpaqueId { id, .. } => {
+                            let image_resource = self.image_cache.unwrap().get(*id).unwrap();
+                            (
+                                PAINT_TYPE_IMAGE,
+                                Some(TextureSourceId::Atlas(image_resource.atlas_id)),
+                            )
+                        }
                         ImageSource::Pixmap(_) => unimplemented!("Unsupported image source"),
                     },
                     EncodedPaint::Gradient(gradient) => {
@@ -113,7 +141,7 @@
                     paint: (COLOR_SOURCE_PAYLOAD << COLOR_SOURCE_SHIFT)
                         | (paint_type << PAINT_TYPE_SHIFT)
                         | (gpu_offset & PAINT_TEXTURE_INDEX_MASK),
-                    external_texture_id,
+                    texture_source,
                     opaque: !encoded_paint.may_have_transparency(),
                 }
             }
diff --git a/sparse_strips/vello_hybrid/src/render/common.rs b/sparse_strips/vello_hybrid/src/render/common.rs
index 0330850..cacfde2 100644
--- a/sparse_strips/vello_hybrid/src/render/common.rs
+++ b/sparse_strips/vello_hybrid/src/render/common.rs
@@ -37,8 +37,6 @@
 pub(crate) struct DeviceLimits {
     /// Maximum width or height of a two-dimensional texture.
     pub(crate) max_texture_dimension_2d: u16,
-    /// Maximum number of layers in an image atlas texture array.
-    pub(crate) max_texture_array_layers: u16,
 }
 
 impl DeviceLimits {
@@ -119,8 +117,6 @@
             .clamp(1, device_limits.max_texture_dimension_2d)
             .into();
 
-        let supported_max_atlases = usize::from(device_limits.max_texture_array_layers);
-        image_atlas_config.max_atlases = image_atlas_config.max_atlases.min(supported_max_atlases);
         image_atlas_config.initial_atlas_count = image_atlas_config
             .initial_atlas_count
             .min(image_atlas_config.max_atlases);
@@ -198,15 +194,14 @@
     use vello_common::multi_atlas::AtlasConfig;
     use vello_common::record::CommandRecorder;
 
-    fn device_limits(max_texture_dimension_2d: u16, max_texture_array_layers: u16) -> DeviceLimits {
+    fn device_limits(max_texture_dimension_2d: u16) -> DeviceLimits {
         DeviceLimits {
             max_texture_dimension_2d,
-            max_texture_array_layers,
         }
     }
 
     #[test]
-    fn normalize_memory_settings_clamps_atlas_config_to_backend_limits() {
+    fn normalize_memory_settings_clamps_atlas_dimensions_to_backend_limits() {
         let mut settings = MemorySettings {
             image_atlas_config: AtlasConfig {
                 initial_atlas_count: 8,
@@ -217,24 +212,18 @@
             ..Default::default()
         };
 
-        settings.normalize(&device_limits(4096, 4));
+        settings.normalize(&device_limits(4096));
 
         let config = settings.image_atlas_config;
-        assert_eq!(config.initial_atlas_count, 4);
-        assert_eq!(config.max_atlases, 4);
+        assert_eq!(config.initial_atlas_count, 8);
+        assert_eq!(config.max_atlases, 16);
         assert_eq!(config.atlas_size, (4096, 2048));
     }
 
     #[test]
     fn resource_texture_dimension_is_capped_at_4k() {
-        assert_eq!(
-            device_limits(16384, 256).resource_texture_dimension_2d(),
-            4096
-        );
-        assert_eq!(
-            device_limits(2048, 256).resource_texture_dimension_2d(),
-            2048
-        );
+        assert_eq!(device_limits(16384).resource_texture_dimension_2d(), 4096);
+        assert_eq!(device_limits(2048).resource_texture_dimension_2d(), 2048);
     }
 
     #[test]
@@ -248,7 +237,7 @@
             ..Default::default()
         };
 
-        settings.normalize(&device_limits(4096, 8));
+        settings.normalize(&device_limits(4096));
 
         let config = settings.image_atlas_config;
         assert_eq!(config.initial_atlas_count, 0);
@@ -257,23 +246,6 @@
     }
 
     #[test]
-    fn normalize_memory_settings_caps_atlas_count_to_backend_limit() {
-        let mut settings = MemorySettings {
-            image_atlas_config: AtlasConfig {
-                initial_atlas_count: 8,
-                max_atlases: 8,
-                ..Default::default()
-            },
-            ..Default::default()
-        };
-
-        settings.normalize(&device_limits(4096, 1));
-
-        assert_eq!(settings.image_atlas_config.initial_atlas_count, 1);
-        assert_eq!(settings.image_atlas_config.max_atlases, 1);
-    }
-
-    #[test]
     fn normalize_memory_settings_clamps_minimum_to_maximum() {
         let mut settings = MemorySettings {
             layers_config: LayersConfig {
@@ -284,7 +256,7 @@
             ..Default::default()
         };
 
-        settings.normalize(&device_limits(8192, 256));
+        settings.normalize(&device_limits(8192));
 
         assert_eq!(
             settings.layers_config.min_texture_size,
@@ -307,7 +279,7 @@
             ..Default::default()
         };
 
-        settings.normalize(&device_limits(768, 256));
+        settings.normalize(&device_limits(768));
 
         assert_eq!(
             settings.layers_config.min_texture_size,
@@ -494,8 +466,7 @@
 #[derive(Debug, Clone, Copy, Zeroable, Pod)]
 #[allow(dead_code, reason = "Clippy fails when --no-default-features")]
 pub(crate) struct GpuEncodedImage {
-    /// Packed rendering quality, extend modes, and atlas index.
-    /// Bits 6-13: `atlas_index` (8 bits, supports up to 256 atlases)
+    /// Packed rendering quality and extend modes.
     /// Bits 4-5: `extend_y` (2 bits)
     /// Bits 2-3: `extend_x` (2 bits)  
     /// Bits 0-1: `quality` (2 bits)
@@ -643,29 +614,16 @@
     ((x as u32) << 16) | (y as u32)
 }
 
-/// Pack image `quality`, extend modes, `atlas_index`, and source type into a single u32.
-/// `is_external`: stored in bit 14
-/// `atlas_index`: stored in bits 6-13 (8 bits, supports up to 256 atlases)
+/// Pack image `quality` and extend modes into a single u32.
 /// `extend_y`: stored in bits 4-5 (2 bits)
 /// `extend_x`: stored in bits 2-3 (2 bits)
 /// `quality`: stored in bits 0-1 (2 bits)
 #[inline(always)]
-pub(crate) fn pack_image_params(
-    quality: u32,
-    extend_x: u32,
-    extend_y: u32,
-    atlas_index: u32,
-    is_external: bool,
-) -> u32 {
+pub(crate) fn pack_image_params(quality: u32, extend_x: u32, extend_y: u32) -> u32 {
     debug_assert!(extend_x <= 3, "extend_x must be 0-3 (2 bits)");
     debug_assert!(extend_y <= 3, "extend_y must be 0-3 (2 bits)");
     debug_assert!(quality <= 3, "quality must be 0-3 (2 bits)");
-    debug_assert!(atlas_index <= 255, "atlas_index must be 0-255 (8 bits)");
-    (u32::from(is_external) << 14)
-        | (atlas_index << 6)
-        | (extend_y << 4)
-        | (extend_x << 2)
-        | quality
+    (extend_y << 4) | (extend_x << 2) | quality
 }
 
 /// Pack an optional [`Tint`](vello_common::paint::Tint) into a (`tint_color_u32`, `tint_mode_u32`) pair for the GPU.
diff --git a/sparse_strips/vello_hybrid/src/render/webgl/mod.rs b/sparse_strips/vello_hybrid/src/render/webgl/mod.rs
index 13971a0..a720386 100644
--- a/sparse_strips/vello_hybrid/src/render/webgl/mod.rs
+++ b/sparse_strips/vello_hybrid/src/render/webgl/mod.rs
@@ -33,7 +33,7 @@
     copy::GpuCopyInstance,
     filter::{FilterContext, FilterInstanceData, FilterPassPlan},
     gradient_cache::GradientRampCache,
-    paint::PaintResolver,
+    paint::{PaintResolver, TextureSourceId},
     render::{
         Config,
         common::{
@@ -91,7 +91,7 @@
 });
 
 /// First texture unit from which the strip program samples external textures.
-const EXTERNAL_TEXTURE_UNIT_START: u32 = 5;
+const EXTERNAL_TEXTURE_UNIT_START: u32 = 4;
 
 /// Query the WebGL context for the max texture size.
 fn get_max_texture_dimension_2d(gl: &WebGl2RenderingContext) -> u32 {
@@ -101,13 +101,6 @@
         .unwrap() as u32
 }
 
-fn get_max_texture_array_layers(gl: &WebGl2RenderingContext) -> u32 {
-    gl.get_parameter(WebGl2RenderingContext::MAX_ARRAY_TEXTURE_LAYERS)
-        .unwrap()
-        .as_f64()
-        .unwrap() as u32
-}
-
 /// Result of polling WebGL renderer initialization.
 #[derive(Debug)]
 #[expect(
@@ -201,15 +194,15 @@
     }
 }
 
-/// Current allocation state of the WebGL image/glyph atlas texture.
+/// Current allocation state of the WebGL image/glyph atlas textures.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub struct AtlasTextureInfo {
-    /// Width of the currently bound texture array.
+    /// Width of each atlas texture.
     pub width: u16,
-    /// Height of the currently bound texture array.
+    /// Height of each atlas texture.
     pub height: u16,
-    /// Number of real atlas layers available to the image cache.
-    pub layer_count: u32,
+    /// Number of atlas textures available to the image cache.
+    pub texture_count: u32,
 }
 
 impl WebGlRenderer {
@@ -364,8 +357,6 @@
         let device_limits = DeviceLimits {
             max_texture_dimension_2d: u16::try_from(get_max_texture_dimension_2d(&gl))
                 .unwrap_or(u16::MAX),
-            max_texture_array_layers: u16::try_from(get_max_texture_array_layers(&gl))
-                .unwrap_or(u16::MAX),
         };
         settings.memory_settings.normalize(&device_limits);
         if use_depth_buffer {
@@ -461,6 +452,7 @@
             true,
             RootTarget::UserSurface,
             texture_bindings,
+            None,
         )?;
 
         #[cfg(feature = "text")]
@@ -503,13 +495,14 @@
         texture_bindings: &WebGlTextureBindings,
     ) -> Result<(), RenderError> {
         self.programs
-            .maybe_resize_atlas_texture_array(&self.gl, atlas_count);
+            .maybe_create_atlas_textures(&self.gl, atlas_count);
 
         let (atlas_width, atlas_height) = atlas_config.atlas_size;
         let atlas_render_size = RenderSize {
             width: u32::from(atlas_width),
             height: u32::from(atlas_height),
         };
+        let render_target_texture = self.atlas_texture(atlas_id).clone();
 
         let atlas_framebuffer = self
             .programs
@@ -521,25 +514,18 @@
             WebGl2RenderingContext::FRAMEBUFFER,
             Some(&atlas_framebuffer),
         );
-        self.gl.framebuffer_texture_layer(
+        self.gl.framebuffer_texture_2d(
             WebGl2RenderingContext::FRAMEBUFFER,
             WebGl2RenderingContext::COLOR_ATTACHMENT0,
-            Some(&self.programs.resources.atlas_texture_array.texture),
+            WebGl2RenderingContext::TEXTURE_2D,
+            Some(&self.programs.resources.atlas_textures[atlas_id.as_u32() as usize]),
             0,
-            atlas_id.as_u32() as i32,
         );
 
         // Set the view framebuffer override so the scheduler renders to the
         // atlas layer instead of the default framebuffer.
         self.programs.resources.view_framebuffer_override = Some(atlas_framebuffer);
 
-        // Swap in the stub atlas texture array to avoid binding the real atlas
-        // texture as a shader input while it is also the render target.
-        core::mem::swap(
-            &mut self.programs.resources.atlas_texture_array,
-            &mut self.programs.resources.stub_atlas_texture_array,
-        );
-
         // TODO: Explore using an option instead of a dummy image cache.
         let dummy_image_cache = self
             .dummy_image_cache
@@ -552,15 +538,10 @@
             false,
             RootTarget::AtlasLayer,
             texture_bindings,
+            Some(&render_target_texture),
         );
         self.dummy_image_cache = Some(dummy_image_cache);
 
-        // Restore the real atlas texture array.
-        core::mem::swap(
-            &mut self.programs.resources.atlas_texture_array,
-            &mut self.programs.resources.stub_atlas_texture_array,
-        );
-
         // Restore the default view framebuffer and cache the atlas FBO for reuse.
         self.programs.resources.atlas_render_framebuffer =
             self.programs.resources.view_framebuffer_override.take();
@@ -595,10 +576,16 @@
         clear: bool,
         root_output_target: RootTarget,
         texture_bindings: &WebGlTextureBindings,
+        render_target_texture: Option<&WebGlTexture>,
     ) -> Result<(), RenderError> {
         let encoded_paints = &scene.encoded_paints;
 
-        self.prepare_gpu_encoded_paints(encoded_paints, image_cache, texture_bindings)?;
+        self.prepare_gpu_encoded_paints(
+            encoded_paints,
+            image_cache,
+            texture_bindings,
+            render_target_texture,
+        )?;
 
         let mut required_texture_size = self
             .layers_config
@@ -614,7 +601,8 @@
 
         let current_allocations = self.current_allocations();
 
-        let paint_resolver = PaintResolver::new(encoded_paints, &self.paint_idxs);
+        let paint_resolver =
+            PaintResolver::new(encoded_paints, &self.paint_idxs).with_image_cache(image_cache);
         let schedule = Schedule::try_new(
             &mut self.schedule_storage,
             scene,
@@ -732,12 +720,11 @@
         let image_resource = image_cache.get(image_id).expect("Image resource not found");
 
         self.programs
-            .maybe_resize_atlas_texture_array(&self.gl, image_cache.atlas_count() as u32);
+            .maybe_create_atlas_textures(&self.gl, image_cache.atlas_count() as u32);
         let offset = offset_override.unwrap_or(image_resource.offset);
-        writer.write_to_atlas_layer(
+        writer.write_to_atlas(
             &self.gl,
-            &self.programs.resources.atlas_texture_array.texture,
-            image_resource.atlas_id.as_u32(),
+            &self.programs.resources.atlas_textures[image_resource.atlas_id.as_u32() as usize],
             offset,
             writer.width(),
             writer.height(),
@@ -760,29 +747,27 @@
         }
     }
 
-    /// Returns a reference to the underlying atlas texture array.
-    ///
-    /// This is a 2D array texture (`TextureViewDimension::D2Array`) containing all
-    /// atlas layers used by the image cache. Each layer holds cached image data
-    /// (e.g., rasterised glyphs) that the renderer samples during draw calls. Before the first
-    /// layer is allocated, this returns the 1x1 placeholder texture.
-    pub fn atlas_texture(&self) -> &WebGlTexture {
-        &self.programs.resources.atlas_texture_array.texture
+    /// Returns an individual image atlas texture.
+    pub fn atlas_texture(&self, atlas_id: AtlasId) -> &WebGlTexture {
+        self.programs
+            .resources
+            .atlas_textures
+            .get(atlas_id.as_u32() as usize)
+            .map(|texture| &**texture)
+            .unwrap()
     }
 
     /// Returns the current allocation state of the image/glyph atlas texture.
     pub fn atlas_info(&self) -> AtlasTextureInfo {
         let resources = &self.programs.resources;
         AtlasTextureInfo {
-            width: u16::try_from(resources.atlas_texture_array.size.width)
-                .expect("atlas texture width exceeds the u16 renderer domain"),
-            height: u16::try_from(resources.atlas_texture_array.size.height)
-                .expect("atlas texture height exceeds the u16 renderer domain"),
-            layer_count: resources.atlas_layer_count,
+            width: resources.atlas_size.0,
+            height: resources.atlas_size.1,
+            texture_count: u32::try_from(resources.atlas_textures.len()).unwrap(),
         }
     }
 
-    /// Clear a specific region of the atlas texture array.
+    /// Clear a specific region of an atlas texture.
     fn clear_atlas_region(&mut self, atlas_id: AtlasId, offset: [u16; 2], width: u16, height: u16) {
         let _state_guard = WebGlStateGuard::for_clear_atlas_region(&self.gl);
         let temp_framebuffer = Framebuffer::new(&self.gl);
@@ -791,19 +776,18 @@
         self.gl
             .bind_framebuffer(WebGl2RenderingContext::FRAMEBUFFER, Some(&temp_framebuffer));
 
-        // Attach the specific atlas layer to the framebuffer
-        self.gl.framebuffer_texture_layer(
+        self.gl.framebuffer_texture_2d(
             WebGl2RenderingContext::FRAMEBUFFER,
             WebGl2RenderingContext::COLOR_ATTACHMENT0,
-            Some(&self.programs.resources.atlas_texture_array.texture),
+            WebGl2RenderingContext::TEXTURE_2D,
+            Some(&self.programs.resources.atlas_textures[atlas_id.as_u32() as usize]),
             0,
-            atlas_id.as_u32() as i32,
         );
 
         // Set viewport to match the atlas texture dimensions
-        let atlas_size = &self.programs.resources.atlas_texture_array.size;
+        let (atlas_width, atlas_height) = self.programs.resources.atlas_size;
         self.gl
-            .viewport(0, 0, atlas_size.width as i32, atlas_size.height as i32);
+            .viewport(0, 0, i32::from(atlas_width), i32::from(atlas_height));
 
         // Enable scissor test and set scissor rectangle to our region
         self.gl.enable(WebGl2RenderingContext::SCISSOR_TEST);
@@ -824,6 +808,7 @@
         encoded_paints: &[EncodedPaint],
         image_cache: &ImageCache,
         texture_bindings: &WebGlTextureBindings,
+        render_target_texture: Option<&WebGlTexture>,
     ) -> Result<(), RenderError> {
         self.encoded_paints
             .resize_with(encoded_paints.len(), || GPU_PAINT_PLACEHOLDER);
@@ -842,8 +827,11 @@
                         ImageSource::ExternalTexture {
                             id, source_region, ..
                         } => {
-                            if texture_bindings.get(*id).is_none() {
-                                return Err(RenderError::MissingTextureBinding(*id));
+                            let texture = texture_bindings
+                                .get(*id)
+                                .ok_or(RenderError::MissingTextureBinding(*id))?;
+                            if render_target_texture.is_some_and(|target| target == texture) {
+                                return Err(RenderError::TextureFeedbackLoop(*id));
                             }
                             Self::encode_external_texture_paint(img, *source_region)
                         }
@@ -891,8 +879,6 @@
             image.sampler.quality as u32,
             image.sampler.x_extend as u32,
             image.sampler.y_extend as u32,
-            image_resource.atlas_id.as_u32(),
-            false,
         );
         let (tint, tint_mode) = pack_tint(image.tint);
 
@@ -918,8 +904,6 @@
             image.sampler.quality as u32,
             image.sampler.x_extend as u32,
             image.sampler.y_extend as u32,
-            0,
-            true,
         );
         let (tint, tint_mode) = pack_tint(image.tint);
 
@@ -1130,8 +1114,6 @@
     alphas_texture: WebGlUniformLocation,
     /// Layer input texture location.
     layer_input_texture: WebGlUniformLocation,
-    /// Atlas texture location.
-    atlas_texture_array: WebGlUniformLocation,
     /// Encoded paints texture location for fragment shader.
     encoded_paints_texture_fs: WebGlUniformLocation,
     /// Encoded paints texture location for vertex shader.
@@ -1155,12 +1137,10 @@
     alphas_texture: Texture,
     /// Height of alpha texture.
     alpha_texture_height: u32,
-    /// Texture array for atlas data (multiple atlases supported)
-    pub(crate) atlas_texture_array: WebGlTextureArray,
-    /// Configured dimensions used when promoting the placeholder to a real atlas.
+    /// One 2D texture per image atlas.
+    pub(crate) atlas_textures: Vec<Texture>,
+    /// Configured atlas dimensions.
     pub(crate) atlas_size: (u16, u16),
-    /// Number of real atlas layers currently allocated.
-    pub(crate) atlas_layer_count: u32,
     /// Encoded paints texture for image metadata.
     encoded_paints_texture: Texture,
     /// Height of encoded paints texture.
@@ -1187,11 +1167,7 @@
     /// Dimensions of the intermediate layer and scratch textures.
     texture_size: SizeU16,
 
-    /// Placeholder 1x1 atlas texture array, used during `render_to_atlas` to avoid
-    /// binding the real atlas texture while it is also the render target.
-    stub_atlas_texture_array: WebGlTextureArray,
-
-    /// Cached framebuffer for rendering into an atlas layer in `render_to_atlas`.
+    /// Cached framebuffer for rendering into an atlas texture in `render_to_atlas`.
     /// Reused to avoid create/delete overhead on every call.
     atlas_render_framebuffer: Option<Framebuffer>,
 
@@ -1543,104 +1519,17 @@
         self.resources.texture_size = texture_size;
     }
 
-    /// Resize atlas texture array to accommodate more atlases.
-    fn maybe_resize_atlas_texture_array(
+    /// Create any newly allocated atlas textures.
+    fn maybe_create_atlas_textures(
         &mut self,
         gl: &WebGl2RenderingContext,
         required_atlas_count: u32,
     ) {
-        let current_atlas_count = self.resources.atlas_layer_count;
-        if required_atlas_count <= current_atlas_count {
-            return;
-        }
         let (width, height) = self.resources.atlas_size;
-
-        if current_atlas_count == 0 {
-            // Growing from the 1x1 placeholder allocated for `initial_atlas_count == 0`: there is no
-            // atlas data to preserve, so re-specify the backing store in place on the existing
-            // texture object rather than creating a new one and deleting the stub. Preserving the GL
-            // texture name is required on Mali-G52 under the Android WebView GL compositor: replacing
-            // the stub object mid-session hangs the native compositor, causing an ANR, and flushing
-            // around the swap does not help.
-            gl.active_texture(WebGl2RenderingContext::TEXTURE0);
-            gl.bind_texture(
-                WebGl2RenderingContext::TEXTURE_2D_ARRAY,
-                Some(&self.resources.atlas_texture_array.texture),
-            );
-            gl.tex_image_3d_with_opt_u8_array(
-                WebGl2RenderingContext::TEXTURE_2D_ARRAY,
-                0,
-                WebGl2RenderingContext::RGBA8 as i32,
-                width as i32,
-                height as i32,
-                required_atlas_count as i32,
-                0,
-                WebGl2RenderingContext::RGBA,
-                WebGl2RenderingContext::UNSIGNED_BYTE,
-                None,
-            )
-            .unwrap();
-            self.resources.atlas_texture_array.size = WebGlTextureSize {
-                width: u32::from(width),
-                height: u32::from(height),
-            };
-        } else {
-            // Growing an atlas that already holds data: allocate a new, larger `TEXTURE_2D_ARRAY`,
-            // copy the existing layers across, then swap. On Mali-G52 under the Android WebView GL
-            // compositor, `glFlush`ing after the copy is required before the next composite;
-            // otherwise, the compositor hangs and causes an ANR. Unbinding the atlas and flushing
-            // before allocation are precautionary.
-            gl.active_texture(WebGl2RenderingContext::TEXTURE0);
-            gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D_ARRAY, None);
-            gl.active_texture(WebGl2RenderingContext::TEXTURE2);
-            gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D_ARRAY, None);
-            gl.flush();
-
-            let new_atlas_texture_array =
-                create_atlas_texture_array(gl, width, height, required_atlas_count);
-            self.copy_atlas_texture_data(gl, &new_atlas_texture_array, current_atlas_count);
-
-            // Replace the old resources (dropping the old array frees its GL texture).
-            self.resources.atlas_texture_array = new_atlas_texture_array;
-
-            // This is the critical flush that prevents the Mali-G52 compositor hang.
-            gl.flush();
-        }
-
-        self.resources.atlas_layer_count = required_atlas_count;
-        // Cached FBOs were attached to the previous store; drop them so we recreate on next use.
-        self.resources.atlas_render_framebuffer = None;
-    }
-
-    /// Copy texture data from the old atlas texture array to a new one.
-    /// This is necessary when resizing the texture array to preserve existing atlas data.
-    fn copy_atlas_texture_data(
-        &self,
-        gl: &WebGl2RenderingContext,
-        new_atlas_texture_array: &WebGlTextureArray,
-        layer_count_to_copy: u32,
-    ) {
-        let WebGlTextureSize { width, height, .. } = self.resources.atlas_texture_array.size();
-
-        // Copy each layer from the old atlas to the new one
-        for layer in 0..layer_count_to_copy {
-            copy_to_texture_array_layer(
-                gl,
-                |gl| {
-                    // Attach source layer to READ framebuffer
-                    gl.framebuffer_texture_layer(
-                        WebGl2RenderingContext::READ_FRAMEBUFFER,
-                        WebGl2RenderingContext::COLOR_ATTACHMENT0,
-                        Some(&self.resources.atlas_texture_array.texture),
-                        0,
-                        layer as i32,
-                    );
-                },
-                &new_atlas_texture_array.texture,
-                layer,
-                [0, 0],
-                [width, height],
-            );
+        while self.resources.atlas_textures.len() < required_atlas_count as usize {
+            self.resources
+                .atlas_textures
+                .push(create_atlas_texture(gl, width, height));
         }
     }
 
@@ -2038,7 +1927,6 @@
     original_read_framebuffer: Option<WebGlFramebuffer>,
     original_active_texture: Option<u32>,
     original_texture_2d: Option<WebGlTexture>,
-    original_texture_2d_array: Option<WebGlTexture>,
     original_pixel_pack_buffer: Option<WebGlBuffer>,
     blend_enabled: bool,
     scissor_enabled: bool,
@@ -2085,15 +1973,6 @@
             None
         };
 
-        // Save current 2D array texture binding if requested
-        let original_texture_2d_array = if config.texture_2d_array {
-            gl.get_parameter(WebGl2RenderingContext::TEXTURE_BINDING_2D_ARRAY)
-                .ok()
-                .and_then(|v| v.dyn_into::<WebGlTexture>().ok())
-        } else {
-            None
-        };
-
         let original_pixel_pack_buffer = if config.pixel_pack_buffer {
             gl.get_parameter(WebGl2RenderingContext::PIXEL_PACK_BUFFER_BINDING)
                 .ok()
@@ -2144,7 +2023,6 @@
             original_read_framebuffer,
             original_active_texture,
             original_texture_2d,
-            original_texture_2d_array,
             original_pixel_pack_buffer,
             blend_enabled,
             scissor_enabled,
@@ -2173,8 +2051,7 @@
             gl,
             WebGlStateConfig {
                 read_framebuffer: true,
-                active_texture: true,
-                texture_2d_array: true,
+                texture_2d: true,
                 ..Default::default()
             },
         )
@@ -2267,14 +2144,6 @@
             );
         }
 
-        // Restore original 2D array texture binding if it was saved
-        if self.config.texture_2d_array {
-            self.gl.bind_texture(
-                WebGl2RenderingContext::TEXTURE_2D_ARRAY,
-                self.original_texture_2d_array.as_ref(),
-            );
-        }
-
         if self.config.pixel_pack_buffer {
             self.gl.bind_buffer(
                 WebGl2RenderingContext::PIXEL_PACK_BUFFER,
@@ -2294,8 +2163,6 @@
     pub(crate) active_texture: bool,
     /// Save/restore 2D texture binding (`TEXTURE_BINDING_2D`)
     pub(crate) texture_2d: bool,
-    /// Save/restore 2D array texture binding (`TEXTURE_BINDING_2D_ARRAY`)
-    pub(crate) texture_2d_array: bool,
     /// Save/restore pixel pack buffer binding (`PIXEL_PACK_BUFFER_BINDING`)
     pub(crate) pixel_pack_buffer: bool,
     /// Save/restore blending state
@@ -2336,7 +2203,6 @@
     // Get texture uniform locations.
     let alphas_texture_name = render::fragment::ALPHAS_TEXTURE;
     let layer_input_texture_name = render::fragment::LAYER_INPUT_TEXTURE;
-    let atlas_texture_array_name = render::fragment::ATLAS_TEXTURE_ARRAY;
     let encoded_paints_texture_fs_name = render::fragment::ENCODED_PAINTS_TEXTURE;
     let encoded_paints_texture_vs_name = render::vertex::ENCODED_PAINTS_TEXTURE;
     let gradient_texture_name = render::fragment::GRADIENT_TEXTURE;
@@ -2357,9 +2223,6 @@
         layer_input_texture: gl
             .get_uniform_location(program, layer_input_texture_name)
             .unwrap(),
-        atlas_texture_array: gl
-            .get_uniform_location(program, atlas_texture_array_name)
-            .unwrap(),
         encoded_paints_texture_fs: gl
             .get_uniform_location(program, encoded_paints_texture_fs_name)
             .unwrap(),
@@ -2501,16 +2364,6 @@
     )
 }
 
-/// Create a texture array with the requested filtering and clamp-to-edge wrapping.
-fn create_texture_array(gl: &WebGl2RenderingContext, min_filter: u32, mag_filter: u32) -> Texture {
-    create_texture_inner(
-        gl,
-        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
-        min_filter,
-        mag_filter,
-    )
-}
-
 fn create_texture_inner(
     gl: &WebGl2RenderingContext,
     target: u32,
@@ -2672,18 +2525,9 @@
         ..
     } = *image_cache.atlas_manager().config();
     let atlas_size = (atlas_width, atlas_height);
-    let atlas_layer_count = initial_atlas_count as u32;
-    let atlas_texture_array = if atlas_layer_count == 0 {
-        // Texture arrays cannot have zero layers. Keep a tiny bindable placeholder until the image
-        // cache makes its first real allocation.
-        create_atlas_texture_array(gl, 1, 1, 1)
-    } else {
-        create_atlas_texture_array(gl, atlas_width, atlas_height, atlas_layer_count)
-    };
-
-    // Create a 1x1 stub atlas texture array for use during render_to_atlas.
-    // This avoids binding the real atlas as a shader input while it is the render target.
-    let stub_atlas_texture_array = create_atlas_texture_array(gl, 1, 1, 1);
+    let atlas_textures = (0..initial_atlas_count)
+        .map(|_| create_atlas_texture(gl, atlas_width, atlas_height))
+        .collect();
 
     // Create and configure encoded paints texture.
     let encoded_paints_texture = create_placeholder_rgba32ui_texture(gl);
@@ -2702,9 +2546,8 @@
         strips_buffer,
         alphas_texture,
         alpha_texture_height: 0,
-        atlas_texture_array,
+        atlas_textures,
         atlas_size,
-        atlas_layer_count,
         encoded_paints_texture,
         encoded_paints_texture_height: 0,
         gradient_texture,
@@ -2719,7 +2562,6 @@
         depth_attachment_array: js_sys::Array::of1(&WebGl2RenderingContext::DEPTH.into()),
         resource_texture_dimension_2d,
         texture_size,
-        stub_atlas_texture_array,
         atlas_render_framebuffer: None,
         filter_data_texture,
         filter_data_texture_height: 0,
@@ -2763,31 +2605,24 @@
     }
 }
 
-/// Create an atlas texture array.
-pub(crate) fn create_atlas_texture_array(
+/// Create an image atlas texture.
+pub(crate) fn create_atlas_texture(
     gl: &WebGl2RenderingContext,
     width: u16,
     height: u16,
-    layer_count: u32,
-) -> WebGlTextureArray {
-    debug_assert!(
-        layer_count > 0,
-        "atlas texture arrays must have at least one physical layer"
-    );
-    let atlas_texture = create_texture_array(
+) -> Texture {
+    let atlas_texture = create_texture(
         gl,
         WebGl2RenderingContext::NEAREST,
         WebGl2RenderingContext::NEAREST,
     );
 
-    // Initialize with empty texture array data
-    gl.tex_image_3d_with_opt_u8_array(
-        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+    gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_array_buffer_view(
+        WebGl2RenderingContext::TEXTURE_2D,
         0,
         WebGl2RenderingContext::RGBA8 as i32,
         width as i32,
         height as i32,
-        layer_count as i32,
         0,
         WebGl2RenderingContext::RGBA,
         WebGl2RenderingContext::UNSIGNED_BYTE,
@@ -2795,7 +2630,7 @@
     )
     .unwrap();
 
-    WebGlTextureArray::new(atlas_texture, u32::from(width), u32::from(height))
+    atlas_texture
 }
 
 /// Create a framebuffer for a texture.
@@ -2930,14 +2765,19 @@
 
     /// Bind the external texture slots.
     fn bind_external_textures(&self, bindings: &ExternalTextureBindings) {
-        for (slot, texture_id) in bindings.as_array().into_iter().enumerate() {
+        for (slot, texture_source) in bindings.as_array().into_iter().enumerate() {
             self.gl.active_texture(
                 WebGl2RenderingContext::TEXTURE0
                     + EXTERNAL_TEXTURE_UNIT_START
                     + u32::try_from(slot).unwrap(),
             );
-            let texture: &WebGlTexture = match texture_id {
-                Some(texture_id) => self.texture_bindings.get(texture_id).unwrap(),
+            let texture: &WebGlTexture = match texture_source {
+                Some(TextureSourceId::Atlas(atlas_id)) => {
+                    &self.programs.resources.atlas_textures[atlas_id.as_u32() as usize]
+                }
+                Some(TextureSourceId::External(texture_id)) => {
+                    self.texture_bindings.get(texture_id).unwrap()
+                }
                 None => &self.programs.resources.placeholder_external_texture,
             };
             self.gl
@@ -3046,38 +2886,29 @@
         self.gl
             .uniform1i(Some(&self.programs.strip_uniforms.layer_input_texture), 1);
 
-        // Bind atlas texture array for image rendering
-        self.gl.active_texture(WebGl2RenderingContext::TEXTURE2);
-        self.gl.bind_texture(
-            WebGl2RenderingContext::TEXTURE_2D_ARRAY,
-            Some(&self.programs.resources.atlas_texture_array.texture),
-        );
-        self.gl
-            .uniform1i(Some(&self.programs.strip_uniforms.atlas_texture_array), 2);
-
         // Bind encoded paints texture for image metadata
-        self.gl.active_texture(WebGl2RenderingContext::TEXTURE3);
+        self.gl.active_texture(WebGl2RenderingContext::TEXTURE2);
         self.gl.bind_texture(
             WebGl2RenderingContext::TEXTURE_2D,
             Some(&self.programs.resources.encoded_paints_texture),
         );
         self.gl.uniform1i(
             Some(&self.programs.strip_uniforms.encoded_paints_texture_fs),
-            3,
+            2,
         );
         self.gl.uniform1i(
             Some(&self.programs.strip_uniforms.encoded_paints_texture_vs),
-            3,
+            2,
         );
 
         // Bind gradient texture for gradient rendering
-        self.gl.active_texture(WebGl2RenderingContext::TEXTURE4);
+        self.gl.active_texture(WebGl2RenderingContext::TEXTURE3);
         self.gl.bind_texture(
             WebGl2RenderingContext::TEXTURE_2D,
             Some(&self.programs.resources.gradient_texture),
         );
         self.gl
-            .uniform1i(Some(&self.programs.strip_uniforms.gradient_texture), 4);
+            .uniform1i(Some(&self.programs.strip_uniforms.gradient_texture), 3);
 
         // External textures are rebound per run while drawing. Start from the placeholder so the
         // sampler is valid for draws that don't reference one.
@@ -3456,12 +3287,11 @@
     /// Get the height of the image.
     fn height(&self) -> u16;
 
-    /// Write image data to a specific layer of an atlas texture array at the specified offset.
-    fn write_to_atlas_layer(
+    /// Write image data to an atlas texture at the specified offset.
+    fn write_to_atlas(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture_array: &WebGlTexture,
-        layer: u32,
+        atlas_texture: &WebGlTexture,
         offset: [u16; 2],
         width: u16,
         height: u16,
@@ -3478,35 +3308,27 @@
         self.height()
     }
 
-    fn write_to_atlas_layer(
+    fn write_to_atlas(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture_array: &WebGlTexture,
-        layer: u32,
+        atlas_texture: &WebGlTexture,
         offset: [u16; 2],
         width: u16,
         height: u16,
     ) {
-        // Bind the atlas texture array
         gl.active_texture(WebGl2RenderingContext::TEXTURE0);
-        gl.bind_texture(
-            WebGl2RenderingContext::TEXTURE_2D_ARRAY,
-            Some(atlas_texture_array),
-        );
+        gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(atlas_texture));
 
         // Convert pixmap data to the format expected by WebGL
         let rgba_data = self.data_as_u8_slice();
 
-        // Upload the image data to the specific layer and region of the atlas texture array
-        gl.tex_sub_image_3d_with_opt_u8_array(
-            WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+        gl.tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_u8_array(
+            WebGl2RenderingContext::TEXTURE_2D,
             0,
             offset[0] as i32,
             offset[1] as i32,
-            layer as i32,
             width as i32,
             height as i32,
-            1,
             WebGl2RenderingContext::RGBA,
             WebGl2RenderingContext::UNSIGNED_BYTE,
             Some(rgba_data),
@@ -3525,17 +3347,16 @@
         self.as_ref().height()
     }
 
-    fn write_to_atlas_layer(
+    fn write_to_atlas(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture_array: &WebGlTexture,
-        layer: u32,
+        atlas_texture: &WebGlTexture,
         offset: [u16; 2],
         width: u16,
         height: u16,
     ) {
         self.as_ref()
-            .write_to_atlas_layer(gl, atlas_texture_array, layer, offset, width, height);
+            .write_to_atlas(gl, atlas_texture, offset, width, height);
     }
 }
 
@@ -3555,16 +3376,15 @@
         unreachable!("WebGlTexture height must be provided by caller")
     }
 
-    fn write_to_atlas_layer(
+    fn write_to_atlas(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture_array: &WebGlTexture,
-        layer: u32,
+        atlas_texture: &WebGlTexture,
         offset: [u16; 2],
         width: u16,
         height: u16,
     ) {
-        copy_to_texture_array_layer(
+        copy_to_texture(
             gl,
             |gl| {
                 // Attach source texture to read framebuffer
@@ -3576,8 +3396,7 @@
                     0,
                 );
             },
-            atlas_texture_array,
-            layer,
+            atlas_texture,
             [u32::from(offset[0]), u32::from(offset[1])],
             [u32::from(width), u32::from(height)],
         );
@@ -3604,71 +3423,39 @@
         self.height
     }
 
-    fn write_to_atlas_layer(
+    fn write_to_atlas(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture_array: &WebGlTexture,
-        layer: u32,
+        atlas_texture: &WebGlTexture,
         offset: [u16; 2],
         width: u16,
         height: u16,
     ) {
         self.texture
-            .write_to_atlas_layer(gl, atlas_texture_array, layer, offset, width, height);
+            .write_to_atlas(gl, atlas_texture, offset, width, height);
     }
 }
 
-/// Wrapper for `WebGlTexture` array with known dimensions.
-#[derive(Debug)]
-pub(crate) struct WebGlTextureArray {
-    /// The WebGL texture array.
-    texture: Texture,
-    /// The size of the texture array.
-    size: WebGlTextureSize,
-}
-
-impl WebGlTextureArray {
-    /// Create a new WebGL texture array wrapper.
-    fn new(texture: Texture, width: u32, height: u32) -> Self {
-        Self {
-            texture,
-            size: WebGlTextureSize { width, height },
-        }
-    }
-
-    /// Get the size of the texture array, similar to WGPU's `texture.size()`.
-    fn size(&self) -> WebGlTextureSize {
-        self.size
-    }
-}
-
-/// Size information for WebGL texture arrays, similar to WGPU's `Extent3d`.
-#[derive(Debug, Clone, Copy)]
-struct WebGlTextureSize {
-    /// The width of the texture.
-    width: u32,
-    /// The height of the texture.
-    height: u32,
-}
-
-/// Helper function to copy from a source texture/framebuffer to a destination texture array layer.
-fn copy_to_texture_array_layer(
+/// Copy from a source texture/framebuffer to an atlas texture.
+fn copy_to_texture(
     gl: &WebGl2RenderingContext,
     source_setup: impl FnOnce(&WebGl2RenderingContext),
-    dest_texture_array: &WebGlTexture,
-    dest_layer: u32,
+    dest_texture: &WebGlTexture,
     dest_offset: [u32; 2],
     copy_size: [u32; 2],
 ) {
+    let _active_texture_guard = WebGlStateGuard::with_config(
+        gl,
+        WebGlStateConfig {
+            active_texture: true,
+            ..Default::default()
+        },
+    );
+
+    gl.active_texture(WebGl2RenderingContext::TEXTURE0);
     let _state_guard = WebGlStateGuard::for_texture_copy(gl);
     let read_framebuffer = Framebuffer::new(gl);
-
-    // Bind destination texture array
-    gl.active_texture(WebGl2RenderingContext::TEXTURE0);
-    gl.bind_texture(
-        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
-        Some(dest_texture_array),
-    );
+    gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(dest_texture));
 
     // Bind the READ framebuffer
     gl.bind_framebuffer(
@@ -3679,13 +3466,11 @@
     // Let the caller set up the source (attach texture/layer to read framebuffer)
     source_setup(gl);
 
-    // Copy from READ framebuffer to destination array layer
-    gl.copy_tex_sub_image_3d(
-        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+    gl.copy_tex_sub_image_2d(
+        WebGl2RenderingContext::TEXTURE_2D,
         0,
         dest_offset[0] as i32,
         dest_offset[1] as i32,
-        dest_layer as i32,
         0,
         0,
         copy_size[0] as i32,
diff --git a/sparse_strips/vello_hybrid/src/render/webgl/probe.rs b/sparse_strips/vello_hybrid/src/render/webgl/probe.rs
index 458481b..6fb809a 100644
--- a/sparse_strips/vello_hybrid/src/render/webgl/probe.rs
+++ b/sparse_strips/vello_hybrid/src/render/webgl/probe.rs
@@ -81,7 +81,6 @@
                 framebuffer: true,
                 active_texture: true,
                 texture_2d: true,
-                texture_2d_array: true,
                 pixel_pack_buffer: true,
                 viewport: true,
                 ..Default::default()
@@ -160,6 +159,7 @@
             true,
             RootTarget::AtlasLayer,
             &texture_bindings,
+            Some(&probe_texture),
         );
         let probe_framebuffer = self
             .programs
diff --git a/sparse_strips/vello_hybrid/src/render/wgpu/mod.rs b/sparse_strips/vello_hybrid/src/render/wgpu/mod.rs
index ed256a5..0573677 100644
--- a/sparse_strips/vello_hybrid/src/render/wgpu/mod.rs
+++ b/sparse_strips/vello_hybrid/src/render/wgpu/mod.rs
@@ -27,7 +27,7 @@
     copy::GpuCopyInstance,
     filter::{FilterContext, FilterInstanceData, FilterPassPlan},
     gradient_cache::GradientRampCache,
-    paint::PaintResolver,
+    paint::{PaintResolver, TextureSourceId},
     render::{
         Config,
         common::{
@@ -185,8 +185,6 @@
         let device_limits = DeviceLimits {
             max_texture_dimension_2d: u16::try_from(limits.max_texture_dimension_2d)
                 .unwrap_or(u16::MAX),
-            max_texture_array_layers: u16::try_from(limits.max_texture_array_layers)
-                .unwrap_or(u16::MAX),
         };
         settings.memory_settings.normalize(&device_limits);
         let resources = Resources::new(settings.memory_settings.image_atlas_config);
@@ -360,13 +358,7 @@
             label: Some("Render to Atlas Encoder"),
         });
 
-        Programs::maybe_resize_atlas_texture_array(
-            device,
-            &mut encoder,
-            &mut self.programs.resources,
-            &self.programs.atlas_bind_group_layout,
-            atlas_count,
-        );
+        Programs::maybe_create_atlas_textures(device, &mut self.programs.resources, atlas_count);
 
         let (atlas_width, atlas_height) = atlas_config.atlas_size;
         let atlas_render_size = RenderSize {
@@ -375,28 +367,7 @@
         };
 
         let layer_view =
-            self.programs
-                .resources
-                .atlas_texture_array
-                .create_view(&TextureViewDescriptor {
-                    label: Some("Atlas Layer Render View"),
-                    format: Some(wgpu::TextureFormat::Rgba8Unorm),
-                    dimension: Some(wgpu::TextureViewDimension::D2),
-                    aspect: wgpu::TextureAspect::All,
-                    base_mip_level: 0,
-                    mip_level_count: Some(1),
-                    base_array_layer: atlas_id.as_u32(),
-                    array_layer_count: Some(1),
-                    usage: None,
-                });
-
-        // Swap in the stub atlas bind group to avoid the read-write conflict:
-        // the real atlas texture is used as the render target (COLOR_TARGET), so it
-        // cannot also be bound as a shader resource (TEXTURE_BINDING) in the same pass.
-        core::mem::swap(
-            &mut self.programs.resources.atlas_bind_group,
-            &mut self.programs.resources.stub_atlas_bind_group,
-        );
+            self.programs.resources.atlas_texture_views[atlas_id.as_u32() as usize].clone();
 
         let encoded_paints = &scene.encoded_paints;
         let dummy_image_cache = self
@@ -419,12 +390,6 @@
         );
         self.dummy_image_cache = Some(dummy_image_cache);
 
-        // Restore the real atlas bind group.
-        core::mem::swap(
-            &mut self.programs.resources.atlas_bind_group,
-            &mut self.programs.resources.stub_atlas_bind_group,
-        );
-
         // Submit immediately so the atlas content is committed before subsequent
         // render() calls overwrite the shared alpha/config/paint resources.
         queue.submit(Some(encoder.finish()));
@@ -465,7 +430,12 @@
         texture_bindings: &TextureBindings,
     ) -> Result<(), RenderError> {
         self.programs.depth_cleared_this_frame = false;
-        self.prepare_gpu_encoded_paints(encoded_paints, image_cache, texture_bindings)?;
+        self.prepare_gpu_encoded_paints(
+            encoded_paints,
+            image_cache,
+            texture_bindings,
+            view.texture(),
+        )?;
         let required_texture_size = self
             .layers_config
             .required_intermediate_texture_size(&scene.recorder)?;
@@ -478,7 +448,8 @@
             .texture_size
             .max(required_texture_size);
         let current_allocations = self.current_allocations();
-        let paint_resolver = PaintResolver::new(encoded_paints, &self.paint_idxs);
+        let paint_resolver =
+            PaintResolver::new(encoded_paints, &self.paint_idxs).with_image_cache(image_cache);
         let schedule = Schedule::try_new(
             &mut self.schedule_storage,
             scene,
@@ -516,7 +487,7 @@
             view,
             depth_view,
             texture_bindings,
-            external_paint_source_bind_groups: HashMap::new(),
+            external_texture_bind_groups: HashMap::new(),
             scratch_buffers: &mut self.scratch_buffers,
         };
 
@@ -616,20 +587,19 @@
     ) {
         let image_resource = image_cache.get(image_id).expect("Image resource not found");
 
-        Programs::maybe_resize_atlas_texture_array(
+        Programs::maybe_create_atlas_textures(
             device,
-            encoder,
             &mut self.programs.resources,
-            &self.programs.atlas_bind_group_layout,
             image_cache.atlas_count() as u32,
         );
         let offset = offset_override.unwrap_or(image_resource.offset);
-        writer.write_to_atlas_layer(
+        let atlas_texture =
+            &self.programs.resources.atlas_textures[image_resource.atlas_id.as_u32() as usize];
+        writer.write_to_atlas(
             device,
             queue,
             encoder,
-            &self.programs.resources.atlas_texture_array,
-            image_resource.atlas_id.as_u32(),
+            atlas_texture,
             offset,
             writer.width(),
             writer.height(),
@@ -659,13 +629,13 @@
         }
     }
 
-    /// Returns a reference to the underlying atlas texture array.
-    ///
-    /// This is a 2D array texture (`TextureViewDimension::D2Array`) containing all
-    /// atlas layers used by the image cache. Each layer holds cached image data
-    /// (e.g., rasterised glyphs) that the renderer samples during draw calls.
-    pub fn atlas_texture(&self) -> &Texture {
-        &self.programs.resources.atlas_texture_array
+    /// Returns an individual image atlas texture.
+    pub fn atlas_texture(&self, atlas_id: AtlasId) -> &Texture {
+        self.programs
+            .resources
+            .atlas_textures
+            .get(atlas_id.as_u32() as usize)
+            .unwrap()
     }
 
     /// Clear a specific region of the atlas texture.
@@ -677,23 +647,8 @@
         width: u16,
         height: u16,
     ) {
-        // Create a texture view for the specific atlas layer
         let layer_view =
-            self.programs
-                .resources
-                .atlas_texture_array
-                .create_view(&TextureViewDescriptor {
-                    label: Some("Atlas Layer Clear View"),
-                    format: Some(wgpu::TextureFormat::Rgba8Unorm),
-                    dimension: Some(wgpu::TextureViewDimension::D2),
-                    aspect: wgpu::TextureAspect::All,
-                    base_mip_level: 0,
-                    mip_level_count: Some(1),
-                    base_array_layer: atlas_id.as_u32(),
-                    array_layer_count: Some(1),
-                    // Inherit usage from the texture
-                    usage: None,
-                });
+            self.programs.resources.atlas_texture_views[atlas_id.as_u32() as usize].clone();
 
         let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
             label: Some("Clear Atlas Region"),
@@ -731,6 +686,7 @@
         encoded_paints: &[EncodedPaint],
         image_cache: &ImageCache,
         texture_bindings: &TextureBindings,
+        render_target_texture: &Texture,
     ) -> Result<(), RenderError> {
         self.encoded_paints
             .resize_with(encoded_paints.len(), || GPU_PAINT_PLACEHOLDER);
@@ -749,8 +705,12 @@
                         ImageSource::ExternalTexture {
                             id, source_region, ..
                         } => {
-                            if texture_bindings.get(*id).is_none() {
-                                return Err(RenderError::MissingTextureBinding(*id));
+                            let texture_view = texture_bindings
+                                .get(*id)
+                                .ok_or(RenderError::MissingTextureBinding(*id))?;
+
+                            if texture_view.texture() == render_target_texture {
+                                return Err(RenderError::TextureFeedbackLoop(*id));
                             }
                             self.encode_external_texture_paint(img, *source_region)
                         }
@@ -798,8 +758,6 @@
             image.sampler.quality as u32,
             image.sampler.x_extend as u32,
             image.sampler.y_extend as u32,
-            image_resource.atlas_id.as_u32(),
-            false,
         );
         let (tint, tint_mode) = pack_tint(image.tint);
 
@@ -826,8 +784,6 @@
             image.sampler.quality as u32,
             image.sampler.x_extend as u32,
             image.sampler.y_extend as u32,
-            0,
-            true,
         );
         let (tint, tint_mode) = pack_tint(image.tint);
 
@@ -938,12 +894,12 @@
     renderer.atlas_clear_scratch.resize(byte_count, 0);
     queue.write_texture(
         wgpu::TexelCopyTextureInfo {
-            texture: renderer.atlas_texture(),
+            texture: renderer.atlas_texture(AtlasId::new(rect.page_index)),
             mip_level: 0,
             origin: wgpu::Origin3d {
                 x: rect.x as u32,
                 y: rect.y as u32,
-                z: rect.page_index,
+                z: 0,
             },
             aspect: wgpu::TextureAspect::All,
         },
@@ -980,8 +936,8 @@
     encoded_paints_bind_group_layout: BindGroupLayout,
     /// Bind group layout for gradient texture
     gradient_bind_group_layout: BindGroupLayout,
-    /// Bind group layout for atlas textures
-    atlas_bind_group_layout: BindGroupLayout,
+    /// Bind group layout for external textures.
+    external_texture_bind_group_layout: BindGroupLayout,
     /// Bind group layout for filter data texture.
     filter_bind_group_layout: BindGroupLayout,
     /// Filter input layouts.
@@ -1027,18 +983,16 @@
     alphas_texture: Texture,
     /// Maximum width or height of packed resource textures.
     resource_texture_dimension_2d: u32,
-    /// Textures for atlas data (multiple atlases supported)
-    atlas_texture_array: Texture,
-    /// View for atlas texture array
-    atlas_texture_array_view: TextureView,
-    /// Configured dimensions used when promoting the placeholder to a real atlas.
+    /// One 2D texture per image atlas.
+    atlas_textures: Vec<Texture>,
+    /// Default view corresponding to each image atlas texture.
+    atlas_texture_views: Vec<TextureView>,
+    /// Configured atlas dimensions.
     atlas_size: (u16, u16),
-    /// Number of real atlas layers currently exposed by the texture view.
-    atlas_layer_count: u32,
-    /// Bind group for paint sources: an atlas texture array plus four external texture slots.
-    atlas_bind_group: BindGroup,
     /// Transparent 1x1 placeholder used for unoccupied external texture slots.
     placeholder_external_texture_view: TextureView,
+    /// Bind group used when a draw does not sample an external texture.
+    empty_external_texture_bind_group: BindGroup,
     /// Texture for encoded paints
     encoded_paints_texture: Texture,
     /// Bind group for encoded paints
@@ -1058,10 +1012,6 @@
     /// Layer config buffer.
     layer_config_buffer: Buffer,
 
-    /// Placeholder paint-source bind group with a 1x1 dummy atlas texture, used during
-    /// `render_to_atlas` to avoid a read-write conflict on the real atlas texture.
-    stub_atlas_bind_group: BindGroup,
-
     /// Layer textures by parity.
     layer_textures: [Vec<TextureView>; 2],
     /// Scratch texture.
@@ -1171,26 +1121,16 @@
             },
             count: None,
         };
-        let paint_source_layout_entries = [
-            wgpu::BindGroupLayoutEntry {
-                binding: 0,
-                visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
-                ty: wgpu::BindingType::Texture {
-                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
-                    view_dimension: wgpu::TextureViewDimension::D2Array,
-                    multisampled: false,
-                },
-                count: None,
-            },
+        let external_texture_layout_entries = [
+            external_texture_layout_entry(0),
             external_texture_layout_entry(1),
             external_texture_layout_entry(2),
             external_texture_layout_entry(3),
-            external_texture_layout_entry(4),
         ];
-        let atlas_bind_group_layout =
+        let external_texture_bind_group_layout =
             device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
-                label: Some("Paint Source Bind Group Layout"),
-                entries: &paint_source_layout_entries,
+                label: Some("External Texture Bind Group Layout"),
+                entries: &external_texture_layout_entries,
             });
 
         let encoded_paints_bind_group_layout =
@@ -1238,7 +1178,7 @@
                 label: Some("Strip Pipeline Layout"),
                 bind_group_layouts: &[
                     Some(&strip_bind_group_layout),
-                    Some(&atlas_bind_group_layout),
+                    Some(&external_texture_bind_group_layout),
                     Some(&encoded_paints_bind_group_layout),
                     Some(&gradient_bind_group_layout),
                 ],
@@ -1688,30 +1628,16 @@
             ..
         } = image_cache.atlas_manager().config();
         let atlas_size = (*atlas_width, *atlas_height);
-        let atlas_layer_count = *initial_atlas_count as u32;
-        let (atlas_texture_array, atlas_texture_array_view) = if atlas_layer_count == 0 {
-            // Texture arrays cannot have zero layers. Keep a tiny bindable placeholder until the
-            // image cache makes its first real allocation.
-            Self::create_atlas_texture_array(device, 1, 1, 1)
-        } else {
-            Self::create_atlas_texture_array(device, *atlas_width, *atlas_height, atlas_layer_count)
-        };
-        let atlas_bind_group = Self::create_paint_source_bind_group(
+        let atlas_textures: Vec<_> = (0..*initial_atlas_count)
+            .map(|_| Self::create_atlas_texture(device, *atlas_width, *atlas_height))
+            .collect();
+        let atlas_texture_views = atlas_textures
+            .iter()
+            .map(|texture| texture.create_view(&TextureViewDescriptor::default()))
+            .collect();
+        let empty_external_texture_bind_group = Self::create_external_texture_bind_group(
             device,
-            &atlas_bind_group_layout,
-            &atlas_texture_array_view,
-            [&placeholder_external_texture_view; EXTERNAL_TEXTURE_SLOT_COUNT],
-        );
-
-        // Create a 1x1 stub atlas texture array for use during render_to_atlas.
-        // This avoids the read-write conflict that occurs when the real atlas is both
-        // a shader input (bind group) and render target in the same pass.
-        let (_stub_atlas_texture, stub_atlas_view) =
-            Self::create_atlas_texture_array(device, 1, 1, 1);
-        let stub_atlas_bind_group = Self::create_paint_source_bind_group(
-            device,
-            &atlas_bind_group_layout,
-            &stub_atlas_view,
+            &external_texture_bind_group_layout,
             [&placeholder_external_texture_view; EXTERNAL_TEXTURE_SLOT_COUNT],
         );
 
@@ -1776,13 +1702,11 @@
             layer_config_buffer,
             alphas_texture,
             resource_texture_dimension_2d,
-            atlas_texture_array,
-            atlas_texture_array_view,
+            atlas_textures,
+            atlas_texture_views,
             atlas_size,
-            atlas_layer_count,
-            atlas_bind_group,
             placeholder_external_texture_view,
-            stub_atlas_bind_group,
+            empty_external_texture_bind_group,
             encoded_paints_texture,
             encoded_paints_bind_group,
             gradient_texture,
@@ -1802,7 +1726,7 @@
             strip_bind_group_layout,
             encoded_paints_bind_group_layout,
             gradient_bind_group_layout,
-            atlas_bind_group_layout,
+            external_texture_bind_group_layout,
             filter_bind_group_layout,
             filter_pipeline,
             blend_pipeline,
@@ -2017,31 +1941,13 @@
         })
     }
 
-    fn create_atlas_texture_array(
-        device: &Device,
-        width: u16,
-        height: u16,
-        atlas_count: u32,
-    ) -> (Texture, TextureView) {
-        debug_assert!(
-            atlas_count > 0,
-            "atlas texture arrays must have at least one physical layer"
-        );
-        // In WGPU's GLES backend, heuristics classify a one-layer texture as D2 even when it was
-        // created as D2Array. Allocate at least two physical layers on WASM to keep the backend's
-        // classification consistent with the D2Array view.
-        // See https://github.com/gfx-rs/wgpu/blob/61e5124eb9530d3b3865556a7da4fd320d03ddc5/wgpu-hal/src/gles/mod.rs#L470-L517.
-        #[cfg(target_arch = "wasm32")]
-        let depth_or_array_layers = atlas_count.max(2);
-        #[cfg(not(target_arch = "wasm32"))]
-        let depth_or_array_layers = atlas_count;
-
-        let atlas_texture_array = device.create_texture(&wgpu::TextureDescriptor {
-            label: Some("Atlas Texture Array"),
+    fn create_atlas_texture(device: &Device, width: u16, height: u16) -> Texture {
+        device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("Atlas Texture"),
             size: Extent3d {
                 width: u32::from(width),
                 height: u32::from(height),
-                depth_or_array_layers,
+                depth_or_array_layers: 1,
             },
             mip_level_count: 1,
             sample_count: 1,
@@ -2052,28 +1958,6 @@
                 | wgpu::TextureUsages::COPY_SRC
                 | wgpu::TextureUsages::RENDER_ATTACHMENT,
             view_formats: &[],
-        });
-
-        let atlas_texture_array_view =
-            Self::create_atlas_texture_array_view(&atlas_texture_array, atlas_count);
-
-        (atlas_texture_array, atlas_texture_array_view)
-    }
-
-    fn create_atlas_texture_array_view(
-        atlas_texture_array: &Texture,
-        atlas_count: u32,
-    ) -> TextureView {
-        atlas_texture_array.create_view(&TextureViewDescriptor {
-            label: Some("Atlas Texture Array View"),
-            format: None,
-            dimension: Some(wgpu::TextureViewDimension::D2Array),
-            aspect: wgpu::TextureAspect::All,
-            base_mip_level: 0,
-            mip_level_count: None,
-            base_array_layer: 0,
-            array_layer_count: Some(atlas_count),
-            usage: None,
         })
     }
 
@@ -2127,37 +2011,32 @@
         texture.create_view(&TextureViewDescriptor::default())
     }
 
-    fn create_paint_source_bind_group(
+    fn create_external_texture_bind_group(
         device: &Device,
-        atlas_bind_group_layout: &BindGroupLayout,
-        atlas_texture_array_view: &TextureView,
-        external_texture_views: [&TextureView; EXTERNAL_TEXTURE_SLOT_COUNT],
+        external_texture_bind_group_layout: &BindGroupLayout,
+        texture_views: [&TextureView; EXTERNAL_TEXTURE_SLOT_COUNT],
     ) -> BindGroup {
         let entries = [
             wgpu::BindGroupEntry {
                 binding: 0,
-                resource: wgpu::BindingResource::TextureView(atlas_texture_array_view),
+                resource: wgpu::BindingResource::TextureView(texture_views[0]),
             },
             wgpu::BindGroupEntry {
                 binding: 1,
-                resource: wgpu::BindingResource::TextureView(external_texture_views[0]),
+                resource: wgpu::BindingResource::TextureView(texture_views[1]),
             },
             wgpu::BindGroupEntry {
                 binding: 2,
-                resource: wgpu::BindingResource::TextureView(external_texture_views[1]),
+                resource: wgpu::BindingResource::TextureView(texture_views[2]),
             },
             wgpu::BindGroupEntry {
                 binding: 3,
-                resource: wgpu::BindingResource::TextureView(external_texture_views[2]),
-            },
-            wgpu::BindGroupEntry {
-                binding: 4,
-                resource: wgpu::BindingResource::TextureView(external_texture_views[3]),
+                resource: wgpu::BindingResource::TextureView(texture_views[3]),
             },
         ];
         device.create_bind_group(&wgpu::BindGroupDescriptor {
-            label: Some("Paint Source Bind Group"),
-            layout: atlas_bind_group_layout,
+            label: Some("External Texture Bind Group"),
+            layout: external_texture_bind_group_layout,
             entries: &entries,
         })
     }
@@ -2466,118 +2345,33 @@
         }
     }
 
-    /// Resize the texture array to accommodate more atlases.
-    fn maybe_resize_atlas_texture_array(
+    /// Create any newly allocated atlas textures.
+    fn maybe_create_atlas_textures(
         device: &Device,
-        encoder: &mut CommandEncoder,
         resources: &mut GpuResources,
-        atlas_bind_group_layout: &BindGroupLayout,
         required_atlas_count: u32,
     ) {
-        let current_atlas_count = resources.atlas_layer_count;
-        if required_atlas_count > current_atlas_count {
-            let (width, height) = resources.atlas_size;
-            let physical_layer_count = resources.atlas_texture_array.size().depth_or_array_layers;
-
-            // WGPU's WebGL backend allocates at least two physical layers to keep the texture
-            // classified as D2Array. Expose an already-allocated layer without replacing the
-            // texture when that spare capacity is available.
-            if current_atlas_count > 0 && required_atlas_count <= physical_layer_count {
-                let new_atlas_texture_array_view = Self::create_atlas_texture_array_view(
-                    &resources.atlas_texture_array,
-                    required_atlas_count,
-                );
-                let new_atlas_bind_group = Self::create_paint_source_bind_group(
-                    device,
-                    atlas_bind_group_layout,
-                    &new_atlas_texture_array_view,
-                    [&resources.placeholder_external_texture_view; EXTERNAL_TEXTURE_SLOT_COUNT],
-                );
-                resources.atlas_texture_array_view = new_atlas_texture_array_view;
-                resources.atlas_bind_group = new_atlas_bind_group;
-                resources.atlas_layer_count = required_atlas_count;
-                return;
-            }
-
-            // Create new texture array with more layers
-            let (new_atlas_texture_array, new_atlas_texture_array_view) =
-                Self::create_atlas_texture_array(device, width, height, required_atlas_count);
-
-            if current_atlas_count > 0 {
-                // Copy existing atlas data from old texture array to new one. A zero-depth copy is
-                // still validated against the placeholder's 1x1 extent by WGPU, so skip it when
-                // promoting the placeholder.
-                Self::copy_atlas_texture_data(
-                    encoder,
-                    &resources.atlas_texture_array,
-                    &new_atlas_texture_array,
-                    current_atlas_count,
-                    width,
-                    height,
-                );
-            }
-
-            // Update the bind group with the new texture array view
-            let new_atlas_bind_group = Self::create_paint_source_bind_group(
-                device,
-                atlas_bind_group_layout,
-                &new_atlas_texture_array_view,
-                [&resources.placeholder_external_texture_view; EXTERNAL_TEXTURE_SLOT_COUNT],
-            );
-
-            // Replace the old resources
-            resources.atlas_texture_array = new_atlas_texture_array;
-            resources.atlas_texture_array_view = new_atlas_texture_array_view;
-            resources.atlas_bind_group = new_atlas_bind_group;
-            resources.atlas_layer_count = required_atlas_count;
+        let (width, height) = resources.atlas_size;
+        while resources.atlas_textures.len() < required_atlas_count as usize {
+            let texture = Self::create_atlas_texture(device, width, height);
+            let texture_view = texture.create_view(&TextureViewDescriptor::default());
+            resources.atlas_textures.push(texture);
+            resources.atlas_texture_views.push(texture_view);
         }
     }
 
-    fn create_external_paint_source_bind_group(
+    fn create_run_external_texture_bind_group(
         &self,
         device: &Device,
-        external_texture_views: [&TextureView; EXTERNAL_TEXTURE_SLOT_COUNT],
+        texture_views: [&TextureView; EXTERNAL_TEXTURE_SLOT_COUNT],
     ) -> BindGroup {
-        Self::create_paint_source_bind_group(
+        Self::create_external_texture_bind_group(
             device,
-            &self.atlas_bind_group_layout,
-            &self.resources.atlas_texture_array_view,
-            external_texture_views,
+            &self.external_texture_bind_group_layout,
+            texture_views,
         )
     }
 
-    /// Copy texture data from the old atlas texture array to a new one.
-    /// This is necessary when resizing the texture array to preserve existing atlas data.
-    fn copy_atlas_texture_data(
-        encoder: &mut CommandEncoder,
-        old_atlas_texture_array: &Texture,
-        new_atlas_texture_array: &Texture,
-        layer_count_to_copy: u32,
-        width: u16,
-        height: u16,
-    ) {
-        // Copy all layers from old texture array to new texture array
-        encoder.copy_texture_to_texture(
-            wgpu::TexelCopyTextureInfo {
-                texture: old_atlas_texture_array,
-                mip_level: 0,
-                origin: wgpu::Origin3d { x: 0, y: 0, z: 0 },
-                aspect: wgpu::TextureAspect::All,
-            },
-            wgpu::TexelCopyTextureInfo {
-                texture: new_atlas_texture_array,
-                mip_level: 0,
-                origin: wgpu::Origin3d { x: 0, y: 0, z: 0 },
-                aspect: wgpu::TextureAspect::All,
-            },
-            Extent3d {
-                width: u32::from(width),
-                height: u32::from(height),
-                depth_or_array_layers: layer_count_to_copy,
-            },
-        );
-    }
-
     /// Upload alpha data to the texture.
     fn upload_alpha_texture(&mut self, queue: &Queue, alphas: &mut Vec<u8>) {
         if alphas.is_empty() {
@@ -2765,31 +2559,35 @@
     view: &'a TextureView,
     depth_view: Option<&'a TextureView>,
     texture_bindings: &'a TextureBindings,
-    external_paint_source_bind_groups: HashMap<ExternalTextureBindings, BindGroup>,
+    external_texture_bind_groups: HashMap<ExternalTextureBindings, BindGroup>,
     scratch_buffers: &'a mut ScratchBuffers,
 }
 
 impl RendererContext<'_> {
-    fn external_paint_source_bind_group_for_textures(
+    fn external_texture_bind_group_for_textures(
         &mut self,
         bindings: ExternalTextureBindings,
     ) -> &BindGroup {
-        match self.external_paint_source_bind_groups.entry(bindings) {
+        let atlas_texture_views = &self.programs.resources.atlas_texture_views;
+        let placeholder = &self.programs.resources.placeholder_external_texture_view;
+        let texture_bindings = self.texture_bindings;
+        match self.external_texture_bind_groups.entry(bindings) {
             Entry::Occupied(entry) => entry.into_mut(),
             Entry::Vacant(entry) => {
-                let texture_views = bindings.as_array().map(|texture_id| {
-                    texture_id.map_or(
-                        &self.programs.resources.placeholder_external_texture_view,
-                        |texture_id| {
-                            self.texture_bindings.get(texture_id).expect(
-                                "external texture bindings were validated during paint preparation",
-                            )
-                        },
-                    )
+                let texture_sources = bindings.as_array();
+                let texture_views = core::array::from_fn(|slot| match texture_sources[slot] {
+                    Some(TextureSourceId::Atlas(atlas_id)) => {
+                        atlas_texture_views[atlas_id.as_u32() as usize].clone()
+                    }
+                    Some(TextureSourceId::External(texture_id)) => texture_bindings
+                        .get(texture_id)
+                        .expect("external texture binding was validated during paint preparation")
+                        .clone(),
+                    None => placeholder.clone(),
                 });
                 let bind_group = self
                     .programs
-                    .create_external_paint_source_bind_group(self.device, texture_views);
+                    .create_run_external_texture_bind_group(self.device, texture_views.each_ref());
                 entry.insert(bind_group)
             }
         }
@@ -2812,10 +2610,9 @@
         }
         // TODO: We currently allocate a new strips buffer for each render pass. A more efficient
         // approach would be to re-use buffers or slices of a larger buffer.
-        // Create bind groups for all external textures passed in by the user that are used this
-        // pass.
+        // Create bind groups for all external textures used by this pass.
         for run in external_texture_runs {
-            self.external_paint_source_bind_group_for_textures(run.bindings);
+            self.external_texture_bind_group_for_textures(run.bindings);
         }
 
         self.programs
@@ -2910,21 +2707,25 @@
 
         let draw_strip_runs = |render_pass: &mut wgpu::RenderPass<'_>, first_instance, count| {
             if external_texture_runs.is_empty() {
-                render_pass.set_bind_group(1, &self.programs.resources.atlas_bind_group, &[]);
+                render_pass.set_bind_group(
+                    1,
+                    &self.programs.resources.empty_external_texture_bind_group,
+                    &[],
+                );
                 render_pass.draw(0..4, first_instance..first_instance + count);
 
                 return;
             }
 
-            // Each run is drawn with a different external texture binding. Runs go from
+            // Each run is drawn with different external texture bindings. Runs go from
             // `run.strips_start` to the next run's `strips_start`; the last run goes to the end of
             // the strips buffer.
             for (i, run) in external_texture_runs.iter().enumerate() {
-                let paint_source_bind_group = self
-                    .external_paint_source_bind_groups
+                let external_texture_bind_group = self
+                    .external_texture_bind_groups
                     .get(&run.bindings)
                     .unwrap();
-                render_pass.set_bind_group(1, paint_source_bind_group, &[]);
+                render_pass.set_bind_group(1, external_texture_bind_group, &[]);
                 let start = u32::try_from(run.strips_start).unwrap();
                 let end = external_texture_runs
                     .get(i + 1)
@@ -3347,14 +3148,13 @@
     /// Get the height of the image.
     fn height(&self) -> u16;
 
-    /// Write image data to a specific layer of an atlas texture array at the specified offset.
-    fn write_to_atlas_layer(
+    /// Write image data to an atlas texture at the specified offset.
+    fn write_to_atlas(
         &self,
         device: &Device,
         queue: &Queue,
         encoder: &mut CommandEncoder,
         atlas_texture: &Texture,
-        layer: u32,
         offset: [u16; 2],
         width: u16,
         height: u16,
@@ -3371,13 +3171,12 @@
         u16::try_from(self.height()).expect("texture height exceeds the u16 atlas domain")
     }
 
-    fn write_to_atlas_layer(
+    fn write_to_atlas(
         &self,
         _device: &Device,
         _queue: &Queue,
         encoder: &mut CommandEncoder,
         atlas_texture: &Texture,
-        layer: u32,
         offset: [u16; 2],
         width: u16,
         height: u16,
@@ -3395,7 +3194,7 @@
                 origin: wgpu::Origin3d {
                     x: u32::from(offset[0]),
                     y: u32::from(offset[1]),
-                    z: layer,
+                    z: 0,
                 },
                 aspect: wgpu::TextureAspect::All,
             },
@@ -3418,13 +3217,12 @@
         self.height()
     }
 
-    fn write_to_atlas_layer(
+    fn write_to_atlas(
         &self,
         _device: &Device,
         queue: &Queue,
         _encoder: &mut CommandEncoder,
         atlas_texture: &Texture,
-        layer: u32,
         offset: [u16; 2],
         width: u16,
         height: u16,
@@ -3438,7 +3236,7 @@
                 origin: wgpu::Origin3d {
                     x: u32::from(offset[0]),
                     y: u32::from(offset[1]),
-                    z: layer,
+                    z: 0,
                 },
                 aspect: wgpu::TextureAspect::All,
             },
@@ -3467,26 +3265,17 @@
         self.as_ref().height()
     }
 
-    fn write_to_atlas_layer(
+    fn write_to_atlas(
         &self,
         device: &Device,
         queue: &Queue,
         encoder: &mut CommandEncoder,
         atlas_texture: &Texture,
-        layer: u32,
         offset: [u16; 2],
         width: u16,
         height: u16,
     ) {
-        self.as_ref().write_to_atlas_layer(
-            device,
-            queue,
-            encoder,
-            atlas_texture,
-            layer,
-            offset,
-            width,
-            height,
-        );
+        self.as_ref()
+            .write_to_atlas(device, queue, encoder, atlas_texture, offset, width, height);
     }
 }
diff --git a/sparse_strips/vello_sparse_shaders/shaders/helpers/image.wesl b/sparse_strips/vello_sparse_shaders/shaders/helpers/image.wesl
index 3b78761..68c3e5d 100644
--- a/sparse_strips/vello_sparse_shaders/shaders/helpers/image.wesl
+++ b/sparse_strips/vello_sparse_shaders/shaders/helpers/image.wesl
@@ -8,10 +8,6 @@
 const IMAGE_QUALITY_MEDIUM: u32 = 1u;
 const IMAGE_QUALITY_HIGH: u32 = 2u;
 
-/// Image source kinds.
-const IMAGE_SOURCE_ATLAS: u32 = 0u;
-const IMAGE_SOURCE_EXTERNAL: u32 = 1u;
-
 /// Tint mode constants.
 const TINT_MODE_ALPHA_MASK: u32 = 0u;
 const TINT_MODE_MULTIPLY: u32 = 1u;
@@ -22,8 +18,6 @@
 //   bits 0-1: quality
 //   bits 2-3: extend_x
 //   bits 4-5: extend_y
-//   bits 6-13: atlas_index
-//   bit 14: source_kind (0=atlas, 1=external texture)
 // texel0.y: image_size, packed as [width:16, height:16]
 // texel0.z: image_offset, packed as [x:16, y:16]
 // texel0.w/texel1.x/texel1.y/texel1.z: transform matrix [a, b, c, d]
@@ -50,12 +44,6 @@
     return vec2<f32>(f32(texel0.z >> 16u), f32(texel0.z & 0xFFFFu));
 }
 
-/// The atlas index containing this image.
-fn get_image_atlas_index(texel0: vec4<u32>) -> u32 { return (texel0.x >> 6u) & 0xFFu; }
-
-/// Whether the image is sourced from the atlas or the externally bound texture.
-fn get_image_source_kind(texel0: vec4<u32>) -> u32 { return (texel0.x >> 14u) & 0x1u; }
-
 /// 2x2 linear part of the affine transform (columns [a,b] and [c,d]).
 fn get_image_transform(texel0: vec4<u32>, texel1: vec4<u32>) -> mat2x2<f32> {
     return mat2x2<f32>(
@@ -69,88 +57,6 @@
     return vec2<f32>(bitcast<f32>(texel1.w), bitcast<f32>(texel2.x));
 }
 
-/// Number of transparent padding pixels around the image in the atlas.
-fn get_image_padding(texel2: vec4<u32>) -> f32 { return f32(texel2.w); }
-
-// Bilinear filtering
-//
-// Bilinear filtering consists of sampling the 4 surrounding pixels of the target point and
-// interpolating them with a bilinear filter.
-fn bilinear_sample(
-    tex: texture_2d_array<f32>,
-    coords: vec2<f32>,
-    atlas_idx: i32,
-    image_offset: vec2<f32>,
-    image_size: vec2<f32>,
-    _extend_modes: vec2<u32>,
-    _image_padding: f32,
-) -> vec4<f32> {
-    let atlas_max = image_offset + image_size - vec2(1.0);
-    let atlas_uv_clamped = clamp(coords, image_offset, atlas_max);
-    let uv_quad = vec4(floor(atlas_uv_clamped), ceil(atlas_uv_clamped));
-    let uv_frac = fract(coords);
-    let a = textureLoad(tex, vec2<i32>(uv_quad.xy), atlas_idx, 0);
-    let b = textureLoad(tex, vec2<i32>(uv_quad.xw), atlas_idx, 0);
-    let c = textureLoad(tex, vec2<i32>(uv_quad.zy), atlas_idx, 0);
-    let d = textureLoad(tex, vec2<i32>(uv_quad.zw), atlas_idx, 0);
-    return mix(mix(a, b, uv_frac.y), mix(c, d, uv_frac.y), uv_frac.x);
-}
-
-// Bicubic filtering using Mitchell filter with B=1/3, C=1/3
-//
-// Cubic resampling consists of sampling the 16 surrounding pixels of the target point and
-// interpolating them with a cubic filter. The generated matrix is 4x4 and represent the coefficients
-// of the cubic function used to  calculate weights based on the `x_fract` and `y_fract` of the
-// location we are looking at.
-fn bicubic_sample(
-    tex: texture_2d_array<f32>,
-    coords: vec2<f32>,
-    atlas_idx: i32,
-    image_offset: vec2<f32>,
-    image_size: vec2<f32>,
-    _extend_modes: vec2<u32>,
-    _image_padding: f32,
-) -> vec4<f32> {
-     let atlas_max = image_offset + image_size - vec2(1.0);
-     let frac_coords = fract(coords + 0.5);
-     // Get cubic weights for x and y directions
-     let cx = cubic_weights(frac_coords.x);
-     let cy = cubic_weights(frac_coords.y);
-     
-     // Sample 4x4 grid around coords
-     let s00 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-1.5, -1.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s10 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-0.5, -1.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s20 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(0.5, -1.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s30 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(1.5, -1.5), image_offset, atlas_max)), atlas_idx, 0);
-     
-     let s01 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-1.5, -0.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s11 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-0.5, -0.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s21 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(0.5, -0.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s31 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(1.5, -0.5), image_offset, atlas_max)), atlas_idx, 0);
-     
-     let s02 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-1.5, 0.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s12 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-0.5, 0.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s22 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(0.5, 0.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s32 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(1.5, 0.5), image_offset, atlas_max)), atlas_idx, 0);
-     
-     let s03 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-1.5, 1.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s13 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-0.5, 1.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s23 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(0.5, 1.5), image_offset, atlas_max)), atlas_idx, 0);
-     let s33 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(1.5, 1.5), image_offset, atlas_max)), atlas_idx, 0);
-    
-    // Interpolate in x direction for each row
-    let row0 = cx.x * s00 + cx.y * s10 + cx.z * s20 + cx.w * s30;
-    let row1 = cx.x * s01 + cx.y * s11 + cx.z * s21 + cx.w * s31;
-    let row2 = cx.x * s02 + cx.y * s12 + cx.z * s22 + cx.w * s32;
-    let row3 = cx.x * s03 + cx.y * s13 + cx.z * s23 + cx.w * s33;
-    // Interpolate in y direction
-    let result = cy.x * row0 + cy.y * row1 + cy.z * row2 + cy.w * row3;
-    
-    // Clamp alpha first, then clamp premultiplied color channels against it.
-    let a = clamp(result.a, 0.0, 1.0);
-    return vec4<f32>(clamp(result.rgb, vec3(0.0), vec3(a)), a);
-}
-
 // Cubic resampler logic borrowed from Skia (same as CPU cubic_resampler function)
 // Mitchell-Netravali cubic filter coefficients with parameters B=1/3 and C=1/3
 const MF: array<vec4<f32>, 4> = array<vec4<f32>, 4>(
diff --git a/sparse_strips/vello_sparse_shaders/shaders/render.wesl b/sparse_strips/vello_sparse_shaders/shaders/render.wesl
index 54f8dbe..0f28076 100644
--- a/sparse_strips/vello_sparse_shaders/shaders/render.wesl
+++ b/sparse_strips/vello_sparse_shaders/shaders/render.wesl
@@ -40,19 +40,11 @@
 };
 import package::helpers::geometry::{pixel_to_ndc, quad_corner};
 import package::helpers::image::{
-    IMAGE_QUALITY_HIGH,
-    IMAGE_QUALITY_MEDIUM,
-    IMAGE_SOURCE_EXTERNAL,
     TINT_MODE_MULTIPLY,
-    bicubic_sample,
-    bilinear_sample,
-    get_image_atlas_index,
     get_image_extend_modes,
     get_image_offset,
-    get_image_padding,
     get_image_quality,
     get_image_size,
-    get_image_source_kind,
     get_image_transform,
     get_image_translate
 };
@@ -222,18 +214,15 @@
 var<uniform> config: Config;
 
 @group(1) @binding(0)
-var atlas_texture_array: texture_2d_array<f32>;
-
-@group(1) @binding(1)
 var external_texture_0: texture_2d<f32>;
 
-@group(1) @binding(2)
+@group(1) @binding(1)
 var external_texture_1: texture_2d<f32>;
 
-@group(1) @binding(3)
+@group(1) @binding(2)
 var external_texture_2: texture_2d<f32>;
 
-@group(1) @binding(4)
+@group(1) @binding(3)
 var external_texture_3: texture_2d<f32>;
 
 @group(2) @binding(0)
@@ -410,10 +399,7 @@
             let image_offset = get_image_offset(image_texel0);
             let image_size = get_image_size(image_texel0);
             let image_extend_modes = get_image_extend_modes(image_texel0);
-            let image_atlas_index = get_image_atlas_index(image_texel0);
             let image_quality = get_image_quality(image_texel0);
-            let image_source_kind = get_image_source_kind(image_texel0);
-            let image_padding = get_image_padding(image_texel2);
             let packed_tint = image_texel2.y;
             let image_tint = unpack4x8unorm(packed_tint);
             let is_multiply = image_texel2.z == TINT_MODE_MULTIPLY;
@@ -431,72 +417,40 @@
             // using GPU-native bilinear sampling
 
             var sample_color: vec4<f32>;
-            if image_source_kind == IMAGE_SOURCE_EXTERNAL {
-                let external_texture_slot =
-                    (paint_and_rect_flag >> EXTERNAL_TEXTURE_SLOT_SHIFT) & 0x3u;
-                let final_xy = image_offset + extended_xy;
-                if external_texture_slot == 0u {
-                    sample_color = sample_external_image(
-                        external_texture_0,
-                        image_quality,
-                        final_xy,
-                        image_offset,
-                        image_size,
-                    );
-                } else if external_texture_slot == 1u {
-                    sample_color = sample_external_image(
-                        external_texture_1,
-                        image_quality,
-                        final_xy,
-                        image_offset,
-                        image_size,
-                    );
-                } else if external_texture_slot == 2u {
-                    sample_color = sample_external_image(
-                        external_texture_2,
-                        image_quality,
-                        final_xy,
-                        image_offset,
-                        image_size,
-                    );
-                } else {
-                    sample_color = sample_external_image(
-                        external_texture_3,
-                        image_quality,
-                        final_xy,
-                        image_offset,
-                        image_size,
-                    );
-                }
-            } else if image_quality == IMAGE_QUALITY_HIGH {
-                let final_xy = image_offset + extended_xy;
-                sample_color = bicubic_sample(
-                    atlas_texture_array,
+            let external_texture_slot =
+                (paint_and_rect_flag >> EXTERNAL_TEXTURE_SLOT_SHIFT) & 0x3u;
+            let final_xy = image_offset + extended_xy;
+            if external_texture_slot == 0u {
+                sample_color = sample_external_image(
+                    external_texture_0,
+                    image_quality,
                     final_xy,
-                    i32(image_atlas_index),
                     image_offset,
                     image_size,
-                    image_extend_modes,
-                    image_padding,
                 );
-            } else if image_quality == IMAGE_QUALITY_MEDIUM {
-                let final_xy = image_offset + extended_xy - vec2(0.5);
-                sample_color = bilinear_sample(
-                    atlas_texture_array,
+            } else if external_texture_slot == 1u {
+                sample_color = sample_external_image(
+                    external_texture_1,
+                    image_quality,
                     final_xy,
-                    i32(image_atlas_index),
                     image_offset,
                     image_size,
-                    image_extend_modes,
-                    image_padding,
+                );
+            } else if external_texture_slot == 2u {
+                sample_color = sample_external_image(
+                    external_texture_2,
+                    image_quality,
+                    final_xy,
+                    image_offset,
+                    image_size,
                 );
             } else {
-                let final_xy = image_offset + extended_xy;
-                sample_color = textureLoad(
-                    atlas_texture_array,
-                    vec2<u32>(final_xy),
-                    i32(image_atlas_index),
-                    0,
+                sample_color = sample_external_image(
+                    external_texture_3,
+                    image_quality,
+                    final_xy,
+                    image_offset,
+                    image_size,
                 );
             }
 
diff --git a/sparse_strips/vello_sparse_tests/tests/image_atlas.rs b/sparse_strips/vello_sparse_tests/tests/image_atlas.rs
index 7524469..8cab391 100644
--- a/sparse_strips/vello_sparse_tests/tests/image_atlas.rs
+++ b/sparse_strips/vello_sparse_tests/tests/image_atlas.rs
@@ -3,20 +3,60 @@
 
 //! Tests for the image/glyph atlas configuration of the WebGL renderer.
 
-use vello_common::pixmap::Pixmap;
-use vello_hybrid::{AtlasConfig, AtlasTextureInfo, MemorySettings, RenderSettings, WebGlRenderer};
+use vello_common::{
+    geometry::RectU16,
+    kurbo::Rect,
+    paint::{Image, ImageSource},
+    peniko::ImageSampler,
+    pixmap::Pixmap,
+};
+use vello_hybrid::{
+    AtlasConfig, AtlasId, AtlasTextureInfo, MemorySettings, RenderError, RenderSettings, Scene,
+    TextureId, WebGlRenderer, WebGlTextureBindings, WebGlTextureWithDimensions,
+};
 use wasm_bindgen::JsCast;
 use wasm_bindgen_test::*;
-use web_sys::{HtmlCanvasElement, WebGl2RenderingContext};
+use web_sys::{HtmlCanvasElement, WebGl2RenderingContext, WebGlTexture};
 
-#[wasm_bindgen_test]
-fn image_atlas_placeholder_is_promoted_on_first_upload() {
-    let document = web_sys::window().unwrap().document().unwrap();
-    let canvas = document
+fn create_canvas() -> HtmlCanvasElement {
+    web_sys::window()
+        .unwrap()
+        .document()
+        .unwrap()
         .create_element("canvas")
         .unwrap()
-        .dyn_into::<HtmlCanvasElement>()
-        .unwrap();
+        .dyn_into()
+        .unwrap()
+}
+
+fn solid_texture(gl: &WebGl2RenderingContext, pixel: [u8; 4]) -> WebGlTexture {
+    let texture = gl.create_texture().unwrap();
+    gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture));
+    gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array(
+        WebGl2RenderingContext::TEXTURE_2D,
+        0,
+        WebGl2RenderingContext::RGBA8 as i32,
+        1,
+        1,
+        0,
+        WebGl2RenderingContext::RGBA,
+        WebGl2RenderingContext::UNSIGNED_BYTE,
+        Some(&pixel),
+    )
+    .unwrap();
+    texture
+}
+
+fn texture_binding_2d(gl: &WebGl2RenderingContext) -> WebGlTexture {
+    gl.get_parameter(WebGl2RenderingContext::TEXTURE_BINDING_2D)
+        .unwrap()
+        .dyn_into()
+        .unwrap()
+}
+
+#[wasm_bindgen_test]
+fn image_atlas_texture_is_created_on_first_upload() {
+    let canvas = create_canvas();
     canvas.set_width(100);
     canvas.set_height(100);
 
@@ -37,11 +77,11 @@
     assert_eq!(
         renderer.atlas_info(),
         AtlasTextureInfo {
-            width: 1,
-            height: 1,
-            layer_count: 0,
+            width: 10,
+            height: 10,
+            texture_count: 0,
         },
-        "renderer should start with only the 1x1 placeholder atlas texture"
+        "renderer should start without allocated atlas textures"
     );
     assert_eq!(
         renderer.gl_context().get_error(),
@@ -56,9 +96,9 @@
         AtlasTextureInfo {
             width: 10,
             height: 10,
-            layer_count: 1,
+            texture_count: 1,
         },
-        "first upload should promote the placeholder to the configured atlas"
+        "first upload should allocate the first configured atlas texture"
     );
     assert_eq!(
         renderer.gl_context().get_error(),
@@ -70,17 +110,12 @@
 /// Uploading an image that is larger than the configured atlas must fail with
 /// `AtlasError::TextureTooLarge`.
 ///
-/// The renderer constructor configures both the 10x10 GPU atlas texture and the allocator in the
-/// returned resources, which runs the `TextureTooLarge` check.
+/// The renderer constructor configures the allocator in the returned resources, which runs the
+/// `TextureTooLarge` check.
 #[wasm_bindgen_test]
 #[should_panic(expected = "TextureTooLarge")]
 fn image_atlas_upload_larger_than_atlas_fails() {
-    let document = web_sys::window().unwrap().document().unwrap();
-    let canvas = document
-        .create_element("canvas")
-        .unwrap()
-        .dyn_into::<HtmlCanvasElement>()
-        .unwrap();
+    let canvas = create_canvas();
     canvas.set_width(100);
     canvas.set_height(100);
 
@@ -102,3 +137,97 @@
     let image = Pixmap::new(64, 64);
     renderer.upload_image(&mut resources, &image);
 }
+
+#[wasm_bindgen_test]
+fn image_atlas_rejects_sampling_from_its_render_target() {
+    let canvas = create_canvas();
+    let atlas_config = AtlasConfig {
+        initial_atlas_count: 1,
+        atlas_size: (1, 1),
+        ..AtlasConfig::default()
+    };
+    let settings = RenderSettings {
+        memory_settings: MemorySettings {
+            image_atlas_config: atlas_config,
+            ..MemorySettings::default()
+        },
+        ..RenderSettings::default()
+    };
+    let (mut renderer, _) = WebGlRenderer::new_with(&canvas, settings, false);
+    let texture_id = TextureId(0);
+    let mut bindings = WebGlTextureBindings::new();
+    bindings.insert(texture_id, renderer.atlas_texture(AtlasId::new(0)).clone());
+
+    let mut scene = Scene::new(1, 1);
+    scene.set_paint(Image {
+        image: ImageSource::external_texture(texture_id, RectU16::new(0, 0, 1, 1), false),
+        sampler: ImageSampler::default(),
+    });
+    scene.fill_rect(&Rect::new(0.0, 0.0, 1.0, 1.0));
+
+    assert!(
+        matches!(
+            renderer.render_to_atlas(&scene, 1, atlas_config, AtlasId::new(0), &bindings),
+            Err(RenderError::TextureFeedbackLoop(id)) if id == texture_id
+        ),
+        "sampling from the render target should fail"
+    );
+}
+
+#[wasm_bindgen_test]
+fn texture_copy_restores_texture_zero_binding_when_another_unit_is_active() {
+    let canvas = create_canvas();
+    let settings = RenderSettings {
+        memory_settings: MemorySettings {
+            image_atlas_config: AtlasConfig {
+                initial_atlas_count: 1,
+                max_atlases: 1,
+                atlas_size: (1, 1),
+                ..AtlasConfig::default()
+            },
+            ..MemorySettings::default()
+        },
+        ..RenderSettings::default()
+    };
+    let (mut renderer, mut resources) = WebGlRenderer::new_with(&canvas, settings, true);
+    let gl = renderer.gl_context().clone();
+
+    let source = solid_texture(&gl, [255, 0, 0, 255]);
+    let texture_zero = solid_texture(&gl, [0, 255, 0, 255]);
+    let texture_seven = solid_texture(&gl, [0, 0, 255, 255]);
+
+    gl.active_texture(WebGl2RenderingContext::TEXTURE0);
+    gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture_zero));
+    gl.active_texture(WebGl2RenderingContext::TEXTURE7);
+    gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture_seven));
+
+    renderer.upload_image(
+        &mut resources,
+        &WebGlTextureWithDimensions {
+            texture: source,
+            width: 1,
+            height: 1,
+        },
+    );
+
+    assert_eq!(
+        gl.get_parameter(WebGl2RenderingContext::ACTIVE_TEXTURE)
+            .unwrap()
+            .as_f64()
+            .unwrap() as u32,
+        WebGl2RenderingContext::TEXTURE7,
+        "the texture copy should restore the active texture unit",
+    );
+    assert_eq!(
+        texture_binding_2d(&gl),
+        texture_seven,
+        "the texture copy should preserve the active unit's binding",
+    );
+
+    gl.active_texture(WebGl2RenderingContext::TEXTURE0);
+    assert_eq!(
+        texture_binding_2d(&gl),
+        texture_zero,
+        "the texture copy should preserve texture unit 0's binding",
+    );
+}