[`vello_hybrid`]: Multiple image atlases support
diff --git a/sparse_strips/vello_example_scenes/src/svg.rs b/sparse_strips/vello_example_scenes/src/svg.rs
index 8c6e377..9165ebb 100644
--- a/sparse_strips/vello_example_scenes/src/svg.rs
+++ b/sparse_strips/vello_example_scenes/src/svg.rs
@@ -173,10 +173,8 @@
             let cargo_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
                 .canonicalize()
                 .unwrap();
-            &std::fs::read_to_string(
-                cargo_dir.join("../../../../examples/assets/Ghostscript_Tiger.svg"),
-            )
-            .unwrap()
+            &std::fs::read_to_string(cargo_dir.join("../../examples/assets/Ghostscript_Tiger.svg"))
+                .unwrap()
         };
 
         let svg = PicoSvg::load(svg_content, 1.0).expect("Failed to parse Ghost Tiger SVG");
diff --git a/sparse_strips/vello_hybrid/examples/wgpu_webgl/src/lib.rs b/sparse_strips/vello_hybrid/examples/wgpu_webgl/src/lib.rs
index 4052aae..b5dbef0 100644
--- a/sparse_strips/vello_hybrid/examples/wgpu_webgl/src/lib.rs
+++ b/sparse_strips/vello_hybrid/examples/wgpu_webgl/src/lib.rs
@@ -12,11 +12,14 @@
 use std::cell::RefCell;
 use std::rc::Rc;
 use vello_common::{
+    fearless_simd::Level,
     kurbo::{Affine, Point},
     paint::{ImageId, ImageSource},
 };
 use vello_example_scenes::{AnyScene, image::ImageScene};
-use vello_hybrid::{Pixmap, Scene};
+use vello_hybrid::{
+    AllocationStrategy, AtlasConfig, Pixmap, RenderSettings, RenderTargetConfig, Renderer, Scene,
+};
 use wasm_bindgen::prelude::*;
 use web_sys::{Event, HtmlCanvasElement, KeyboardEvent, MouseEvent, WheelEvent};
 
@@ -75,13 +78,30 @@
         };
         surface.configure(&device, &surface_config);
 
-        let renderer = vello_hybrid::Renderer::new(
+        let max_texture_dimension_2d = device.limits().max_texture_dimension_2d;
+        let renderer = Renderer::new_with(
             &device,
-            &vello_hybrid::RenderTargetConfig {
+            &RenderTargetConfig {
                 format: surface_format,
                 width,
                 height,
             },
+            RenderSettings {
+                level: Level::try_detect().unwrap_or(Level::fallback()),
+                atlas_config: AtlasConfig {
+                    atlas_size: (max_texture_dimension_2d, max_texture_dimension_2d),
+                    // In WGPU’s GLES backend, heuristics are used to decide whether a texture
+                    // should be treated as D2 or D2Array. However, this can cause a mismatch:
+                    // - when depth_or_array_layers == 1, the backend assumes the texture is D2,
+                    // even if it was actually created as a D2Array. This issue only occurs with the GLES backend.
+                    //
+                    // @see https://github.com/gfx-rs/wgpu/blob/61e5124eb9530d3b3865556a7da4fd320d03ddc5/wgpu-hal/src/gles/mod.rs#L470-L517
+                    initial_atlas_count: 2,
+                    max_atlases: 10,
+                    auto_grow: true,
+                    allocation_strategy: AllocationStrategy::FirstFit,
+                },
+            },
         );
 
         Self {
diff --git a/sparse_strips/vello_hybrid/src/image_cache.rs b/sparse_strips/vello_hybrid/src/image_cache.rs
index 15b6d64..b877a7b 100644
--- a/sparse_strips/vello_hybrid/src/image_cache.rs
+++ b/sparse_strips/vello_hybrid/src/image_cache.rs
@@ -3,93 +3,82 @@
 
 #![allow(dead_code, reason = "Clippy fails when --no-default-features")]
 
+use crate::multi_atlas::{AtlasConfig, AtlasError, AtlasId, MultiAtlasManager};
 use alloc::vec::Vec;
-use guillotiere::{AllocId, AtlasAllocator, size2};
+use guillotiere::AllocId;
 use vello_common::paint::ImageId;
 
-const DEFAULT_ATLAS_SIZE: u32 = 1024;
-
-/// Represents an image resource for rendering
+/// Represents an image resource for rendering.
 #[derive(Debug)]
 pub(crate) struct ImageResource {
-    /// The ID of the image
+    /// The ID of the image.
     pub(crate) id: ImageId,
-    /// The width of the image
+    /// The width of the image.
     pub(crate) width: u16,
-    /// The height of the image
+    /// The height of the image.
     pub(crate) height: u16,
-    /// The offset of the image in the atlas
+    /// The Id of the atlas containing this image.
+    pub(crate) atlas_id: AtlasId,
+    /// The offset of the image within its atlas.
     pub(crate) offset: [u16; 2],
-    /// The atlas allocation ID for deallocation
+    /// The atlas allocation ID for deallocation.
     atlas_alloc_id: AllocId,
 }
 
-/// Manages image resources for the renderer
+/// Manages image resources for the renderer.
 pub(crate) struct ImageCache {
-    /// Atlas allocator for the images
-    atlas: AtlasAllocator,
-    /// Vector of optional image resources (None = free slot)
+    /// Multi-atlas manager for handling multiple texture atlases.
+    atlas_manager: MultiAtlasManager,
+    /// Vector of optional image resources (None = free slot).
     slots: Vec<Option<ImageResource>>,
-    /// Stack of free indices for O(1) allocation/deallocation
+    /// Stack of free indices.
     free_idxs: Vec<usize>,
 }
 
 impl core::fmt::Debug for ImageCache {
     fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
-        // Count allocated and free rectangles in the atlas
-        let mut allocated_count = 0;
-        let mut free_count = 0;
-
-        self.atlas.for_each_allocated_rectangle(|_id, _rect| {
-            allocated_count += 1;
-        });
-
-        self.atlas.for_each_free_rectangle(|_rect| {
-            free_count += 1;
-        });
+        let atlas_stats = self.atlas_manager.atlas_stats();
 
         f.debug_struct("ImageCache")
             .field("slots", &self.slots)
             .field("free_idxs", &self.free_idxs)
-            .field("atlas_size", &self.atlas.size())
-            .field("atlas_is_empty", &self.atlas.is_empty())
-            .field("atlas_allocated_count", &allocated_count)
-            .field("atlas_free_count", &free_count)
+            .field("atlas_count", &self.atlas_manager.atlas_count())
+            .field("atlas_stats", &atlas_stats)
             .finish()
     }
 }
 
-impl Default for ImageCache {
-    fn default() -> Self {
-        Self::new(DEFAULT_ATLAS_SIZE, DEFAULT_ATLAS_SIZE)
-    }
-}
-
 impl ImageCache {
-    /// Create a new image cache
+    /// Create a new image cache with default atlas configuration.
     pub(crate) fn new(width: u32, height: u32) -> Self {
         Self {
-            atlas: AtlasAllocator::new(size2(width as i32, height as i32)),
+            atlas_manager: MultiAtlasManager::new_with_initial_atlas(width, height),
             slots: Vec::new(),
             free_idxs: Vec::new(),
         }
     }
 
-    /// Get an image resource by its Id
+    /// Create a new image cache with custom atlas configuration.
+    pub(crate) fn new_with_config(config: AtlasConfig) -> Self {
+        Self {
+            atlas_manager: MultiAtlasManager::new(config),
+            slots: Vec::new(),
+            free_idxs: Vec::new(),
+        }
+    }
+
+    /// Get an image resource by its Id.
     pub(crate) fn get(&self, id: ImageId) -> Option<&ImageResource> {
         self.slots.get(id.as_u32() as usize)?.as_ref()
     }
 
-    /// Allocate an image in the cache
+    /// Allocate an image in the cache.
     #[expect(
         clippy::cast_possible_truncation,
         reason = "u16 is enough for the offset and width/height"
     )]
-    pub(crate) fn allocate(&mut self, width: u32, height: u32) -> ImageId {
-        let alloc = self
-            .atlas
-            .allocate(size2(width as i32, height as i32))
-            .expect("Failed to allocate texture");
+    pub(crate) fn allocate(&mut self, width: u32, height: u32) -> Result<ImageId, AtlasError> {
+        let atlas_alloc = self.atlas_manager.try_allocate(width, height)?;
 
         let slot_idx = self.free_idxs.pop().unwrap_or_else(|| {
             // No free slots, append to vector
@@ -104,18 +93,27 @@
             id: image_id,
             width: width as u16,
             height: height as u16,
-            offset: [alloc.rectangle.min.x as u16, alloc.rectangle.min.y as u16],
-            atlas_alloc_id: alloc.id,
+            atlas_id: atlas_alloc.atlas_id,
+            offset: [
+                atlas_alloc.allocation.rectangle.min.x as u16,
+                atlas_alloc.allocation.rectangle.min.y as u16,
+            ],
+            atlas_alloc_id: atlas_alloc.allocation.id,
         });
-        image_id
+        Ok(image_id)
     }
 
-    /// Deallocate an image from the cache, returning true if it existed
+    /// Deallocate an image from the cache, returning the image resource if it existed.
     pub(crate) fn deallocate(&mut self, id: ImageId) -> Option<ImageResource> {
         let index = id.as_u32() as usize;
         if let Some(image_resource) = self.slots.get_mut(index).and_then(Option::take) {
-            // Deallocate from the atlas using the stored allocation ID
-            self.atlas.deallocate(image_resource.atlas_alloc_id);
+            // Deallocate from the appropriate atlas
+            let _ = self.atlas_manager.deallocate(
+                image_resource.atlas_id,
+                image_resource.atlas_alloc_id,
+                image_resource.width as u32,
+                image_resource.height as u32,
+            );
             self.free_idxs.push(index);
             Some(image_resource)
         } else {
@@ -123,11 +121,26 @@
         }
     }
 
-    /// Clear all images from the cache
+    /// Get access to the atlas manager.
+    pub(crate) fn atlas_manager(&self) -> &MultiAtlasManager {
+        &self.atlas_manager
+    }
+
+    /// Get atlas stats.
+    pub(crate) fn atlas_stats(&self) -> Vec<(AtlasId, &crate::multi_atlas::AtlasUsageStats)> {
+        self.atlas_manager.atlas_stats()
+    }
+
+    /// Get the number of atlases.
+    pub(crate) fn atlas_count(&self) -> usize {
+        self.atlas_manager.atlas_count()
+    }
+
+    /// Clear all images from the cache.
     pub(crate) fn clear(&mut self) {
         self.slots.clear();
         self.free_idxs.clear();
-        self.atlas.clear();
+        self.atlas_manager.clear();
     }
 }
 
@@ -135,11 +148,13 @@
 mod tests {
     use super::*;
 
+    const ATLAS_SIZE: u32 = 1024;
+
     #[test]
     fn test_insert_single_image() {
-        let mut cache = ImageCache::default();
+        let mut cache = ImageCache::new(ATLAS_SIZE, ATLAS_SIZE);
 
-        let id = cache.allocate(100, 100);
+        let id = cache.allocate(100, 100).unwrap();
 
         assert_eq!(id.as_u32(), 0);
         let resource = cache.get(id).unwrap();
@@ -151,10 +166,10 @@
 
     #[test]
     fn test_insert_multiple_images() {
-        let mut cache = ImageCache::default();
+        let mut cache = ImageCache::new(ATLAS_SIZE, ATLAS_SIZE);
 
-        let id1 = cache.allocate(50, 50);
-        let id2 = cache.allocate(75, 75);
+        let id1 = cache.allocate(50, 50).unwrap();
+        let id2 = cache.allocate(75, 75).unwrap();
 
         assert_eq!(id1.as_u32(), 0);
         assert_eq!(id2.as_u32(), 1);
@@ -171,7 +186,7 @@
 
     #[test]
     fn test_get_nonexistent_image() {
-        let cache: ImageCache = ImageCache::default();
+        let cache: ImageCache = ImageCache::new(ATLAS_SIZE, ATLAS_SIZE);
 
         assert!(cache.get(ImageId::new(0)).is_none());
         assert!(cache.get(ImageId::new(999)).is_none());
@@ -179,9 +194,9 @@
 
     #[test]
     fn test_remove_image() {
-        let mut cache = ImageCache::default();
+        let mut cache = ImageCache::new(ATLAS_SIZE, ATLAS_SIZE);
 
-        let id = cache.allocate(100, 100);
+        let id = cache.allocate(100, 100).unwrap();
         assert!(cache.get(id).is_some());
 
         cache.deallocate(id);
@@ -190,7 +205,7 @@
 
     #[test]
     fn test_remove_nonexistent_image() {
-        let mut cache: ImageCache = ImageCache::default();
+        let mut cache: ImageCache = ImageCache::new(ATLAS_SIZE, ATLAS_SIZE);
 
         // Should not panic when unregistering non-existent image
         cache.deallocate(ImageId::new(0));
@@ -199,12 +214,12 @@
 
     #[test]
     fn test_slot_reuse_after_remove() {
-        let mut cache = ImageCache::default();
+        let mut cache = ImageCache::new(ATLAS_SIZE, ATLAS_SIZE);
 
         // Register three images
-        let id1 = cache.allocate(50, 50);
-        let id2 = cache.allocate(60, 60);
-        let id3 = cache.allocate(70, 70);
+        let id1 = cache.allocate(50, 50).unwrap();
+        let id2 = cache.allocate(60, 60).unwrap();
+        let id3 = cache.allocate(70, 70).unwrap();
 
         assert_eq!(id1.as_u32(), 0);
         assert_eq!(id2.as_u32(), 1);
@@ -215,7 +230,7 @@
         assert!(cache.get(id2).is_none());
 
         // Register a new image - should reuse slot 1
-        let id4 = cache.allocate(80, 80);
+        let id4 = cache.allocate(80, 80).unwrap();
         // Reused slot 1
         assert_eq!(id4.as_u32(), 1);
 
@@ -228,11 +243,11 @@
 
     #[test]
     fn test_multiple_remove_and_reuse() {
-        let mut cache = ImageCache::default();
+        let mut cache = ImageCache::new(ATLAS_SIZE, ATLAS_SIZE);
 
         // Register several images
         let ids: Vec<_> = (0..5)
-            .map(|i| cache.allocate(100 + i * 10, 100 + i * 10))
+            .map(|i| cache.allocate(100 + i * 10, 100 + i * 10).unwrap())
             .collect();
 
         // Unregister some in the middle
@@ -240,12 +255,12 @@
         cache.deallocate(ids[3]);
 
         // Register new images - should reuse the freed slots
-        let new_id1 = cache.allocate(200, 200);
-        let new_id2 = cache.allocate(300, 300);
+        let new_id1 = cache.allocate(200, 200).unwrap();
+        let new_id2 = cache.allocate(300, 300).unwrap();
 
         // Should have reused slots 3 and 1 (in reverse order due to stack behavior)
-        assert!(new_id1.as_u32() == 3);
-        assert!(new_id2.as_u32() == 1);
+        assert_eq!(new_id1.as_u32(), 3);
+        assert_eq!(new_id2.as_u32(), 1);
         assert_ne!(new_id1.as_u32(), new_id2.as_u32());
     }
 }
diff --git a/sparse_strips/vello_hybrid/src/lib.rs b/sparse_strips/vello_hybrid/src/lib.rs
index 683a7b6..a236072 100644
--- a/sparse_strips/vello_hybrid/src/lib.rs
+++ b/sparse_strips/vello_hybrid/src/lib.rs
@@ -35,11 +35,13 @@
 
 mod gradient_cache;
 mod image_cache;
+mod multi_atlas;
 mod render;
 mod scene;
 #[cfg(any(all(target_arch = "wasm32", feature = "webgl"), feature = "wgpu"))]
 mod schedule;
 pub mod util;
+pub use multi_atlas::{AllocationStrategy, AtlasConfig, AtlasError, AtlasId};
 #[cfg(feature = "wgpu")]
 pub use render::{AtlasWriter, RenderTargetConfig, Renderer};
 pub use render::{Config, GpuStrip, RenderSize};
diff --git a/sparse_strips/vello_hybrid/src/multi_atlas.rs b/sparse_strips/vello_hybrid/src/multi_atlas.rs
new file mode 100644
index 0000000..363030f
--- /dev/null
+++ b/sparse_strips/vello_hybrid/src/multi_atlas.rs
@@ -0,0 +1,539 @@
+// Copyright 2025 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Multi-atlas management for `vello_hybrid`.
+//!
+//! This module provides support for managing multiple texture atlases, allowing for handling of
+//! large numbers of images.
+
+use alloc::vec::Vec;
+use guillotiere::{AllocId, Allocation, AtlasAllocator, size2};
+
+/// Manages multiple texture atlases.
+pub(crate) struct MultiAtlasManager {
+    /// All atlases managed by this instance.
+    atlases: Vec<Atlas>,
+    /// Configuration for atlas management.
+    config: AtlasConfig,
+    /// Next atlas Id to assign.
+    next_atlas_id: u32,
+    /// Round-robin counter for allocation strategy.
+    round_robin_counter: usize,
+}
+
+impl MultiAtlasManager {
+    /// Create a new multi-atlas manager with the given configuration.
+    pub(crate) fn new(config: AtlasConfig) -> Self {
+        let mut manager = Self {
+            atlases: Vec::new(),
+            config,
+            next_atlas_id: 0,
+            round_robin_counter: 0,
+        };
+
+        for _ in 0..config.initial_atlas_count {
+            manager
+                .create_atlas()
+                .expect("Failed to create initial atlas");
+        }
+
+        manager
+    }
+
+    /// Create a new multi-atlas manager with default configuration.
+    pub(crate) fn new_with_initial_atlas(width: u32, height: u32) -> Self {
+        let config = AtlasConfig {
+            atlas_size: (width, height),
+            ..Default::default()
+        };
+
+        Self::new(config)
+    }
+
+    /// Get the current configuration.
+    pub(crate) fn config(&self) -> &AtlasConfig {
+        &self.config
+    }
+
+    /// Create a new atlas and return its ID.
+    pub(crate) fn create_atlas(&mut self) -> Result<AtlasId, AtlasError> {
+        if self.atlases.len() >= self.config.max_atlases {
+            return Err(AtlasError::AtlasLimitReached);
+        }
+
+        let atlas_id = AtlasId::new(self.next_atlas_id);
+        self.next_atlas_id += 1;
+
+        let atlas = Atlas::new(atlas_id, self.config.atlas_size.0, self.config.atlas_size.1);
+        self.atlases.push(atlas);
+
+        Ok(atlas_id)
+    }
+
+    /// Check if a new atlas can be created.
+    pub(crate) fn can_create_new_atlas(&self) -> bool {
+        self.atlases.len() < self.config.max_atlases
+    }
+
+    /// Try to allocate space for an image with the given dimensions.
+    pub(crate) fn try_allocate(
+        &mut self,
+        width: u32,
+        height: u32,
+    ) -> Result<AtlasAllocation, AtlasError> {
+        // Check if the image is too large for any atlas
+        if width > self.config.atlas_size.0 || height > self.config.atlas_size.1 {
+            return Err(AtlasError::TextureTooLarge { width, height });
+        }
+
+        // If no atlases exist and auto-grow is enabled, create one
+        if self.atlases.is_empty() && self.config.auto_grow {
+            self.create_atlas()?;
+        }
+
+        // Try allocation based on strategy
+        match self.config.allocation_strategy {
+            AllocationStrategy::FirstFit => self.allocate_first_fit(width, height),
+            AllocationStrategy::BestFit => self.allocate_best_fit(width, height),
+            AllocationStrategy::LeastUsed => self.allocate_least_used(width, height),
+            AllocationStrategy::RoundRobin => self.allocate_round_robin(width, height),
+        }
+    }
+
+    /// Allocate using first-fit strategy: try atlases in order until one has space.
+    fn allocate_first_fit(
+        &mut self,
+        width: u32,
+        height: u32,
+    ) -> Result<AtlasAllocation, AtlasError> {
+        for atlas in &mut self.atlases {
+            if let Some(allocation) = atlas.allocate(width, height) {
+                return Ok(AtlasAllocation {
+                    atlas_id: atlas.id,
+                    allocation,
+                });
+            }
+        }
+
+        // Try creating a new atlas if auto-grow is enabled
+        if self.config.auto_grow && self.can_create_new_atlas() {
+            let atlas_id = self.create_atlas()?;
+            let atlas = self.atlases.last_mut().unwrap();
+            if let Some(allocation) = atlas.allocate(width, height) {
+                return Ok(AtlasAllocation {
+                    atlas_id,
+                    allocation,
+                });
+            }
+        }
+
+        Err(AtlasError::NoSpaceAvailable)
+    }
+
+    /// Allocate using best-fit strategy: choose the atlas with the smallest remaining space that
+    /// can fit the image.
+    fn allocate_best_fit(
+        &mut self,
+        width: u32,
+        height: u32,
+    ) -> Result<AtlasAllocation, AtlasError> {
+        let mut best_atlas_idx = None;
+        let mut best_remaining_space = u32::MAX;
+
+        // Find the atlas with the least remaining space that can fit the image
+        for (idx, atlas) in self.atlases.iter().enumerate() {
+            let stats = atlas.stats();
+            let remaining_space = stats.total_area - stats.allocated_area;
+
+            if remaining_space >= width * height && remaining_space < best_remaining_space {
+                best_remaining_space = remaining_space;
+                best_atlas_idx = Some(idx);
+            }
+        }
+
+        if let Some(idx) = best_atlas_idx {
+            let atlas = &mut self.atlases[idx];
+            if let Some(allocation) = atlas.allocate(width, height) {
+                return Ok(AtlasAllocation {
+                    atlas_id: atlas.id,
+                    allocation,
+                });
+            }
+        }
+
+        // Fallback to first-fit if best-fit didn't work
+        self.allocate_first_fit(width, height)
+    }
+
+    /// Allocate using least-used strategy: prefer the atlas with the lowest usage percentage.
+    fn allocate_least_used(
+        &mut self,
+        width: u32,
+        height: u32,
+    ) -> Result<AtlasAllocation, AtlasError> {
+        let mut best_atlas_idx = None;
+        let mut lowest_usage = f32::MAX;
+
+        // Find the atlas with the lowest usage percentage
+        for (idx, atlas) in self.atlases.iter().enumerate() {
+            let usage = atlas.stats().usage_percentage();
+            if usage < lowest_usage {
+                lowest_usage = usage;
+                best_atlas_idx = Some(idx);
+            }
+        }
+
+        if let Some(idx) = best_atlas_idx {
+            if let Some(allocation) = self.atlases[idx].allocate(width, height) {
+                let atlas_id = self.atlases[idx].id;
+                return Ok(AtlasAllocation {
+                    atlas_id,
+                    allocation,
+                });
+            }
+        }
+
+        // Fallback to first-fit if least-used didn't work
+        self.allocate_first_fit(width, height)
+    }
+
+    /// Allocate using round-robin strategy: cycle through atlases using a round-robin counter.
+    fn allocate_round_robin(
+        &mut self,
+        width: u32,
+        height: u32,
+    ) -> Result<AtlasAllocation, AtlasError> {
+        if self.atlases.is_empty() {
+            return self.allocate_first_fit(width, height);
+        }
+
+        let start_idx = self.round_robin_counter % self.atlases.len();
+
+        // Try starting from the round-robin position
+        for i in 0..self.atlases.len() {
+            let idx = (start_idx + i) % self.atlases.len();
+
+            if let Some(allocation) = self.atlases[idx].allocate(width, height) {
+                let atlas_id = self.atlases[idx].id;
+                self.round_robin_counter = (idx + 1) % self.atlases.len();
+                return Ok(AtlasAllocation {
+                    atlas_id,
+                    allocation,
+                });
+            }
+        }
+
+        // Try creating a new atlas if auto-grow is enabled
+        if self.config.auto_grow && self.can_create_new_atlas() {
+            let atlas_id = self.create_atlas()?;
+            let atlas = self.atlases.last_mut().unwrap();
+            if let Some(allocation) = atlas.allocate(width, height) {
+                self.round_robin_counter = self.atlases.len() - 1;
+                return Ok(AtlasAllocation {
+                    atlas_id,
+                    allocation,
+                });
+            }
+        }
+
+        Err(AtlasError::NoSpaceAvailable)
+    }
+
+    /// Deallocate space in the specified atlas.
+    pub(crate) fn deallocate(
+        &mut self,
+        atlas_id: AtlasId,
+        alloc_id: AllocId,
+        width: u32,
+        height: u32,
+    ) -> Result<(), AtlasError> {
+        for atlas in &mut self.atlases {
+            if atlas.id == atlas_id {
+                atlas.deallocate(alloc_id, width, height);
+                return Ok(());
+            }
+        }
+        Err(AtlasError::AtlasNotFound(atlas_id))
+    }
+
+    /// Get statistics for all atlases.
+    pub(crate) fn atlas_stats(&self) -> Vec<(AtlasId, &AtlasUsageStats)> {
+        self.atlases
+            .iter()
+            .map(|atlas| (atlas.id, atlas.stats()))
+            .collect()
+    }
+
+    /// Get the number of atlases.
+    pub(crate) fn atlas_count(&self) -> usize {
+        self.atlases.len()
+    }
+
+    /// Clear all atlases.
+    pub(crate) fn clear(&mut self) {
+        for atlas in &mut self.atlases {
+            atlas.clear();
+        }
+    }
+}
+
+impl core::fmt::Debug for MultiAtlasManager {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        f.debug_struct("MultiAtlasManager")
+            .field("atlas_count", &self.atlases.len())
+            .field("config", &self.config)
+            .field("next_atlas_id", &self.next_atlas_id)
+            .field("round_robin_counter", &self.round_robin_counter)
+            .field("atlases", &self.atlases)
+            .finish()
+    }
+}
+
+/// Represents a single atlas in the multi-atlas system.
+pub(crate) struct Atlas {
+    /// Unique identifier for this atlas.
+    pub id: AtlasId,
+    /// Guillotiere allocator for this atlas.
+    allocator: AtlasAllocator,
+    /// Current usage statistics.
+    stats: AtlasUsageStats,
+    /// Round-robin allocation counter.
+    allocation_counter: u32,
+}
+
+impl Atlas {
+    /// Create a new atlas with the given ID and size.
+    pub(crate) fn new(id: AtlasId, width: u32, height: u32) -> Self {
+        Self {
+            id,
+            allocator: AtlasAllocator::new(size2(width as i32, height as i32)),
+            stats: AtlasUsageStats {
+                allocated_area: 0,
+                total_area: width * height,
+                allocated_count: 0,
+            },
+            allocation_counter: 0,
+        }
+    }
+
+    /// Try to allocate an image in this atlas.
+    pub(crate) fn allocate(&mut self, width: u32, height: u32) -> Option<Allocation> {
+        if let Some(allocation) = self.allocator.allocate(size2(width as i32, height as i32)) {
+            self.stats.allocated_area += width * height;
+            self.stats.allocated_count += 1;
+            self.allocation_counter += 1;
+            Some(allocation)
+        } else {
+            None
+        }
+    }
+
+    /// Deallocate an image from this atlas.
+    pub(crate) fn deallocate(&mut self, alloc_id: AllocId, width: u32, height: u32) {
+        self.allocator.deallocate(alloc_id);
+        self.stats.allocated_area = self.stats.allocated_area.saturating_sub(width * height);
+        self.stats.allocated_count = self.stats.allocated_count.saturating_sub(1);
+    }
+
+    /// Get current usage statistics.
+    pub(crate) fn stats(&self) -> &AtlasUsageStats {
+        &self.stats
+    }
+
+    /// Clear all allocations in this atlas.
+    pub(crate) fn clear(&mut self) {
+        self.allocator.clear();
+        self.stats.allocated_area = 0;
+        self.stats.allocated_count = 0;
+        self.allocation_counter = 0;
+    }
+}
+
+impl core::fmt::Debug for Atlas {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        f.debug_struct("Atlas")
+            .field("id", &self.id)
+            .field("stats", &self.stats)
+            .field("allocation_counter", &self.allocation_counter)
+            .finish_non_exhaustive()
+    }
+}
+
+/// Errors that can occur during atlas operations.
+#[derive(Debug)]
+pub enum AtlasError {
+    /// No space available in any atlas.
+    NoSpaceAvailable,
+    /// Maximum number of atlases reached.
+    AtlasLimitReached,
+    /// The requested texture size is too large for any atlas.
+    TextureTooLarge {
+        /// The width of the requested texture.
+        width: u32,
+        /// The height of the requested texture.
+        height: u32,
+    },
+    /// Atlas with the given ID was not found.
+    AtlasNotFound(AtlasId),
+}
+
+impl core::fmt::Display for AtlasError {
+    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+        match self {
+            Self::NoSpaceAvailable => write!(f, "No space available in any atlas"),
+            Self::AtlasLimitReached => write!(f, "Maximum number of atlases reached"),
+            Self::TextureTooLarge { width, height } => {
+                write!(f, "Texture too large ({width}x{height}) for atlas")
+            }
+            Self::AtlasNotFound(id) => write!(f, "Atlas with ID {:?} not found", id),
+        }
+    }
+}
+
+/// Unique identifier for an atlas.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct AtlasId(pub u32);
+
+impl AtlasId {
+    /// Create a new atlas ID.
+    pub fn new(id: u32) -> Self {
+        Self(id)
+    }
+
+    /// Get the raw ID value.
+    pub fn as_u32(self) -> u32 {
+        self.0
+    }
+}
+
+impl Default for AllocationStrategy {
+    fn default() -> Self {
+        Self::FirstFit
+    }
+}
+
+/// Usage statistics for an atlas.
+#[derive(Debug, Clone)]
+pub(crate) struct AtlasUsageStats {
+    /// Total allocated area in pixels.
+    pub allocated_area: u32,
+    /// Total available area in pixels.
+    pub total_area: u32,
+    /// Number of allocated images.
+    pub allocated_count: u32,
+}
+
+impl AtlasUsageStats {
+    /// Calculate usage percentage (0.0 to 1.0).
+    pub(crate) fn usage_percentage(&self) -> f32 {
+        if self.total_area == 0 {
+            0.0
+        } else {
+            self.allocated_area as f32 / self.total_area as f32
+        }
+    }
+}
+
+/// Result of an atlas allocation attempt.
+pub(crate) struct AtlasAllocation {
+    /// The atlas where the allocation was made.
+    pub atlas_id: AtlasId,
+    /// The allocation details from guillotiere.
+    pub allocation: Allocation,
+}
+
+/// Configuration for multiple atlas support.
+#[derive(Debug, Clone, Copy)]
+pub struct AtlasConfig {
+    /// Initial number of atlases to create.
+    pub initial_atlas_count: usize,
+    /// Maximum number of atlases to create.
+    pub max_atlases: usize,
+    /// Size of each atlas texture.
+    pub atlas_size: (u32, u32),
+    /// Whether to automatically create new atlases when needed.
+    pub auto_grow: bool,
+    /// Strategy for allocating images across atlases.
+    pub allocation_strategy: AllocationStrategy,
+}
+
+impl Default for AtlasConfig {
+    fn default() -> Self {
+        Self {
+            initial_atlas_count: 1,
+            max_atlases: 8,
+            atlas_size: (4096, 4096),
+            auto_grow: true,
+            allocation_strategy: AllocationStrategy::FirstFit,
+        }
+    }
+}
+
+/// Strategy for allocating images across multiple atlases.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AllocationStrategy {
+    /// Try atlases in order until one has space.
+    FirstFit,
+    /// Choose the atlas with the smallest remaining space that can fit the image.
+    BestFit,
+    /// Prefer the atlas with the lowest usage percentage.
+    LeastUsed,
+    /// Cycle through atlases in round-robin fashion.
+    RoundRobin,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_atlas_creation() {
+        let mut manager = MultiAtlasManager::new(AtlasConfig {
+            initial_atlas_count: 0,
+            ..Default::default()
+        });
+
+        let atlas_id = manager.create_atlas().unwrap();
+        assert_eq!(atlas_id.as_u32(), 0);
+        assert_eq!(manager.atlas_count(), 1);
+    }
+
+    #[test]
+    fn test_allocation_strategies() {
+        let mut manager = MultiAtlasManager::new(AtlasConfig {
+            initial_atlas_count: 1,
+            max_atlases: 3,
+            atlas_size: (256, 256),
+            allocation_strategy: AllocationStrategy::FirstFit,
+            auto_grow: true,
+        });
+
+        // Should create atlas automatically
+        let allocation = manager.try_allocate(100, 100).unwrap();
+        assert_eq!(allocation.atlas_id.as_u32(), 0);
+    }
+
+    #[test]
+    fn test_atlas_limit() {
+        let mut manager = MultiAtlasManager::new(AtlasConfig {
+            initial_atlas_count: 1,
+            max_atlases: 1,
+            atlas_size: (256, 256),
+            allocation_strategy: AllocationStrategy::FirstFit,
+            auto_grow: false,
+        });
+
+        assert!(manager.create_atlas().is_err());
+    }
+
+    #[test]
+    fn test_texture_too_large() {
+        let mut manager = MultiAtlasManager::new(AtlasConfig {
+            atlas_size: (256, 256),
+            ..Default::default()
+        });
+
+        let result = manager.try_allocate(300, 300);
+        assert!(matches!(result, Err(AtlasError::TextureTooLarge { .. })));
+    }
+}
diff --git a/sparse_strips/vello_hybrid/src/render/common.rs b/sparse_strips/vello_hybrid/src/render/common.rs
index fc574fb..09f6cf9 100644
--- a/sparse_strips/vello_hybrid/src/render/common.rs
+++ b/sparse_strips/vello_hybrid/src/render/common.rs
@@ -116,10 +116,12 @@
     pub image_size: u32,
     /// The offset of the image in the atlas texture in pixels.
     pub image_offset: u32,
+    /// The atlas index containing this image.
+    pub atlas_index: u32,
     /// Transform matrix [a, b, c, d, tx, ty].
     pub transform: [f32; 6],
-    /// Padding for 16-byte alignment
-    pub _padding: [u32; 3],
+    /// Padding for 16-byte alignment.
+    pub _padding: [u32; 2],
 }
 
 /// GPU encoded linear gradient data.
diff --git a/sparse_strips/vello_hybrid/src/render/webgl.rs b/sparse_strips/vello_hybrid/src/render/webgl.rs
index 217a11b..27049b0 100644
--- a/sparse_strips/vello_hybrid/src/render/webgl.rs
+++ b/sparse_strips/vello_hybrid/src/render/webgl.rs
@@ -21,7 +21,7 @@
 )]
 
 use crate::{
-    GpuStrip, RenderError, RenderSettings, RenderSize,
+    AtlasConfig, GpuStrip, RenderError, RenderSettings, RenderSize,
     gradient_cache::GradientRampCache,
     image_cache::{ImageCache, ImageResource},
     render::{
@@ -38,6 +38,7 @@
     schedule::{LoadOp, RendererBackend, Scheduler},
 };
 
+use alloc::sync::Arc;
 use alloc::vec;
 use alloc::vec::Vec;
 use bytemuck::{Pod, Zeroable};
@@ -48,6 +49,7 @@
     kurbo::Affine,
     paint::ImageSource,
     peniko,
+    pixmap::Pixmap,
     tile::Tile,
 };
 use vello_sparse_shaders::{clear_slots, render_strips};
@@ -139,7 +141,7 @@
 
         let max_texture_dimension_2d = get_max_texture_dimension_2d(&gl);
         let total_slots: usize = (max_texture_dimension_2d / u32::from(Tile::HEIGHT)) as usize;
-        let image_cache = ImageCache::new(max_texture_dimension_2d, max_texture_dimension_2d);
+        let image_cache = ImageCache::new_with_config(settings.atlas_config);
         // Estimate the maximum number of gradient cache entries based on the max texture dimension
         // and the maximum gradient LUT size - worst case scenario.
         let max_gradient_cache_size =
@@ -147,7 +149,7 @@
         let gradient_cache = GradientRampCache::new(max_gradient_cache_size, settings.level);
 
         Self {
-            programs: WebGlPrograms::new(gl.clone(), total_slots),
+            programs: WebGlPrograms::new(gl.clone(), &image_cache, total_slots),
             scheduler: Scheduler::new(total_slots),
             gl,
             image_cache,
@@ -249,6 +251,13 @@
         Ok(())
     }
 
+    /// Get a reference to the underlying WebGL context.
+    ///
+    /// This allows direct access to WebGL operations for advanced use cases like texture creation.
+    pub fn gl_context(&self) -> &WebGl2RenderingContext {
+        &self.gl
+    }
+
     /// Upload image to cache and atlas in one step. Returns the `ImageId`.
     ///
     /// This is the WebGL analogue of the wgpu Renderer's `upload_image` method.
@@ -259,19 +268,23 @@
     ) -> vello_common::paint::ImageId {
         let width = writer.width();
         let height = writer.height();
-        let image_id = self.image_cache.allocate(width, height);
+        let image_id = self.image_cache.allocate(width, height).unwrap();
         let image_resource = self
             .image_cache
             .get(image_id)
             .expect("Image resource not found");
+
+        self.programs
+            .maybe_resize_atlas_texture_array(&self.gl, self.image_cache.atlas_count() as u32);
         let offset = [
             image_resource.offset[0] as u32,
             image_resource.offset[1] as u32,
         ];
-
-        writer.write_to_atlas(
+        // Write to the appropriate layer in the atlas texture array
+        writer.write_to_atlas_layer(
             &self.gl,
-            &self.programs.resources.atlas_texture,
+            &self.programs.resources.atlas_texture_array.texture,
+            image_resource.atlas_id.as_u32(),
             offset,
             width,
             height,
@@ -280,17 +293,11 @@
         image_id
     }
 
-    /// Get a reference to the underlying WebGL context.
-    ///
-    /// This allows direct access to WebGL operations for advanced use cases like texture creation.
-    pub fn gl_context(&self) -> &WebGl2RenderingContext {
-        &self.gl
-    }
-
     /// Destroy an image from the cache and clear the allocated slot in the atlas.
     pub fn destroy_image(&mut self, image_id: vello_common::paint::ImageId) {
         if let Some(image_resource) = self.image_cache.deallocate(image_id) {
             self.clear_atlas_region(
+                image_resource.atlas_id,
                 [
                     image_resource.offset[0] as u32,
                     image_resource.offset[1] as u32,
@@ -301,25 +308,33 @@
         }
     }
 
-    /// Clear a specific region of the atlas texture with uninitialized data.
-    fn clear_atlas_region(&mut self, offset: [u32; 2], width: u32, height: u32) {
+    /// Clear a specific region of the atlas texture array with uninitialized data.
+    fn clear_atlas_region(
+        &mut self,
+        atlas_id: crate::AtlasId,
+        offset: [u32; 2],
+        width: u32,
+        height: u32,
+    ) {
         // Rgba8Unorm is 4 bytes per pixel
         let uninitialized_data = vec![0_u8; (width * height * 4) as usize];
 
         self.gl.active_texture(WebGl2RenderingContext::TEXTURE0);
         self.gl.bind_texture(
-            WebGl2RenderingContext::TEXTURE_2D,
-            Some(&self.programs.resources.atlas_texture),
+            WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+            Some(&self.programs.resources.atlas_texture_array.texture),
         );
 
         self.gl
-            .tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_u8_array(
-                WebGl2RenderingContext::TEXTURE_2D,
-                0,                // mip level
-                offset[0] as i32, // x offset
-                offset[1] as i32, // y offset
+            .tex_sub_image_3d_with_opt_u8_array(
+                WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+                0,
+                offset[0] as i32,
+                offset[1] as i32,
+                atlas_id.as_u32() as i32,
                 width as i32,
                 height as i32,
+                1,
                 WebGl2RenderingContext::RGBA,
                 WebGl2RenderingContext::UNSIGNED_BYTE,
                 Some(&uninitialized_data),
@@ -390,8 +405,9 @@
             quality_and_extend_modes,
             image_size,
             image_offset,
+            atlas_index: image_resource.atlas_id.as_u32(),
             transform,
-            _padding: [0, 0, 0],
+            _padding: [0, 0],
         })
     }
 
@@ -501,7 +517,7 @@
     /// Clip input texture location.
     clip_input_texture: WebGlUniformLocation,
     /// Atlas texture location.
-    atlas_texture: WebGlUniformLocation,
+    atlas_texture_array: WebGlUniformLocation,
     /// Encoded paints texture location for fragment shader.
     encoded_paints_texture_fs: WebGlUniformLocation,
     /// Encoded paints texture location for vertex shader.
@@ -528,9 +544,8 @@
     alphas_texture: WebGlTexture,
     /// Height of alpha texture.
     alpha_texture_height: u32,
-    /// Texture for atlas data
-    // TODO: Support more than one atlas texture
-    atlas_texture: WebGlTexture,
+    /// Texture array for atlas data (multiple atlases supported)
+    atlas_texture_array: WebGlTextureArray,
     /// Encoded paints texture for image metadata.
     encoded_paints_texture: WebGlTexture,
     /// Height of encoded paints texture.
@@ -583,7 +598,7 @@
 
 impl WebGlPrograms {
     /// Creates programs and initializes resources.
-    fn new(gl: WebGl2RenderingContext, slot_count: usize) -> Self {
+    fn new(gl: WebGl2RenderingContext, image_cache: &ImageCache, slot_count: usize) -> Self {
         let strip_program = create_shader_program(
             &gl,
             render_strips::VERTEX_SOURCE,
@@ -598,7 +613,7 @@
         let strip_uniforms = get_strip_uniforms(&gl, &strip_program);
         let clear_uniforms = get_clear_uniforms(&gl, &clear_program);
 
-        let resources = create_webgl_resources(&gl, slot_count);
+        let resources = create_webgl_resources(&gl, image_cache, slot_count);
 
         initialize_strip_vao(&gl, &resources);
         initialize_clear_vao(&gl, &resources);
@@ -655,6 +670,112 @@
         self.clear_view_framebuffer(gl);
     }
 
+    /// Resize atlas texture array to accommodate more atlases.
+    fn maybe_resize_atlas_texture_array(
+        &mut self,
+        gl: &WebGl2RenderingContext,
+        required_atlas_count: u32,
+    ) {
+        let WebGlTextureSize {
+            width,
+            height,
+            depth_or_array_layers: current_atlas_count,
+        } = self.resources.atlas_texture_array.size();
+        if required_atlas_count > current_atlas_count {
+            // Create new texture array with more layers
+            let new_atlas_texture_array =
+                create_atlas_texture_array(gl, width, height, required_atlas_count);
+
+            // Copy existing atlas data from old texture array to new one
+            self.copy_atlas_texture_data(gl, &new_atlas_texture_array, current_atlas_count);
+
+            // Replace the old resources
+            self.resources.atlas_texture_array = new_atlas_texture_array;
+        }
+    }
+
+    /// 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();
+
+        // Create framebuffers for read and draw operations
+        let read_framebuffer = gl.create_framebuffer().unwrap();
+        let draw_framebuffer = gl.create_framebuffer().unwrap();
+
+        // Save original framebuffer bindings
+        let original_read_fb = gl
+            .get_parameter(WebGl2RenderingContext::READ_FRAMEBUFFER_BINDING)
+            .ok()
+            .and_then(|v| v.dyn_into::<web_sys::WebGlFramebuffer>().ok());
+        let original_draw_fb = gl
+            .get_parameter(WebGl2RenderingContext::DRAW_FRAMEBUFFER_BINDING)
+            .ok()
+            .and_then(|v| v.dyn_into::<web_sys::WebGlFramebuffer>().ok());
+
+        // Copy each layer using blitFramebuffer
+        for layer in 0..layer_count_to_copy {
+            // Attach source layer to read framebuffer
+            gl.bind_framebuffer(
+                WebGl2RenderingContext::READ_FRAMEBUFFER,
+                Some(&read_framebuffer),
+            );
+            gl.framebuffer_texture_layer(
+                WebGl2RenderingContext::READ_FRAMEBUFFER,
+                WebGl2RenderingContext::COLOR_ATTACHMENT0,
+                Some(&self.resources.atlas_texture_array.texture),
+                0,
+                layer as i32,
+            );
+
+            // Attach destination layer to draw framebuffer
+            gl.bind_framebuffer(
+                WebGl2RenderingContext::DRAW_FRAMEBUFFER,
+                Some(&draw_framebuffer),
+            );
+            gl.framebuffer_texture_layer(
+                WebGl2RenderingContext::DRAW_FRAMEBUFFER,
+                WebGl2RenderingContext::COLOR_ATTACHMENT0,
+                Some(&new_atlas_texture_array.texture),
+                0,
+                layer as i32,
+            );
+
+            // Perform the blit operation
+            gl.blit_framebuffer(
+                0,
+                0,
+                width as i32,
+                height as i32,
+                0,
+                0,
+                width as i32,
+                height as i32,
+                WebGl2RenderingContext::COLOR_BUFFER_BIT,
+                WebGl2RenderingContext::NEAREST,
+            );
+        }
+
+        // Restore original framebuffer bindings
+        gl.bind_framebuffer(
+            WebGl2RenderingContext::READ_FRAMEBUFFER,
+            original_read_fb.as_ref(),
+        );
+        gl.bind_framebuffer(
+            WebGl2RenderingContext::DRAW_FRAMEBUFFER,
+            original_draw_fb.as_ref(),
+        );
+
+        // Clean up
+        gl.delete_framebuffer(Some(&read_framebuffer));
+        gl.delete_framebuffer(Some(&draw_framebuffer));
+    }
+
     /// Update the alpha texture size if needed.
     fn maybe_resize_alphas_tex(&mut self, max_texture_dimension_2d: u32, alphas: &[u8]) {
         let required_alpha_height = (alphas.len() as u32)
@@ -1064,7 +1185,7 @@
     // Get texture uniform locations.
     let alphas_texture_name = render_strips::fragment::ALPHAS_TEXTURE;
     let clip_input_texture_name = render_strips::fragment::CLIP_INPUT_TEXTURE;
-    let atlas_texture_name = render_strips::fragment::ATLAS_TEXTURE;
+    let atlas_texture_array_name = render_strips::fragment::ATLAS_TEXTURE_ARRAY;
     let encoded_paints_texture_fs_name = render_strips::fragment::ENCODED_PAINTS_TEXTURE;
     let encoded_paints_texture_vs_name = render_strips::vertex::ENCODED_PAINTS_TEXTURE;
     let gradient_texture_name = render_strips::fragment::GRADIENT_TEXTURE;
@@ -1078,8 +1199,8 @@
         clip_input_texture: gl
             .get_uniform_location(program, clip_input_texture_name)
             .unwrap(),
-        atlas_texture: gl
-            .get_uniform_location(program, atlas_texture_name)
+        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)
@@ -1111,7 +1232,11 @@
 }
 
 /// Create all WebGL resources needed for rendering.
-fn create_webgl_resources(gl: &WebGl2RenderingContext, slot_count: usize) -> WebGlResources {
+fn create_webgl_resources(
+    gl: &WebGl2RenderingContext,
+    image_cache: &ImageCache,
+    slot_count: usize,
+) -> WebGlResources {
     let strip_vao = gl.create_vertex_array().unwrap();
     let clear_vao = gl.create_vertex_array().unwrap();
 
@@ -1148,47 +1273,13 @@
         );
     }
 
-    // Create and configure atlas texture.
-    let atlas_texture = gl.create_texture().unwrap();
-    {
-        gl.active_texture(WebGl2RenderingContext::TEXTURE0);
-        gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&atlas_texture));
-        gl.tex_parameteri(
-            WebGl2RenderingContext::TEXTURE_2D,
-            WebGl2RenderingContext::TEXTURE_MIN_FILTER,
-            WebGl2RenderingContext::LINEAR as i32,
-        );
-        gl.tex_parameteri(
-            WebGl2RenderingContext::TEXTURE_2D,
-            WebGl2RenderingContext::TEXTURE_MAG_FILTER,
-            WebGl2RenderingContext::LINEAR as i32,
-        );
-        gl.tex_parameteri(
-            WebGl2RenderingContext::TEXTURE_2D,
-            WebGl2RenderingContext::TEXTURE_WRAP_S,
-            WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
-        );
-        gl.tex_parameteri(
-            WebGl2RenderingContext::TEXTURE_2D,
-            WebGl2RenderingContext::TEXTURE_WRAP_T,
-            WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
-        );
-
-        // Initialize with empty texture data
-        let max_texture_dimension_2d = get_max_texture_dimension_2d(gl);
-        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,
-            max_texture_dimension_2d as i32,
-            max_texture_dimension_2d as i32,
-            0,
-            WebGl2RenderingContext::RGBA,
-            WebGl2RenderingContext::UNSIGNED_BYTE,
-            None,
-        )
-        .unwrap();
-    }
+    let AtlasConfig {
+        atlas_size: (atlas_width, atlas_height),
+        initial_atlas_count,
+        ..
+    } = image_cache.atlas_manager().config();
+    let atlas_texture_array =
+        create_atlas_texture_array(gl, *atlas_width, *atlas_height, *initial_atlas_count as u32);
 
     // Create and configure encoded paints texture.
     let encoded_paints_texture = gl.create_texture().unwrap();
@@ -1294,7 +1385,7 @@
         strips_buffer,
         alphas_texture,
         alpha_texture_height: 0,
-        atlas_texture,
+        atlas_texture_array,
         encoded_paints_texture,
         encoded_paints_texture_height: 0,
         gradient_texture,
@@ -1312,6 +1403,59 @@
     }
 }
 
+/// Create an atlas texture array.
+fn create_atlas_texture_array(
+    gl: &WebGl2RenderingContext,
+    width: u32,
+    height: u32,
+    layer_count: u32,
+) -> WebGlTextureArray {
+    let atlas_texture = gl.create_texture().unwrap();
+    gl.active_texture(WebGl2RenderingContext::TEXTURE0);
+    gl.bind_texture(
+        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+        Some(&atlas_texture),
+    );
+
+    gl.tex_parameteri(
+        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+        WebGl2RenderingContext::TEXTURE_MIN_FILTER,
+        WebGl2RenderingContext::LINEAR as i32,
+    );
+    gl.tex_parameteri(
+        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+        WebGl2RenderingContext::TEXTURE_MAG_FILTER,
+        WebGl2RenderingContext::LINEAR as i32,
+    );
+    gl.tex_parameteri(
+        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+        WebGl2RenderingContext::TEXTURE_WRAP_S,
+        WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
+    );
+    gl.tex_parameteri(
+        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+        WebGl2RenderingContext::TEXTURE_WRAP_T,
+        WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
+    );
+
+    // Initialize with empty texture array data
+    gl.tex_image_3d_with_opt_u8_array(
+        WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+        0,
+        WebGl2RenderingContext::RGBA8 as i32,
+        width as i32,
+        height as i32,
+        layer_count as i32,
+        0,
+        WebGl2RenderingContext::RGBA,
+        WebGl2RenderingContext::UNSIGNED_BYTE,
+        None,
+    )
+    .unwrap();
+
+    WebGlTextureArray::new(atlas_texture, width, height, layer_count)
+}
+
 /// Create a texture for slot rendering.
 fn create_slot_texture(gl: &WebGl2RenderingContext, slot_count: usize) -> WebGlTexture {
     let texture = gl.create_texture().unwrap();
@@ -1533,14 +1677,14 @@
         self.gl
             .uniform1i(Some(&self.programs.strip_uniforms.clip_input_texture), 1);
 
-        // Bind atlas texture for image rendering
+        // Bind atlas texture array for image rendering
         self.gl.active_texture(WebGl2RenderingContext::TEXTURE2);
         self.gl.bind_texture(
-            WebGl2RenderingContext::TEXTURE_2D,
-            Some(&self.programs.resources.atlas_texture),
+            WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+            Some(&self.programs.resources.atlas_texture_array.texture),
         );
         self.gl
-            .uniform1i(Some(&self.programs.strip_uniforms.atlas_texture), 2);
+            .uniform1i(Some(&self.programs.strip_uniforms.atlas_texture_array), 2);
 
         // Bind encoded paints texture for image metadata
         self.gl.active_texture(WebGl2RenderingContext::TEXTURE3);
@@ -1665,19 +1809,20 @@
     /// Get the height of the image.
     fn height(&self) -> u32;
 
-    /// Write image data to the atlas texture at the specified offset.
-    fn write_to_atlas(
+    /// Write image data to a specific layer of an atlas texture array at the specified offset.
+    fn write_to_atlas_layer(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
+        atlas_texture_array: &WebGlTexture,
+        layer: u32,
         offset: [u32; 2],
         width: u32,
         height: u32,
     );
 }
 
-/// Implementation for `Pixmap` - direct upload using raw pixel data
-impl WebGlAtlasWriter for crate::Pixmap {
+/// Implementation for `Pixmap` - direct upload using raw pixel data.
+impl WebGlAtlasWriter for Pixmap {
     fn width(&self) -> u32 {
         self.width() as u32
     }
@@ -1686,29 +1831,35 @@
         self.height() as u32
     }
 
-    fn write_to_atlas(
+    fn write_to_atlas_layer(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
+        atlas_texture_array: &WebGlTexture,
+        layer: u32,
         offset: [u32; 2],
         width: u32,
         height: u32,
     ) {
-        // Bind the atlas texture
+        // Bind the atlas texture array
         gl.active_texture(WebGl2RenderingContext::TEXTURE0);
-        gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(atlas_texture));
+        gl.bind_texture(
+            WebGl2RenderingContext::TEXTURE_2D_ARRAY,
+            Some(atlas_texture_array),
+        );
 
         // Convert pixmap data to the format expected by WebGL
         let rgba_data = self.data_as_u8_slice();
 
-        // Upload the image data to the specific region of the atlas texture
-        gl.tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_u8_array(
-            WebGl2RenderingContext::TEXTURE_2D,
-            0,                // mip level
-            offset[0] as i32, // x offset
-            offset[1] as i32, // y offset
+        // 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,
+            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),
@@ -1717,8 +1868,8 @@
     }
 }
 
-/// Implementation for `Arc<Pixmap>`
-impl WebGlAtlasWriter for alloc::sync::Arc<crate::Pixmap> {
+/// Implementation for `Arc<Pixmap>`.
+impl WebGlAtlasWriter for Arc<Pixmap> {
     fn width(&self) -> u32 {
         self.as_ref().width() as u32
     }
@@ -1727,20 +1878,21 @@
         self.as_ref().height() as u32
     }
 
-    fn write_to_atlas(
+    fn write_to_atlas_layer(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
+        atlas_texture_array: &WebGlTexture,
+        layer: u32,
         offset: [u32; 2],
         width: u32,
         height: u32,
     ) {
         self.as_ref()
-            .write_to_atlas(gl, atlas_texture, offset, width, height);
+            .write_to_atlas_layer(gl, atlas_texture_array, layer, offset, width, height);
     }
 }
 
-/// Implementation for `WebGlTexture` - texture-to-texture copy
+/// Implementation for `WebGlTexture` - texture-to-texture copy.
 impl WebGlAtlasWriter for WebGlTexture {
     fn width(&self) -> u32 {
         // WebGL textures don't expose their dimensions directly
@@ -1756,56 +1908,86 @@
         unreachable!("WebGlTexture height must be provided by caller")
     }
 
-    fn write_to_atlas(
+    fn write_to_atlas_layer(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
+        atlas_texture_array: &WebGlTexture,
+        layer: u32,
         offset: [u32; 2],
         width: u32,
         height: u32,
     ) {
-        // Create a temporary framebuffer for the source texture
-        let framebuffer = gl.create_framebuffer().unwrap();
-        gl.bind_framebuffer(WebGl2RenderingContext::FRAMEBUFFER, Some(&framebuffer));
+        // Create framebuffers for read and draw operations
+        let read_framebuffer = gl.create_framebuffer().unwrap();
+        let draw_framebuffer = gl.create_framebuffer().unwrap();
 
-        // Attach the source texture to the framebuffer
+        // Save original framebuffer bindings
+        let original_read_fb = gl
+            .get_parameter(WebGl2RenderingContext::READ_FRAMEBUFFER_BINDING)
+            .ok()
+            .and_then(|v| v.dyn_into::<web_sys::WebGlFramebuffer>().ok());
+        let original_draw_fb = gl
+            .get_parameter(WebGl2RenderingContext::DRAW_FRAMEBUFFER_BINDING)
+            .ok()
+            .and_then(|v| v.dyn_into::<web_sys::WebGlFramebuffer>().ok());
+
+        // Attach source texture to read framebuffer
+        gl.bind_framebuffer(
+            WebGl2RenderingContext::READ_FRAMEBUFFER,
+            Some(&read_framebuffer),
+        );
         gl.framebuffer_texture_2d(
-            WebGl2RenderingContext::FRAMEBUFFER,
+            WebGl2RenderingContext::READ_FRAMEBUFFER,
             WebGl2RenderingContext::COLOR_ATTACHMENT0,
             WebGl2RenderingContext::TEXTURE_2D,
             Some(self),
-            0, // mip level
+            0,
         );
 
-        // Verify framebuffer is complete
-        let status = gl.check_framebuffer_status(WebGl2RenderingContext::FRAMEBUFFER);
-        if status != WebGl2RenderingContext::FRAMEBUFFER_COMPLETE {
-            panic!("Framebuffer not complete: {status}");
-        }
+        // Attach destination layer to draw framebuffer
+        gl.bind_framebuffer(
+            WebGl2RenderingContext::DRAW_FRAMEBUFFER,
+            Some(&draw_framebuffer),
+        );
+        gl.framebuffer_texture_layer(
+            WebGl2RenderingContext::DRAW_FRAMEBUFFER,
+            WebGl2RenderingContext::COLOR_ATTACHMENT0,
+            Some(atlas_texture_array),
+            0,
+            layer as i32,
+        );
 
-        // Bind the atlas texture as the target
-        gl.active_texture(WebGl2RenderingContext::TEXTURE0);
-        gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(atlas_texture));
-
-        // Copy from framebuffer to atlas texture
-        gl.copy_tex_sub_image_2d(
-            WebGl2RenderingContext::TEXTURE_2D,
-            0,                // mip level
-            offset[0] as i32, // x offset in atlas
-            offset[1] as i32, // y offset in atlas
-            0,                // x coordinate in source framebuffer
-            0,                // y coordinate in source framebuffer
+        // Perform the blit operation
+        gl.blit_framebuffer(
+            0,
+            0,
             width as i32,
             height as i32,
+            offset[0] as i32,
+            offset[1] as i32,
+            (offset[0] + width) as i32,
+            (offset[1] + height) as i32,
+            WebGl2RenderingContext::COLOR_BUFFER_BIT,
+            WebGl2RenderingContext::NEAREST,
+        );
+
+        // Restore original framebuffer bindings
+        gl.bind_framebuffer(
+            WebGl2RenderingContext::READ_FRAMEBUFFER,
+            original_read_fb.as_ref(),
+        );
+        gl.bind_framebuffer(
+            WebGl2RenderingContext::DRAW_FRAMEBUFFER,
+            original_draw_fb.as_ref(),
         );
 
         // Clean up
-        gl.bind_framebuffer(WebGl2RenderingContext::FRAMEBUFFER, None);
-        gl.delete_framebuffer(Some(&framebuffer));
+        gl.delete_framebuffer(Some(&read_framebuffer));
+        gl.delete_framebuffer(Some(&draw_framebuffer));
     }
 }
 
-/// Wrapper for `WebGlTexture` with known dimensions to work around WebGL API limitations
+/// Wrapper for `WebGlTexture` with known dimensions.
 #[derive(Debug)]
 pub struct WebGlTextureWithDimensions {
     /// The WebGL texture.
@@ -1825,15 +2007,55 @@
         self.height
     }
 
-    fn write_to_atlas(
+    fn write_to_atlas_layer(
         &self,
         gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
+        atlas_texture_array: &WebGlTexture,
+        layer: u32,
         offset: [u32; 2],
         width: u32,
         height: u32,
     ) {
         self.texture
-            .write_to_atlas(gl, atlas_texture, offset, width, height);
+            .write_to_atlas_layer(gl, atlas_texture_array, layer, offset, width, height);
     }
 }
+
+/// Wrapper for `WebGlTexture` array with known dimensions.
+#[derive(Debug)]
+struct WebGlTextureArray {
+    /// The WebGL texture array.
+    texture: WebGlTexture,
+    /// The size of the texture array.
+    size: WebGlTextureSize,
+}
+
+impl WebGlTextureArray {
+    /// Create a new WebGL texture array wrapper.
+    fn new(texture: WebGlTexture, width: u32, height: u32, depth_or_array_layers: u32) -> Self {
+        Self {
+            texture,
+            size: WebGlTextureSize {
+                width,
+                height,
+                depth_or_array_layers,
+            },
+        }
+    }
+
+    /// 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,
+    /// The number of layers in the texture array.
+    depth_or_array_layers: u32,
+}
diff --git a/sparse_strips/vello_hybrid/src/render/wgpu.rs b/sparse_strips/vello_hybrid/src/render/wgpu.rs
index 2fb20b7..a4439c1 100644
--- a/sparse_strips/vello_hybrid/src/render/wgpu.rs
+++ b/sparse_strips/vello_hybrid/src/render/wgpu.rs
@@ -18,10 +18,12 @@
 only break in edge cases, and some of them are also only related to conversions from f64 to f32."
 )]
 
-use alloc::vec;
 use alloc::vec::Vec;
+use alloc::{sync::Arc, vec};
 use core::{fmt::Debug, mem, num::NonZeroU64};
+use wgpu::Extent3d;
 
+use crate::{AtlasConfig, AtlasId};
 use crate::{
     GpuStrip, RenderError, RenderSettings, RenderSize,
     gradient_cache::GradientRampCache,
@@ -106,7 +108,7 @@
 
         let max_texture_dimension_2d = device.limits().max_texture_dimension_2d;
         let total_slots = (max_texture_dimension_2d / u32::from(Tile::HEIGHT)) as usize;
-        let image_cache = ImageCache::new(max_texture_dimension_2d, max_texture_dimension_2d);
+        let image_cache = ImageCache::new_with_config(settings.atlas_config);
         // Estimate the maximum number of gradient cache entries based on the max texture dimension
         // and the maximum gradient LUT size - worst case scenario.
         let max_gradient_cache_size =
@@ -114,7 +116,7 @@
         let gradient_cache = GradientRampCache::new(max_gradient_cache_size, settings.level);
 
         Self {
-            programs: Programs::new(device, render_target_config, total_slots),
+            programs: Programs::new(device, &image_cache, render_target_config, total_slots),
             scheduler: Scheduler::new(total_slots),
             image_cache,
             gradient_cache,
@@ -180,21 +182,29 @@
     ) -> vello_common::paint::ImageId {
         let width = writer.width();
         let height = writer.height();
-        let image_id = self.image_cache.allocate(width, height);
+        let image_id = self.image_cache.allocate(width, height).unwrap();
         let image_resource = self
             .image_cache
             .get(image_id)
             .expect("Image resource not found");
+
+        Programs::maybe_resize_atlas_texture_array(
+            device,
+            queue,
+            &mut self.programs.resources,
+            &self.programs.atlas_bind_group_layout,
+            self.image_cache.atlas_count() as u32,
+        );
         let offset = [
             image_resource.offset[0] as u32,
             image_resource.offset[1] as u32,
         ];
-
-        writer.write_to_atlas(
+        writer.write_to_atlas_layer(
             device,
             queue,
             encoder,
-            &self.programs.resources.atlas_texture,
+            &self.programs.resources.atlas_texture_array,
+            image_resource.atlas_id.as_u32(),
             offset,
             width,
             height,
@@ -216,6 +226,7 @@
                 device,
                 queue,
                 encoder,
+                image_resource.atlas_id,
                 [
                     image_resource.offset[0] as u32,
                     image_resource.offset[1] as u32,
@@ -232,6 +243,7 @@
         _device: &Device,
         queue: &Queue,
         _encoder: &mut CommandEncoder,
+        atlas_id: AtlasId,
         offset: [u32; 2],
         width: u32,
         height: u32,
@@ -240,12 +252,12 @@
         let uninitialized_data = vec![0_u8; (width * height * 4) as usize];
         queue.write_texture(
             wgpu::TexelCopyTextureInfo {
-                texture: &self.programs.resources.atlas_texture,
+                texture: &self.programs.resources.atlas_texture_array,
                 mip_level: 0,
                 origin: wgpu::Origin3d {
                     x: offset[0],
                     y: offset[1],
-                    z: 0,
+                    z: atlas_id.as_u32(),
                 },
                 aspect: wgpu::TextureAspect::All,
             },
@@ -326,8 +338,9 @@
             quality_and_extend_modes,
             image_size,
             image_offset,
+            atlas_index: image_resource.atlas_id.as_u32(),
             transform,
-            _padding: [0, 0, 0],
+            _padding: [0, 0],
         })
     }
 
@@ -415,10 +428,10 @@
     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,
     /// Pipeline for clearing slots in slot textures.
     clear_pipeline: RenderPipeline,
-
     /// GPU resources for rendering (created during prepare)
     resources: GpuResources,
     /// Dimensions of the rendering target
@@ -436,10 +449,11 @@
     strips_buffer: Buffer,
     /// Texture for alpha values (used by both view and slot rendering)
     alphas_texture: Texture,
-    /// Texture for atlas data
-    // TODO: Support more than one atlas texture
-    atlas_texture: Texture,
-    /// Bind group for atlas texture
+    /// Textures for atlas data (multiple atlases supported)
+    atlas_texture_array: Texture,
+    /// View for atlas texture array
+    atlas_texture_array_view: TextureView,
+    /// Bind group for atlas textures (as texture array)
     atlas_bind_group: BindGroup,
     /// Texture for encoded paints
     encoded_paints_texture: Texture,
@@ -496,7 +510,12 @@
 }
 
 impl Programs {
-    fn new(device: &Device, render_target_config: &RenderTargetConfig, slot_count: usize) -> Self {
+    fn new(
+        device: &Device,
+        image_cache: &ImageCache,
+        render_target_config: &RenderTargetConfig,
+        slot_count: usize,
+    ) -> Self {
         let strip_bind_group_layout =
             device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
                 label: Some("Strip Bind Group Layout"),
@@ -542,7 +561,7 @@
                     visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
                     ty: wgpu::BindingType::Texture {
                         sample_type: wgpu::TextureSampleType::Float { filterable: true },
-                        view_dimension: wgpu::TextureViewDimension::D2,
+                        view_dimension: wgpu::TextureViewDimension::D2Array,
                         multisampled: false,
                     },
                     count: None,
@@ -734,12 +753,12 @@
                 resource: clear_config_buffer.as_entire_binding(),
             }],
         });
-        let clear_slot_indices_buffer = Self::make_clear_slot_indices_buffer(
+        let clear_slot_indices_buffer = Self::create_clear_slot_indices_buffer(
             device,
             slot_count as u64 * size_of::<u32>() as u64,
         );
 
-        let slot_config_buffer = Self::make_config_buffer(
+        let slot_config_buffer = Self::create_config_buffer(
             device,
             &RenderSize {
                 width: u32::from(WideTile::WIDTH),
@@ -750,14 +769,14 @@
 
         let max_texture_dimension_2d = device.limits().max_texture_dimension_2d;
         const INITIAL_ALPHA_TEXTURE_HEIGHT: u32 = 1;
-        let alphas_texture = Self::make_alphas_texture(
+        let alphas_texture = Self::create_alphas_texture(
             device,
             max_texture_dimension_2d,
             INITIAL_ALPHA_TEXTURE_HEIGHT,
         );
         let alpha_data =
             vec![0; ((max_texture_dimension_2d * INITIAL_ALPHA_TEXTURE_HEIGHT) << 4) as usize];
-        let view_config_buffer = Self::make_config_buffer(
+        let view_config_buffer = Self::create_config_buffer(
             device,
             &RenderSize {
                 width: render_target_config.width,
@@ -765,45 +784,54 @@
             },
             max_texture_dimension_2d,
         );
-        let atlas_texture = Self::make_atlas_texture(
+
+        let AtlasConfig {
+            atlas_size: (atlas_width, atlas_height),
+            initial_atlas_count,
+            ..
+        } = image_cache.atlas_manager().config();
+        let (atlas_texture_array, atlas_texture_array_view) = Self::create_atlas_texture_array(
             device,
-            max_texture_dimension_2d,
-            max_texture_dimension_2d,
-            wgpu::TextureFormat::Rgba8Unorm,
+            *atlas_width,
+            *atlas_height,
+            *initial_atlas_count as u32,
         );
-        let atlas_bind_group = Self::make_atlas_bind_group(
+        let atlas_bind_group = Self::create_atlas_bind_group(
             device,
             &atlas_bind_group_layout,
-            &atlas_texture.create_view(&Default::default()),
+            &atlas_texture_array_view,
         );
+
         const INITIAL_ENCODED_PAINTS_TEXTURE_HEIGHT: u32 = 1;
         let encoded_paints_data = vec![
             0;
             ((max_texture_dimension_2d * INITIAL_ENCODED_PAINTS_TEXTURE_HEIGHT) << 4)
                 as usize
         ];
-        let encoded_paints_texture = Self::make_encoded_paints_texture(
+        let encoded_paints_texture = Self::create_encoded_paints_texture(
             device,
             max_texture_dimension_2d,
             INITIAL_ENCODED_PAINTS_TEXTURE_HEIGHT,
         );
-        let encoded_paints_bind_group = Self::make_encoded_paints_bind_group(
+        let encoded_paints_bind_group = Self::create_encoded_paints_bind_group(
             device,
             &encoded_paints_bind_group_layout,
             &encoded_paints_texture.create_view(&Default::default()),
         );
+
         const INITIAL_GRADIENT_TEXTURE_HEIGHT: u32 = 1;
-        let gradient_texture = Self::make_gradient_texture(
+        let gradient_texture = Self::create_gradient_texture(
             device,
             max_texture_dimension_2d,
             INITIAL_GRADIENT_TEXTURE_HEIGHT,
         );
-        let gradient_bind_group = Self::make_gradient_bind_group(
+        let gradient_bind_group = Self::create_gradient_bind_group(
             device,
             &gradient_bind_group_layout,
             &gradient_texture.create_view(&Default::default()),
         );
-        let slot_bind_groups = Self::make_strip_bind_groups(
+
+        let slot_bind_groups = Self::create_strip_bind_groups(
             device,
             &strip_bind_group_layout,
             &alphas_texture.create_view(&Default::default()),
@@ -813,14 +841,15 @@
         );
 
         let resources = GpuResources {
-            strips_buffer: Self::make_strips_buffer(device, 0),
+            strips_buffer: Self::create_strips_buffer(device, 0),
             clear_slot_indices_buffer,
             slot_texture_views,
             slot_config_buffer,
             slot_bind_groups,
             clear_bind_group,
             alphas_texture,
-            atlas_texture,
+            atlas_texture_array,
+            atlas_texture_array_view,
             atlas_bind_group,
             encoded_paints_texture,
             encoded_paints_bind_group,
@@ -834,6 +863,7 @@
             strip_bind_group_layout,
             encoded_paints_bind_group_layout,
             gradient_bind_group_layout,
+            atlas_bind_group_layout,
             resources,
             alpha_data,
             encoded_paints_data,
@@ -841,12 +871,11 @@
                 width: render_target_config.width,
                 height: render_target_config.height,
             },
-
             clear_pipeline,
         }
     }
 
-    fn make_strips_buffer(device: &Device, required_strips_size: u64) -> Buffer {
+    fn create_strips_buffer(device: &Device, required_strips_size: u64) -> Buffer {
         device.create_buffer(&wgpu::BufferDescriptor {
             label: Some("Strips Buffer"),
             size: required_strips_size,
@@ -855,7 +884,7 @@
         })
     }
 
-    fn make_clear_slot_indices_buffer(device: &Device, required_size: u64) -> Buffer {
+    fn create_clear_slot_indices_buffer(device: &Device, required_size: u64) -> Buffer {
         device.create_buffer(&wgpu::BufferDescriptor {
             label: Some("Slot Indices Buffer"),
             size: required_size,
@@ -864,7 +893,7 @@
         })
     }
 
-    fn make_config_buffer(
+    fn create_config_buffer(
         device: &Device,
         render_size: &RenderSize,
         alpha_texture_width: u32,
@@ -881,7 +910,7 @@
         })
     }
 
-    fn make_alphas_texture(device: &Device, width: u32, height: u32) -> Texture {
+    fn create_alphas_texture(device: &Device, width: u32, height: u32) -> Texture {
         device.create_texture(&wgpu::TextureDescriptor {
             label: Some("Alpha Texture"),
             size: wgpu::Extent3d {
@@ -898,44 +927,62 @@
         })
     }
 
-    fn make_atlas_texture(
+    fn create_atlas_texture_array(
         device: &Device,
         width: u32,
         height: u32,
-        format: wgpu::TextureFormat,
-    ) -> Texture {
-        device.create_texture(&wgpu::TextureDescriptor {
-            label: Some("Atlas Texture"),
+        atlas_count: u32,
+    ) -> (Texture, TextureView) {
+        // Create a single texture array with multiple layers
+        let atlas_texture_array = device.create_texture(&wgpu::TextureDescriptor {
+            label: Some("Atlas Texture Array"),
             size: wgpu::Extent3d {
                 width,
                 height,
-                depth_or_array_layers: 1,
+                depth_or_array_layers: atlas_count,
             },
             mip_level_count: 1,
             sample_count: 1,
             dimension: wgpu::TextureDimension::D2,
-            format,
-            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+            format: wgpu::TextureFormat::Rgba8Unorm,
+            usage: wgpu::TextureUsages::TEXTURE_BINDING
+                | wgpu::TextureUsages::COPY_DST
+                | wgpu::TextureUsages::COPY_SRC,
             view_formats: &[],
-        })
+        });
+
+        let atlas_texture_array_view =
+            atlas_texture_array.create_view(&wgpu::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,
+            });
+
+        (atlas_texture_array, atlas_texture_array_view)
     }
 
-    fn make_atlas_bind_group(
+    fn create_atlas_bind_group(
         device: &Device,
         atlas_bind_group_layout: &BindGroupLayout,
-        atlas_texture_view: &TextureView,
+        atlas_texture_array_view: &TextureView,
     ) -> BindGroup {
         device.create_bind_group(&wgpu::BindGroupDescriptor {
             label: Some("Atlas Bind Group"),
             layout: atlas_bind_group_layout,
             entries: &[wgpu::BindGroupEntry {
                 binding: 0,
-                resource: wgpu::BindingResource::TextureView(atlas_texture_view),
+                resource: wgpu::BindingResource::TextureView(atlas_texture_array_view),
             }],
         })
     }
 
-    fn make_encoded_paints_texture(device: &Device, width: u32, height: u32) -> Texture {
+    fn create_encoded_paints_texture(device: &Device, width: u32, height: u32) -> Texture {
         device.create_texture(&wgpu::TextureDescriptor {
             label: Some("Encoded Paints Texture"),
             size: wgpu::Extent3d {
@@ -952,7 +999,7 @@
         })
     }
 
-    fn make_encoded_paints_bind_group(
+    fn create_encoded_paints_bind_group(
         device: &Device,
         encoded_paints_bind_group_layout: &BindGroupLayout,
         encoded_paints_texture_view: &TextureView,
@@ -967,7 +1014,7 @@
         })
     }
 
-    fn make_gradient_texture(device: &Device, width: u32, height: u32) -> Texture {
+    fn create_gradient_texture(device: &Device, width: u32, height: u32) -> Texture {
         device.create_texture(&wgpu::TextureDescriptor {
             label: Some("Gradient Texture"),
             size: wgpu::Extent3d {
@@ -984,7 +1031,7 @@
         })
     }
 
-    fn make_gradient_bind_group(
+    fn create_gradient_bind_group(
         device: &Device,
         gradient_bind_group_layout: &BindGroupLayout,
         gradient_texture_view: &TextureView,
@@ -999,7 +1046,7 @@
         })
     }
 
-    fn make_strip_bind_groups(
+    fn create_strip_bind_groups(
         device: &Device,
         strip_bind_group_layout: &BindGroupLayout,
         alphas_texture_view: &TextureView,
@@ -1008,21 +1055,21 @@
         strip_texture_views: &[TextureView],
     ) -> [BindGroup; 3] {
         [
-            Self::make_strip_bind_group(
+            Self::create_strip_bind_group(
                 device,
                 strip_bind_group_layout,
                 alphas_texture_view,
                 strip_config_buffer,
                 &strip_texture_views[1],
             ),
-            Self::make_strip_bind_group(
+            Self::create_strip_bind_group(
                 device,
                 strip_bind_group_layout,
                 alphas_texture_view,
                 strip_config_buffer,
                 &strip_texture_views[0],
             ),
-            Self::make_strip_bind_group(
+            Self::create_strip_bind_group(
                 device,
                 strip_bind_group_layout,
                 alphas_texture_view,
@@ -1032,7 +1079,7 @@
         ]
     }
 
-    fn make_strip_bind_group(
+    fn create_strip_bind_group(
         device: &Device,
         strip_bind_group_layout: &BindGroupLayout,
         alphas_texture_view: &TextureView,
@@ -1115,12 +1162,15 @@
             let required_alpha_size = (max_texture_dimension_2d * required_alpha_height) << 4;
             self.alpha_data.resize(required_alpha_size as usize, 0);
             // The alpha texture encodes 16 1-byte alpha values per texel, with 4 alpha values packed in each channel
-            let alphas_texture =
-                Self::make_alphas_texture(device, max_texture_dimension_2d, required_alpha_height);
+            let alphas_texture = Self::create_alphas_texture(
+                device,
+                max_texture_dimension_2d,
+                required_alpha_height,
+            );
             self.resources.alphas_texture = alphas_texture;
 
             // Since the alpha texture has changed, we need to update the clip bind groups.
-            self.resources.slot_bind_groups = Self::make_strip_bind_groups(
+            self.resources.slot_bind_groups = Self::create_strip_bind_groups(
                 device,
                 &self.strip_bind_group_layout,
                 &self
@@ -1157,7 +1207,7 @@
                 (max_texture_dimension_2d * required_encoded_paints_height) << 4;
             self.encoded_paints_data
                 .resize(required_encoded_paints_size as usize, 0);
-            let encoded_paints_texture = Self::make_encoded_paints_texture(
+            let encoded_paints_texture = Self::create_encoded_paints_texture(
                 device,
                 max_texture_dimension_2d,
                 required_encoded_paints_height,
@@ -1165,7 +1215,7 @@
             self.resources.encoded_paints_texture = encoded_paints_texture;
 
             // Since the encoded paints texture has changed, we need to update the strip bind groups.
-            self.resources.encoded_paints_bind_group = Self::make_encoded_paints_bind_group(
+            self.resources.encoded_paints_bind_group = Self::create_encoded_paints_bind_group(
                 device,
                 &self.encoded_paints_bind_group_layout,
                 &self
@@ -1195,7 +1245,7 @@
                 required_gradient_height <= max_texture_dimension_2d,
                 "Gradient texture height exceeds max texture dimensions"
             );
-            let gradient_texture = Self::make_gradient_texture(
+            let gradient_texture = Self::create_gradient_texture(
                 device,
                 max_texture_dimension_2d,
                 required_gradient_height,
@@ -1203,7 +1253,7 @@
             self.resources.gradient_texture = gradient_texture;
 
             // Since the gradient texture has changed, we need to update the gradient bind group.
-            self.resources.gradient_bind_group = Self::make_gradient_bind_group(
+            self.resources.gradient_bind_group = Self::create_gradient_bind_group(
                 device,
                 &self.gradient_bind_group_layout,
                 &self
@@ -1237,6 +1287,98 @@
         }
     }
 
+    /// Resize the texture array to accommodate more atlases.
+    fn maybe_resize_atlas_texture_array(
+        device: &Device,
+        queue: &Queue,
+        resources: &mut GpuResources,
+        atlas_bind_group_layout: &BindGroupLayout,
+        required_atlas_count: u32,
+    ) {
+        let Extent3d {
+            width,
+            height,
+            depth_or_array_layers: current_atlas_count,
+        } = resources.atlas_texture_array.size();
+        if required_atlas_count > current_atlas_count {
+            // 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);
+
+            // Copy existing atlas data from old texture array to new one
+            Self::copy_atlas_texture_data(
+                device,
+                queue,
+                &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_atlas_bind_group(
+                device,
+                atlas_bind_group_layout,
+                &new_atlas_texture_array_view,
+            );
+
+            // 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;
+        }
+    }
+
+    /// 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(
+        device: &Device,
+        queue: &Queue,
+        old_atlas_texture_array: &Texture,
+        new_atlas_texture_array: &Texture,
+        layer_count_to_copy: u32,
+        width: u32,
+        height: u32,
+    ) {
+        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
+            label: Some("Atlas Resize Copy"),
+        });
+
+        // Copy each layer from old texture array to new texture array
+        for layer in 0..layer_count_to_copy {
+            encoder.copy_texture_to_texture(
+                wgpu::TexelCopyTextureInfo {
+                    texture: old_atlas_texture_array,
+                    mip_level: 0,
+                    origin: wgpu::Origin3d {
+                        x: 0,
+                        y: 0,
+                        z: layer,
+                    },
+                    aspect: wgpu::TextureAspect::All,
+                },
+                wgpu::TexelCopyTextureInfo {
+                    texture: new_atlas_texture_array,
+                    mip_level: 0,
+                    origin: wgpu::Origin3d {
+                        x: 0,
+                        y: 0,
+                        z: layer,
+                    },
+                    aspect: wgpu::TextureAspect::All,
+                },
+                wgpu::Extent3d {
+                    width,
+                    height,
+                    depth_or_array_layers: 1,
+                },
+            );
+        }
+
+        queue.submit([encoder.finish()]);
+    }
+
     /// Upload alpha data to the texture.
     fn upload_alpha_texture(&mut self, queue: &Queue, alphas: &[u8]) {
         let texture_width = self.resources.alphas_texture.width();
@@ -1349,7 +1491,7 @@
     /// Upload the strip data by creating and assigning a new `self.resources.strips_buffer`.
     fn upload_strips(&mut self, device: &Device, queue: &Queue, strips: &[GpuStrip]) {
         let required_strips_size = size_of_val(strips) as u64;
-        self.resources.strips_buffer = Self::make_strips_buffer(device, required_strips_size);
+        self.resources.strips_buffer = Self::create_strips_buffer(device, required_strips_size);
         // TODO: Consider using a staging belt to avoid an extra staging buffer allocation.
         let mut buffer = queue
             .write_buffer_with(
@@ -1427,7 +1569,7 @@
         // 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.
         resources.clear_slot_indices_buffer =
-            Programs::make_clear_slot_indices_buffer(self.device, size);
+            Programs::create_clear_slot_indices_buffer(self.device, size);
         // TODO: Consider using a staging belt to avoid an extra staging buffer allocation.
         let mut buffer = self
             .queue
@@ -1499,13 +1641,14 @@
     /// Get the height of the image.
     fn height(&self) -> u32;
 
-    /// Write image data to the atlas texture at the specified offset.
-    fn write_to_atlas(
+    /// Write image data to a specific layer of an atlas texture array at the specified offset.
+    fn write_to_atlas_layer(
         &self,
         device: &Device,
         queue: &Queue,
         encoder: &mut CommandEncoder,
         atlas_texture: &wgpu::Texture,
+        layer: u32,
         offset: [u32; 2],
         width: u32,
         height: u32,
@@ -1522,12 +1665,13 @@
         self.height()
     }
 
-    fn write_to_atlas(
+    fn write_to_atlas_layer(
         &self,
         _device: &Device,
         _queue: &Queue,
         encoder: &mut CommandEncoder,
         atlas_texture: &wgpu::Texture,
+        layer: u32,
         offset: [u32; 2],
         width: u32,
         height: u32,
@@ -1545,7 +1689,7 @@
                 origin: wgpu::Origin3d {
                     x: offset[0],
                     y: offset[1],
-                    z: 0,
+                    z: layer,
                 },
                 aspect: wgpu::TextureAspect::All,
             },
@@ -1568,12 +1712,13 @@
         self.height() as u32
     }
 
-    fn write_to_atlas(
+    fn write_to_atlas_layer(
         &self,
         _device: &Device,
         queue: &Queue,
         _encoder: &mut CommandEncoder,
         atlas_texture: &wgpu::Texture,
+        layer: u32,
         offset: [u32; 2],
         width: u32,
         height: u32,
@@ -1585,7 +1730,7 @@
                 origin: wgpu::Origin3d {
                     x: offset[0],
                     y: offset[1],
-                    z: 0,
+                    z: layer,
                 },
                 aspect: wgpu::TextureAspect::All,
             },
@@ -1605,7 +1750,7 @@
 }
 
 /// Implementation for `Arc<Pixmap>`
-impl AtlasWriter for alloc::sync::Arc<Pixmap> {
+impl AtlasWriter for Arc<Pixmap> {
     fn width(&self) -> u32 {
         self.as_ref().width() as u32
     }
@@ -1614,17 +1759,26 @@
         self.as_ref().height() as u32
     }
 
-    fn write_to_atlas(
+    fn write_to_atlas_layer(
         &self,
         device: &Device,
         queue: &Queue,
         encoder: &mut CommandEncoder,
         atlas_texture: &wgpu::Texture,
+        layer: u32,
         offset: [u32; 2],
         width: u32,
         height: u32,
     ) {
-        self.as_ref()
-            .write_to_atlas(device, queue, encoder, atlas_texture, offset, width, height);
+        self.as_ref().write_to_atlas_layer(
+            device,
+            queue,
+            encoder,
+            atlas_texture,
+            layer,
+            offset,
+            width,
+            height,
+        );
     }
 }
diff --git a/sparse_strips/vello_hybrid/src/scene.rs b/sparse_strips/vello_hybrid/src/scene.rs
index 6040e83..b30b17a 100644
--- a/sparse_strips/vello_hybrid/src/scene.rs
+++ b/sparse_strips/vello_hybrid/src/scene.rs
@@ -19,6 +19,8 @@
 use vello_common::strip::Strip;
 use vello_common::strip_generator::{GenerationMode, StripGenerator, StripStorage};
 
+use crate::AtlasConfig;
+
 /// Default tolerance for curve flattening
 pub(crate) const DEFAULT_TOLERANCE: f64 = 0.1;
 
@@ -27,12 +29,15 @@
 pub struct RenderSettings {
     /// The SIMD level that should be used for rendering operations.
     pub level: Level,
+    /// The configuration for the atlas.
+    pub atlas_config: AtlasConfig,
 }
 
 impl Default for RenderSettings {
     fn default() -> Self {
         Self {
             level: Level::try_detect().unwrap_or(Level::fallback()),
+            atlas_config: AtlasConfig::default(),
         }
     }
 }
diff --git a/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl b/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl
index b1306ba..2452142 100644
--- a/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl
+++ b/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl
@@ -195,7 +195,7 @@
 var<uniform> config: Config;
 
 @group(1) @binding(0)
-var atlas_texture: texture_2d<f32>;
+var atlas_texture_array: texture_2d_array<f32>;
 
 @group(2) @binding(0)
 var encoded_paints_texture: texture_2d<u32>;
@@ -321,26 +321,34 @@
             if encoded_image.quality == IMAGE_QUALITY_HIGH {
                 let final_xy = image_offset + extended_xy;
                 let sample_color = bicubic_sample(
-                    atlas_texture,
+                    atlas_texture_array,
                     final_xy,
+                    i32(encoded_image.atlas_index),
                     image_offset,
                     image_size,
-                    encoded_image.extend_modes
+                    encoded_image.extend_modes,
                 );
                 final_color = alpha * sample_color;
             } else if encoded_image.quality == IMAGE_QUALITY_MEDIUM {
                 let final_xy = image_offset + extended_xy - vec2(0.5);
                 let sample_color = bilinear_sample(
-                    atlas_texture,
+                    atlas_texture_array,
                     final_xy,
+                    i32(encoded_image.atlas_index),
                     image_offset,
                     image_size,
-                    encoded_image.extend_modes
+                    encoded_image.extend_modes,
                 );
                 final_color = alpha * sample_color;
             } else if encoded_image.quality == IMAGE_QUALITY_LOW {
                 let final_xy = image_offset + extended_xy;
-                final_color = alpha * textureLoad(atlas_texture, vec2<u32>(final_xy), 0);
+                let sample_color = textureLoad(
+                    atlas_texture_array,
+                    vec2<u32>(final_xy),
+                    i32(encoded_image.atlas_index),
+                    0,
+                );
+                final_color = alpha * sample_color;
             }
         } else if paint_type == PAINT_TYPE_LINEAR_GRADIENT {
             let paint_tex_idx = in.paint & PAINT_TEXTURE_INDEX_MASK;
@@ -737,6 +745,8 @@
     image_size: vec2<f32>,
     /// The offset of the image in pixels.
     image_offset: vec2<f32>,
+    /// The atlas index containing this image.
+    atlas_index: u32,
     /// Linear transformation matrix coefficients for 2D affine transformation.
     /// Contains [a, b, c, d] where the transformation matrix is:
     /// This enables scaling, rotation, and skewing of the image coordinates.
@@ -759,17 +769,19 @@
     let image_size = vec2<f32>(f32(texel0.y >> 16u), f32(texel0.y & 0xFFFFu));
     // Unpack image_offset from texel0.z (stored as u32, unpack to x/y)
     let image_offset = vec2<f32>(f32(texel0.z >> 16u), f32(texel0.z & 0xFFFFu));
+    let atlas_index = texel0.w;
     let transform = vec4<f32>(
-        bitcast<f32>(texel0.w), bitcast<f32>(texel1.x), 
-        bitcast<f32>(texel1.y), bitcast<f32>(texel1.z)
+        bitcast<f32>(texel1.x), bitcast<f32>(texel1.y), 
+        bitcast<f32>(texel1.z), bitcast<f32>(texel1.w)
     );
-    let translate = vec2<f32>(bitcast<f32>(texel1.w), bitcast<f32>(texel2.x));
+    let translate = vec2<f32>(bitcast<f32>(texel2.x), bitcast<f32>(texel2.y));
 
     return EncodedImage(
         quality, 
         vec2<u32>(extend_x, extend_y),
         image_size,
         image_offset,
+        atlas_index,
         transform,
         translate
     );
@@ -822,20 +834,21 @@
 // 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<f32>,
+    tex: texture_2d_array<f32>,
     coords: vec2<f32>,
+    atlas_idx: i32,
     image_offset: vec2<f32>,
     image_size: vec2<f32>,
-    extend_modes: vec2<u32>
+    extend_modes: vec2<u32>,
 ) -> 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), 0);
-    let b = textureLoad(tex, vec2<i32>(uv_quad.xw), 0);
-    let c = textureLoad(tex, vec2<i32>(uv_quad.zy), 0);
-    let d = textureLoad(tex, vec2<i32>(uv_quad.zw), 0);
+    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);
 }
 
@@ -846,8 +859,9 @@
 // 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<f32>,
+    tex: texture_2d_array<f32>,
     coords: vec2<f32>,
+    atlas_idx: i32,
     image_offset: vec2<f32>,
     image_size: vec2<f32>,
     extend_modes: vec2<u32>,
@@ -859,25 +873,25 @@
      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)), 0);
-     let s10 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-0.5, -1.5), image_offset, atlas_max)), 0);
-     let s20 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(0.5, -1.5), image_offset, atlas_max)), 0);
-     let s30 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(1.5, -1.5), image_offset, atlas_max)), 0);
+     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)), 0);
-     let s11 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-0.5, -0.5), image_offset, atlas_max)), 0);
-     let s21 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(0.5, -0.5), image_offset, atlas_max)), 0);
-     let s31 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(1.5, -0.5), image_offset, atlas_max)), 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)), 0);
-     let s12 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-0.5, 0.5), image_offset, atlas_max)), 0);
-     let s22 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(0.5, 0.5), image_offset, atlas_max)), 0);
-     let s32 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(1.5, 0.5), image_offset, atlas_max)), 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)), 0);
-     let s13 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(-0.5, 1.5), image_offset, atlas_max)), 0);
-     let s23 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(0.5, 1.5), image_offset, atlas_max)), 0);
-     let s33 = textureLoad(tex, vec2<i32>(clamp(coords + vec2(1.5, 1.5), image_offset, atlas_max)), 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;