vello_gpu/vello_cpu: Remove image uploads
diff --git a/glifo/src/glyph.rs b/glifo/src/glyph.rs
index 6ca2604..b0bea9f 100644
--- a/glifo/src/glyph.rs
+++ b/glifo/src/glyph.rs
@@ -2421,7 +2421,7 @@
         }
 
         fn atlas_image_source(&self, atlas_slot: &AtlasSlot) -> ImageSource {
-            ImageSource::opaque_id(ImageId::new(atlas_slot.page_index))
+            ImageSource::Atlas(ImageId::new(atlas_slot.page_index))
         }
 
         fn atlas_paint_transform(&self, atlas_slot: &AtlasSlot) -> Affine {
diff --git a/vello_bench/src/fine/fill.rs b/vello_bench/src/fine/fill.rs
index b91bdc1..64baa32 100644
--- a/vello_bench/src/fine/fill.rs
+++ b/vello_bench/src/fine/fill.rs
@@ -1,12 +1,12 @@
 // Copyright 2025 the Vello Authors
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 
-use crate::fine::default_blend;
+use crate::fine::{NoOpImageResolver, default_blend};
 use criterion::{Bencher, Criterion};
 use vello_common::color::palette::css::ROYAL_BLUE;
 use vello_common::encode::EncodedPaint;
 use vello_common::fearless_simd::Simd;
-use vello_common::paint::{NoOpImageResolver, Paint, PremulColor};
+use vello_common::paint::{Paint, PremulColor};
 use vello_common::peniko::BlendMode;
 use vello_cpu::fine::{Fine, FineKernel, FineResources, PaintFillAttrs};
 use vello_cpu::span::{Span, TileAlignedSpan};
diff --git a/vello_bench/src/fine/mod.rs b/vello_bench/src/fine/mod.rs
index 83d1a17..c46dc93 100644
--- a/vello_bench/src/fine/mod.rs
+++ b/vello_bench/src/fine/mod.rs
@@ -15,11 +15,22 @@
 pub use image::*;
 pub use pack::*;
 pub use rounded_blurred_rect::*;
+use std::sync::Arc;
 pub use strip::*;
+use vello_common::paint::{ImageId, ImageResolver};
 use vello_common::peniko::{BlendMode, Compose, Mix};
+use vello_common::pixmap::Pixmap;
 
 pub(crate) const BENCH_WIDTH: u16 = 256;
 
+pub(crate) struct NoOpImageResolver;
+
+impl ImageResolver for NoOpImageResolver {
+    fn resolve(&self, _id: ImageId) -> Option<Arc<Pixmap>> {
+        None
+    }
+}
+
 pub(crate) fn default_blend() -> BlendMode {
     BlendMode::new(Mix::Normal, Compose::SrcOver)
 }
diff --git a/vello_bench/src/fine/strip.rs b/vello_bench/src/fine/strip.rs
index 15bde1c..cb36ce2 100644
--- a/vello_bench/src/fine/strip.rs
+++ b/vello_bench/src/fine/strip.rs
@@ -2,14 +2,14 @@
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 
 use crate::SEED;
-use crate::fine::{BENCH_WIDTH, default_blend};
+use crate::fine::{BENCH_WIDTH, NoOpImageResolver, default_blend};
 use criterion::{Bencher, Criterion};
 use rand::prelude::StdRng;
 use rand::{Rng, SeedableRng};
 use vello_common::color::palette::css::ROYAL_BLUE;
 use vello_common::encode::EncodedPaint;
 use vello_common::fearless_simd::Simd;
-use vello_common::paint::{NoOpImageResolver, Paint, PremulColor};
+use vello_common::paint::{Paint, PremulColor};
 use vello_common::tile::Tile;
 use vello_cpu::fine::{Fine, FineKernel, FineResources, PaintFillAttrs};
 use vello_cpu::span::{Span, TileAlignedSpan};
diff --git a/vello_common/CHANGELOG.md b/vello_common/CHANGELOG.md
index 1fe6a7b..3366548 100644
--- a/vello_common/CHANGELOG.md
+++ b/vello_common/CHANGELOG.md
@@ -20,6 +20,7 @@
 
 - Breaking change: `FilterPrimitive::GaussianBlur`'s `std_deviation` is now `std_deviation_x` and `std_deviation_y`, `filter::gaussian_blur::GaussianBlur` carries a decimation plan and kernel per axis, and `DecimationSizer` scales the axes independently through `downscale_axes` and `upscale_axes`. ([#1997][] by [@theashraf][])
 - A blur under a non-uniform scale or skew is scaled per axis instead of by the average scale factor, so its device-space extent follows the transform. ([#1997][] by [@theashraf][])
+- Breaking change: `ImageSource::OpaqueId`, `ImageSource::opaque_id`, and `ImageSource::opaque_id_with_transparency_hint` have been replaced by the hidden `ImageSource::Atlas`, which is only used internally for glyph caching. `ImageResolver` is now hidden as well, and `NoOpImageResolver` has been removed. Use `ImageSource::Pixmap` or `ImageSource::external_texture` instead. (by [@LaurenzV][])
 
 ### Fixed
 
diff --git a/vello_common/src/encode.rs b/vello_common/src/encode.rs
index 2245c5f..bbaa560 100644
--- a/vello_common/src/encode.rs
+++ b/vello_common/src/encode.rs
@@ -1226,11 +1226,12 @@
 #[cfg(test)]
 mod tests {
     use super::{EncodeExt, EncodedPaint, Gradient};
+    use crate::TextureId;
     use crate::blurred_rounded_rect::BlurredRoundedRectangle;
     use crate::color::DynamicColor;
     use crate::color::palette::css::{BLACK, BLUE, GREEN};
     use crate::kurbo::{Affine, Point, Rect};
-    use crate::paint::{Image, ImageId, ImageSource, Tint, TintMode};
+    use crate::paint::{Image, ImageSource, TextureRegion, Tint, TintMode};
     use crate::peniko::{ColorStop, ColorStops};
     use alloc::vec;
     use peniko::{
@@ -1472,7 +1473,7 @@
     #[test]
     fn image_sampler_alpha_with_tint() {
         let image = Image {
-            image: ImageSource::opaque_id_with_transparency_hint(ImageId::new(1), false),
+            image: ImageSource::external_texture(TextureId(1), TextureRegion::Full, false),
             sampler: ImageSampler {
                 alpha: 0.5,
                 ..ImageSampler::default()
@@ -1505,7 +1506,7 @@
     #[test]
     fn image_sampler_alpha_with_tint_is_clamped() {
         let image = Image {
-            image: ImageSource::opaque_id_with_transparency_hint(ImageId::new(1), false),
+            image: ImageSource::external_texture(TextureId(1), TextureRegion::Full, false),
             sampler: ImageSampler {
                 alpha: 2.0,
                 ..ImageSampler::default()
@@ -1555,7 +1556,7 @@
 
     fn opaque_image() -> Image {
         Image {
-            image: ImageSource::opaque_id_with_transparency_hint(ImageId::new(1), false),
+            image: ImageSource::external_texture(TextureId(1), TextureRegion::Full, false),
             sampler: ImageSampler::default(),
         }
     }
diff --git a/vello_common/src/lib.rs b/vello_common/src/lib.rs
index 58e9ead..8cf9c3a 100644
--- a/vello_common/src/lib.rs
+++ b/vello_common/src/lib.rs
@@ -105,9 +105,7 @@
         pub use crate::filter_effects;
         pub use crate::geometry;
         pub use crate::mask::Mask;
-        pub use crate::paint::{
-            Image, ImageId, ImageSource, PaintType, TextureRegion, Tint, TintMode,
-        };
+        pub use crate::paint::{Image, ImageSource, PaintType, TextureRegion, Tint, TintMode};
         pub use crate::pixmap::{PixelMetadata, Pixels, Pixmap};
         pub use crate::render_state::RenderState;
         pub use crate::transforms::Transforms;
@@ -126,10 +124,7 @@
         pub use super::shared::*;
         pub use crate::TextureId;
         pub use crate::geometry::{RectU16, SizeU16};
-        pub use crate::multi_atlas::{
-            AllocationStrategy, AtlasConfig, AtlasError, AtlasId, AtlasLayerDiagnostics,
-            AtlasSpaceDiagnostics,
-        };
+        pub use crate::multi_atlas::{AllocationStrategy, AtlasConfig};
         #[cfg(feature = "probe")]
         pub use crate::probe::{
             ALL_PROBE_ELEMENTS, CellStatistics, Probe, ProbeFeature, ProbeResult,
diff --git a/vello_common/src/paint.rs b/vello_common/src/paint.rs
index 4d25100..9978b49 100644
--- a/vello_common/src/paint.rs
+++ b/vello_common/src/paint.rs
@@ -89,14 +89,11 @@
 pub enum ImageSource {
     /// Pixmap pixels travel with the scene packet.
     Pixmap(Arc<Pixmap>),
-    // TODO: Explore whether we can merge opaque ID and external texture in some form?
-    /// Pixmap pixels were registered earlier; this is just a handle.
-    OpaqueId {
-        /// The image handle.
-        id: ImageId,
-        /// Whether the image may contain non-opaque pixels.
-        may_have_transparency: bool,
-    },
+    /// An entry in a renderer's internal glyph atlas.
+    ///
+    /// This is used by the glyph caching backends and should not be constructed manually.
+    #[doc(hidden)]
+    Atlas(ImageId),
     /// An externally owned texture supplied to the renderer at render time.
     ExternalTexture {
         /// Opaque external texture handle.
@@ -109,26 +106,6 @@
 }
 
 impl ImageSource {
-    /// Create an [`ImageSource`] from a pre-registered image handle.
-    ///
-    /// Conservatively assumes the image may have non-opaque pixels.
-    /// Use [`Self::opaque_id_with_transparency_hint`] when you know the image is fully opaque.
-    pub fn opaque_id(id: ImageId) -> Self {
-        Self::OpaqueId {
-            id,
-            may_have_transparency: true,
-        }
-    }
-
-    /// Create an [`ImageSource`] from a pre-registered image handle,
-    /// with an explicit hint about whether the image may have non-opaque pixels.
-    pub fn opaque_id_with_transparency_hint(id: ImageId, may_have_transparency: bool) -> Self {
-        Self::OpaqueId {
-            id,
-            may_have_transparency,
-        }
-    }
-
     /// Create an image source backed by a texture supplied to the renderer at render time.
     ///
     /// # Panics
@@ -155,11 +132,8 @@
     pub fn may_have_transparency(&self) -> bool {
         match self {
             Self::Pixmap(p) => p.may_have_transparency(),
-            Self::OpaqueId {
-                may_have_transparency,
-                ..
-            }
-            | Self::ExternalTexture {
+            Self::Atlas(_) => true,
+            Self::ExternalTexture {
                 may_have_transparency,
                 ..
             } => *may_have_transparency,
@@ -214,12 +188,12 @@
 /// An image.
 pub type Image = peniko::ImageBrush<ImageSource>;
 
-/// Trait for resolving opaque image IDs to pixmaps at rasterization time.
+/// Trait for resolving atlas image IDs to pixmaps at rasterization time.
 ///
-/// This allows delaying the resolution of `ImageSource::OpaqueId` until the
-/// image is actually needed during rasterization, enabling patterns like
-/// dynamic sprite atlases where the image data may be updated between
-/// encoding and rendering.
+/// This allows delaying the resolution of `ImageSource::Atlas` until the
+/// image is actually needed during rasterization, so that the atlas may be
+/// updated between encoding and rendering.
+#[doc(hidden)]
 pub trait ImageResolver: Send + Sync {
     /// Resolve an `ImageId` to its pixmap data.
     ///
@@ -230,16 +204,6 @@
     fn resolve(&self, id: ImageId) -> Option<Arc<Pixmap>>;
 }
 
-/// A no-op image resolver that always returns `None`.
-#[derive(Debug, Clone, Copy, Default)]
-pub struct NoOpImageResolver;
-
-impl ImageResolver for NoOpImageResolver {
-    fn resolve(&self, _id: ImageId) -> Option<Arc<Pixmap>> {
-        None
-    }
-}
-
 /// A premultiplied color.
 #[derive(Debug, Clone, PartialEq, Copy)]
 pub struct PremulColor {
diff --git a/vello_cpu/CHANGELOG.md b/vello_cpu/CHANGELOG.md
index 3f898e5..09b4b1a 100644
--- a/vello_cpu/CHANGELOG.md
+++ b/vello_cpu/CHANGELOG.md
@@ -20,6 +20,11 @@
 ### Changed
 
 - Breaking change: The `invert` parameter of `RenderContext::fill_blurred_rounded_rect` has been removed. Use `RenderContext::fill_blurred_rounded_rect_in` with `invert` set to `true` instead. ([#1718][] by [@nicoburns][])
+- Breaking change: `ImageSource::OpaqueId`, `ImageSource::opaque_id`, and `ImageSource::opaque_id_with_transparency_hint` have been replaced by the hidden `ImageSource::Atlas`, which is only used internally for glyph caching. Use `ImageSource::Pixmap` instead. (by [@LaurenzV][])
+
+### Removed
+
+- Breaking change: The image registry (`Resources::register_image`, `Resources::destroy_image`, `Resources::resolve_image`, and `Resources::clear_images`), and the `ImageId` re-export. Use `ImageSource::Pixmap` instead. (by [@LaurenzV][])
 
 ### Fixed
 
diff --git a/vello_cpu/examples/paints.rs b/vello_cpu/examples/paints.rs
index 161d35f..0c7a917 100644
--- a/vello_cpu/examples/paints.rs
+++ b/vello_cpu/examples/paints.rs
@@ -121,8 +121,6 @@
     let pixmap = Pixmap::from_png(Cursor::new(file)).unwrap();
 
     Image {
-        // Note that only ImageSource::Pixmap is currently supported. Don't
-        // use ImageSource::OpaqueId.
         image: ImageSource::Pixmap(Arc::new(pixmap)),
         sampler: ImageSampler {
             x_extend: Extend::Repeat,
diff --git a/vello_cpu/src/fine/mod.rs b/vello_cpu/src/fine/mod.rs
index f3f568e..69076ce 100644
--- a/vello_cpu/src/fine/mod.rs
+++ b/vello_cpu/src/fine/mod.rs
@@ -1080,7 +1080,7 @@
             EncodedPaint::Image(image) => {
                 let pixmap = match &image.source {
                     ImageSource::Pixmap(pixmap) => pixmap.clone(),
-                    ImageSource::OpaqueId { id, .. } => resources
+                    ImageSource::Atlas(id) => resources
                         .image_resolver
                         .resolve(*id)
                         .unwrap_or_else(|| panic!("Image {:?} not found in registry", id)),
diff --git a/vello_cpu/src/render.rs b/vello_cpu/src/render.rs
index aaa8adc..c3b4b63 100644
--- a/vello_cpu/src/render.rs
+++ b/vello_cpu/src/render.rs
@@ -37,25 +37,6 @@
 
 #[cfg(feature = "text")]
 pub(crate) const DEFAULT_GLYPH_ATLAS_SIZE: u16 = 4096;
-// Why do we need this? The reason is that the way uploaded images work in Vello GPU
-// is different from how they work in Vello CPU.
-//
-// In Vello GPU, all images, regardless of whether they are user-uploaded
-// images or cached glyphs, are stored in an image atlas at a certain location. An image ID then
-// uniquely resolves to an atlas page index + a location on that page. Whenever we want to
-// cache a new glyph, we simply allocate a location in the image atlas and then return the image
-// ID associated with that location.
-//
-// On Vello CPU, it works differently: An image ID is associated with a complete pixmap.
-// If a user uploads an image, instead of blitting it into a bigger image atlas, we just
-// store the user-provided pixmap and associate an image ID with the whole pixmap. However,
-// for glyph caching to work we need the same semantics as in Vello GPU. Therefore, we
-// use a marker to determine whether an image ID refers to a normal uploaded image or a cached
-// glyph and apply special handling based on that.
-//
-// All IDs < than this value are reserved for normal images, all IDs >= this value are
-// reserved for atlas pages.
-pub(crate) const ATLAS_IMAGE_ID_BASE: u32 = u32::MAX / 2;
 
 /// Persistent resources required by Vello CPU for rendering.
 ///
@@ -677,10 +658,6 @@
     }
 
     /// Set the current paint.
-    ///
-    /// If the paint is an image with `ImageSource::OpaqueId`, it will be
-    /// resolved to the corresponding pixmap at rasterization time.
-    /// Make sure to register images with [`Resources::register_image`] first.
     pub fn set_paint(&mut self, paint: impl Into<PaintType>) {
         self.state.paint = paint.into();
     }
@@ -1006,75 +983,23 @@
     }
 }
 
-/// Image registry implementation.
-impl Resources {
-    /// Register a pixmap in the image registry and return its [`ImageId`].
-    pub fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
-        self.image_registry.register(pixmap)
-    }
-
-    /// Remove an image from the registry.
-    pub fn destroy_image(&mut self, id: ImageId) -> bool {
-        self.image_registry.destroy(id)
-    }
-
-    /// Resolve an `ImageId` to its pixmap data.
-    pub fn resolve_image(&self, id: ImageId) -> Option<Arc<Pixmap>> {
-        self.image_registry.resolve(id)
-    }
-
-    /// Clear the image registry.
-    pub fn clear_images(&mut self) {
-        self.image_registry.clear();
-    }
-}
-
 /// Registry that maps opaque [`ImageId`]s to [`Pixmap`] data.
 ///
-/// Used by [`RenderContext`] to resolve `ImageSource::OpaqueId` at rasterization time.
+/// Used by [`RenderContext`] to resolve `ImageSource::Atlas` at rasterization time.
 #[derive(Debug, Default)]
 pub(crate) struct ImageRegistry {
     images: HashMap<u32, Arc<Pixmap>>,
-    next_id: u32,
 }
 
 impl ImageRegistry {
-    fn register(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
-        let id = self.next_id;
-        assert!(
-            id < ATLAS_IMAGE_ID_BASE,
-            "image registry exhausted non-atlas image IDs"
-        );
-
-        self.next_id += 1;
-        self.images.insert(id, pixmap);
-        ImageId::new(id)
-    }
-
     #[cfg(feature = "text")]
     pub(crate) fn register_atlas_page(&mut self, page_index: u32, pixmap: Arc<Pixmap>) {
-        self.images.insert(
-            ImageId::new(ATLAS_IMAGE_ID_BASE + page_index).as_u32(),
-            pixmap,
-        );
-    }
-
-    pub(crate) fn destroy(&mut self, id: ImageId) -> bool {
-        self.images.remove(&id.as_u32()).is_some()
+        self.images.insert(page_index, pixmap);
     }
 
     #[cfg(feature = "text")]
     pub(crate) fn destroy_atlas_page(&mut self, page_index: u32) -> bool {
-        self.destroy(ImageId::new(ATLAS_IMAGE_ID_BASE + page_index))
-    }
-
-    fn resolve(&self, id: ImageId) -> Option<Arc<Pixmap>> {
-        self.images.get(&id.as_u32()).cloned()
-    }
-
-    fn clear(&mut self) {
-        self.images.clear();
-        self.next_id = 0;
+        self.images.remove(&page_index).is_some()
     }
 }
 
diff --git a/vello_cpu/src/text.rs b/vello_cpu/src/text.rs
index ef0ca2a..7205efd 100644
--- a/vello_cpu/src/text.rs
+++ b/vello_cpu/src/text.rs
@@ -11,7 +11,7 @@
 //! [`Arc<Pixmap>`]s here, so the CPU renderer can read pixels directly without
 //! any GPU upload step.
 
-use crate::render::{ATLAS_IMAGE_ID_BASE, DEFAULT_GLYPH_ATLAS_SIZE};
+use crate::render::DEFAULT_GLYPH_ATLAS_SIZE;
 use crate::{
     Image, ImageSource, PaintType, Pixmap, RasterizerSettings, RenderContext, RenderMode,
     RenderSettings, Resources, TargetInit, color, kurbo, peniko,
@@ -33,10 +33,6 @@
 use vello_common::fearless_simd::Level;
 use vello_common::paint::ImageId;
 
-fn atlas_page_image_id(page_index: u32) -> ImageId {
-    ImageId::new(ATLAS_IMAGE_ID_BASE + page_index)
-}
-
 #[derive(Debug)]
 pub(crate) struct GlyphAtlasResources {
     pub(crate) glyph_atlas: GlyphAtlas,
@@ -464,7 +460,7 @@
 
     #[inline]
     fn atlas_image_source(&self, atlas_slot: &AtlasSlot) -> ImageSource {
-        ImageSource::opaque_id(atlas_page_image_id(atlas_slot.page_index))
+        ImageSource::Atlas(ImageId::new(atlas_slot.page_index))
     }
 
     #[inline]
diff --git a/vello_gpu/CHANGELOG.md b/vello_gpu/CHANGELOG.md
index 68e2213..0bb96f4 100644
--- a/vello_gpu/CHANGELOG.md
+++ b/vello_gpu/CHANGELOG.md
@@ -24,10 +24,17 @@
 
 - Breaking change: The `invert` parameter of `Scene::fill_blurred_rounded_rect` has been removed. Use `Scene::fill_blurred_rounded_rect_in` with `invert` set to `true` instead. ([#1718][] by [@nicoburns][])
 - Breaking change: `WebGlError::ResourceTextureTooLarge` has been removed. The WebGL renderer now reports oversized resource textures as `WebGlError::Render(RenderError::ResourceTextureTooLarge)`, which names the overflowing `ResourceTexture`. (by [@LaurenzV][])
+- Breaking change: `MemorySettings::image_atlas_config` has been renamed to `glyph_atlas_config`, as the atlas now only holds cached glyphs. (by [@LaurenzV][])
+- Breaking change: `RenderError::MissingImage` has been renamed to `RenderError::MissingAtlasEntry`. (by [@LaurenzV][])
+- Breaking change: `ImageSource::OpaqueId`, `ImageSource::opaque_id`, and `ImageSource::opaque_id_with_transparency_hint` have been replaced by the hidden `ImageSource::Atlas`, which is only used internally for glyph caching. Use `ImageSource::external_texture` instead. (by [@LaurenzV][])
+
+### Removed
+
+- Breaking change: Uploading images into the renderer's image atlas. `Renderer::upload_image`, `Renderer::destroy_image`, `Renderer::atlas_texture`, `AtlasWriter`, `WebGlRenderer::upload_image`, `WebGlRenderer::destroy_image`, `WebGlRenderer::atlas_texture`, `WebGlRenderer::atlas_info`, `WebGlAtlasWriter`, `WebGlTextureWithDimensions`, `AtlasTextureInfo`, and `RenderError::AtlasError` have been removed, and `AtlasError`, `AtlasId`, `AtlasLayerDiagnostics`, `AtlasSpaceDiagnostics`, and `ImageId` are no longer re-exported. Bind images as external textures with `ImageSource::external_texture` instead. The atlas is now only used for glyph caching. (by [@LaurenzV][])
 
 ### Fixed
 
-- The wgpu renderer returns `RenderError::MissingImage` instead of panicking when a scene references an image that isn't in the image cache. (by [@LaurenzV][])
+- The wgpu renderer returns `RenderError::MissingAtlasEntry` instead of panicking when a scene references an atlas entry that doesn't exist. (by [@LaurenzV][])
 - The wgpu renderer returns `RenderError::ResourceTextureTooLarge` instead of panicking when a scene's alpha, paint, gradient or filter data doesn't fit into the supported texture dimensions. (by [@LaurenzV][])
 - The WebGL renderer returns `RenderError::ResourceTextureTooLarge` instead of panicking when a scene's filter data doesn't fit into the supported texture dimensions. (by [@LaurenzV][])
 - The WebGL renderer recovers after a scene's gradients don't fit into the gradient texture, instead of returning the same error for every following frame. (by [@LaurenzV][])
diff --git a/vello_gpu/README.md b/vello_gpu/README.md
index 5577042..0b53ab6 100644
--- a/vello_gpu/README.md
+++ b/vello_gpu/README.md
@@ -53,8 +53,8 @@
 - `full_image_support` (enabled by default): If this feature is disabled, only
   external textures using `TextureRegion::Full` with nearest or bilinear
   sampling and opacity are supported. Enabling this feature also adds support
-  for uploaded images, glyph atlas caching, bicubic sampling, cropped external
-  textures, and colored or alpha-mask tints.
+  for glyph atlas caching, bicubic sampling, cropped external textures, and
+  colored or alpha-mask tints.
 - `gradient_sweep` (enabled by default): Enables rendering of sweep gradients
 - `wgpu_external_texture` (enabled by default): Adds a WGSL `texture_external`
   binding when the wgpu device has `wgpu::Features::EXTERNAL_TEXTURE` enabled,
diff --git a/vello_gpu/examples/native_webgl/src/lib.rs b/vello_gpu/examples/native_webgl/src/lib.rs
index ca168f5..1acc02e 100644
--- a/vello_gpu/examples/native_webgl/src/lib.rs
+++ b/vello_gpu/examples/native_webgl/src/lib.rs
@@ -14,17 +14,15 @@
 use probe::ProbeIndicator;
 use std::cell::RefCell;
 use std::rc::Rc;
-use vello_common::paint::ImageId;
-use vello_common::{
-    kurbo::{Affine, Vec2},
-    paint::ImageSource,
-};
-use vello_example_scenes::image::ImageScene;
+use vello_common::kurbo::{Affine, Vec2};
+use vello_example_scenes::image::{COWBOY_TEXTURE_ID, FLOWER_TEXTURE_ID, ImageScene};
 use vello_example_scenes::{
     AnyScene,
     performance::{FrameTiming, PerformancePanel, PerformanceStage, WebGlGpuTimer, now},
 };
-use vello_gpu::{RenderSettings, Resources, Scene, WebGlError, WebGlRendererInit};
+use vello_gpu::{
+    RenderSettings, Resources, Scene, WebGlError, WebGlRendererInit, WebGlTextureBindings,
+};
 use wasm_bindgen::prelude::*;
 use wasm_bindgen_futures::JsFuture;
 use web_sys::{Event, HtmlCanvasElement, KeyboardEvent, MouseEvent, WheelEvent};
@@ -81,6 +79,7 @@
     width: u32,
     height: u32,
     renderer_wrapper: RendererWrapper,
+    texture_bindings: WebGlTextureBindings,
     probe_indicator: ProbeIndicator,
     performance: PerformancePanel<2>,
     canvas: HtmlCanvasElement,
@@ -117,6 +116,7 @@
             width,
             height,
             renderer_wrapper,
+            texture_bindings: WebGlTextureBindings::new(),
             probe_indicator,
             performance: PerformancePanel::new(
                 "Vello GPU · native WebGL2",
@@ -144,7 +144,7 @@
 
         update_page_url(app_state.current_scene);
         app_state.update_title();
-        app_state.upload_images_to_atlas();
+        app_state.upload_images_to_textures();
 
         app_state
     }
@@ -175,7 +175,7 @@
                 &self.scene,
                 &mut self.renderer_wrapper.resources,
                 vello_gpu::WebGlRenderTarget::DrawingBuffer,
-                &vello_gpu::WebGlTextureBindings::new(),
+                &self.texture_bindings,
                 vello_gpu::WebGlRenderOptions::default(),
             )
             .unwrap();
@@ -328,30 +328,19 @@
             * self.transform;
     }
 
-    /// Upload images to the WebGL atlas texture
-    /// This is the WebGL analogue of the winit example's `upload_images_to_atlas` function
-    fn upload_images_to_atlas(&mut self) {
-        // 1st example — uploading pixmap directly to WebGL atlas
-        let pixmap1 = ImageScene::read_flower_image();
-        self.renderer_wrapper
-            .renderer
-            .upload_image(&mut self.renderer_wrapper.resources, &pixmap1)
-            .expect("failed to upload pixmap to WebGL atlas");
-
-        // 2nd example — uploading from a WebGL texture
-        let pixmap2 = ImageScene::read_cowboy_image();
-        let texture2 = self.pixmap_to_webgl_texture(&pixmap2);
-        self.renderer_wrapper
-            .renderer
-            .upload_image(&mut self.renderer_wrapper.resources, &texture2)
-            .expect("failed to upload texture to WebGL atlas");
+    fn upload_images_to_textures(&mut self) {
+        for (id, pixmap) in [
+            (FLOWER_TEXTURE_ID, ImageScene::read_flower_image()),
+            (COWBOY_TEXTURE_ID, ImageScene::read_cowboy_image()),
+        ] {
+            let texture = self.pixmap_to_webgl_texture(&pixmap);
+            self.texture_bindings
+                .insert(id, texture, pixmap.width(), pixmap.height());
+        }
     }
 
     /// Convert a pixmap to WebGL texture
-    fn pixmap_to_webgl_texture(
-        &self,
-        pixmap: &vello_gpu::Pixmap,
-    ) -> vello_gpu::WebGlTextureWithDimensions {
+    fn pixmap_to_webgl_texture(&self, pixmap: &vello_gpu::Pixmap) -> web_sys::WebGlTexture {
         let width = pixmap.width();
         let height = pixmap.height();
         let rgba_data = pixmap.data_as_u8_slice();
@@ -396,11 +385,7 @@
             web_sys::WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
         );
 
-        vello_gpu::WebGlTextureWithDimensions {
-            texture,
-            width,
-            height,
-        }
+        texture
     }
 }
 
@@ -467,10 +452,7 @@
     let scenes = {
         let v = vello_example_scenes::get_example_scenes(
             vello_example_scenes::Capabilities::default(),
-            vec![
-                ImageSource::opaque_id(ImageId::new(0)),
-                ImageSource::opaque_id(ImageId::new(1)),
-            ],
+            ImageScene::external_texture_sources(),
         )
         .into_vec();
         v.into_boxed_slice()
@@ -680,7 +662,7 @@
             &scene,
             &mut resources,
             vello_gpu::WebGlRenderTarget::DrawingBuffer,
-            &vello_gpu::WebGlTextureBindings::new(),
+            &WebGlTextureBindings::new(),
             vello_gpu::WebGlRenderOptions::default(),
         )
         .unwrap();
diff --git a/vello_gpu/examples/wgpu_video/README.md b/vello_gpu/examples/wgpu_video/README.md
index 34868f3..7b85cdb 100644
--- a/vello_gpu/examples/wgpu_video/README.md
+++ b/vello_gpu/examples/wgpu_video/README.md
@@ -14,7 +14,7 @@
 texture; there is no CPU-side or GPU-side intermediate RGBA copy. The
 plane textures are zero-copy — backed by the same `IOSurface`s VT decoded into.
 All videos play simultaneously in a grid layout. A still image
-(`assets/splash-flower.jpg`, uploaded once with `Renderer::upload_image`) sits in the
+(`assets/splash-flower.jpg`, uploaded once to a texture bound as an external texture) sits in the
 free space between the first two videos of the top row. A large yellow "Vello GPU" text
 overlay is rendered on top of the grid using `Scene::glyph_run` with the bundled
 Roboto font, and inherits the user's pan / zoom / rotation.
diff --git a/vello_gpu/examples/wgpu_video/src/main.rs b/vello_gpu/examples/wgpu_video/src/main.rs
index f39e003..9e9c26b 100644
--- a/vello_gpu/examples/wgpu_video/src/main.rs
+++ b/vello_gpu/examples/wgpu_video/src/main.rs
@@ -40,12 +40,10 @@
     use skrifa::raw::FileRef;
     use vello_common::color::palette::css::YELLOW;
     use vello_common::kurbo::{Affine, BezPath, Circle, Ellipse, Point, Rect, Shape, Vec2};
-    use vello_common::paint::{Image, ImageId, ImageSource, TextureRegion};
-    use vello_common::peniko::{
-        Blob, Extend, FontData, ImageAlphaType, ImageQuality, ImageSampler,
-    };
-    use vello_common::pixmap::{PixelMetadata, Pixmap};
+    use vello_common::paint::{Image, ImageSource, TextureRegion};
+    use vello_common::peniko::{Blob, Extend, FontData, ImageQuality, ImageSampler};
     use vello_gpu::{RenderOptions, RenderTarget, Resources, Scene, TextureBindings, TextureId};
+    use wgpu::util::DeviceExt;
     use winit::application::ApplicationHandler;
     use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
     use winit::event_loop::{ActiveEventLoop, EventLoop};
@@ -68,6 +66,8 @@
 
     /// Still image shown in its own grid cell next to the videos.
     const STATIC_IMAGE: &[u8] = include_bytes!("../../../../assets/splash-flower.jpg");
+    /// Video slots use the texture IDs from zero upwards.
+    const STATIC_IMAGE_TEXTURE_ID: TextureId = TextureId(u64::MAX);
 
     /// Font for the text overlay drawn over the grid.
     const ROBOTO_FONT: &[u8] = include_bytes!("../../../../assets/roboto/Roboto-Regular.ttf");
@@ -140,11 +140,11 @@
                     .create_window(attrs)
                     .expect("Failed to create window"),
             );
-            let (mut render_context, mut resources) = RenderContext::new(window.clone());
+            let (render_context, resources) = RenderContext::new(window.clone());
 
             let slots = build_video_slots(&render_context);
             log::info!("{} video(s) loaded into grid", slots.len());
-            let static_image = StaticImage::upload(&mut render_context, &mut resources);
+            let static_image = StaticImage::upload(&render_context);
 
             let size = window.inner_size();
             let scene = Scene::new(
@@ -377,8 +377,13 @@
             }
 
             if cols >= 2 {
-                self.static_image
-                    .draw(&mut self.scene, self.transform, cell_w, cell_h);
+                self.static_image.draw(
+                    &mut self.scene,
+                    &mut bindings,
+                    self.transform,
+                    cell_w,
+                    cell_h,
+                );
             }
 
             // The overlay uses the same transform, so it moves with the grid.
@@ -539,47 +544,63 @@
         slots
     }
 
-    /// [`STATIC_IMAGE`] after uploading to Vello's image atlas. Drawn in the free space
-    /// between the first two cells of the top row, without moving any video.
+    /// [`STATIC_IMAGE`] uploaded to a texture that is bound under [`STATIC_IMAGE_TEXTURE_ID`].
+    /// Drawn in the free space between the first two cells of the top row, without moving any
+    /// video.
     struct StaticImage {
-        id: ImageId,
+        view: wgpu::TextureView,
         width: u16,
         height: u16,
     }
 
     impl StaticImage {
-        fn upload(render_context: &mut RenderContext<'_>, resources: &mut Resources) -> Self {
+        fn upload(render_context: &RenderContext<'_>) -> Self {
             let decoded = image::load_from_memory(STATIC_IMAGE)
                 .expect("splash-flower.jpg is a valid JPEG")
                 .into_rgba8();
             let width = u16::try_from(decoded.width()).expect("image width fits in u16");
             let height = u16::try_from(decoded.height()).expect("image height fits in u16");
-            let pixmap = Pixmap::from_parts(
-                decoded.into_raw(),
+
+            let texture = render_context.device.create_texture_with_data(
+                &render_context.queue,
+                &wgpu::TextureDescriptor {
+                    label: Some("vello_gpu_wgpu_video static image"),
+                    size: wgpu::Extent3d {
+                        width: u32::from(width),
+                        height: u32::from(height),
+                        depth_or_array_layers: 1,
+                    },
+                    mip_level_count: 1,
+                    sample_count: 1,
+                    dimension: wgpu::TextureDimension::D2,
+                    format: wgpu::TextureFormat::Rgba8Unorm,
+                    usage: wgpu::TextureUsages::TEXTURE_BINDING,
+                    view_formats: &[],
+                },
+                wgpu::util::TextureDataOrder::LayerMajor,
+                &decoded,
+            );
+            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
+
+            Self {
+                view,
                 width,
                 height,
-                PixelMetadata::new(ImageAlphaType::Alpha, false),
-            );
-
-            let mut encoder =
-                render_context
-                    .device
-                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
-                        label: Some("vello_gpu_wgpu_video image upload"),
-                    });
-            let id = render_context
-                .renderer
-                .upload_image(resources, &mut encoder, &pixmap);
-            render_context.submit(encoder.finish());
-
-            Self { id, width, height }
+            }
         }
 
         /// The clip shapes in the first two cells are a circle and a triangle, both
         /// centered in their cell and no wider than `cell_h`. That leaves a strip
         /// `cell_w - cell_h` wide around the boundary between the two cells, which the
         /// image is fitted into. Draws nothing if the cells are too narrow for a gap.
-        fn draw(&self, scene: &mut Scene, transform: Affine, cell_w: f64, cell_h: f64) {
+        fn draw(
+            &self,
+            scene: &mut Scene,
+            bindings: &mut TextureBindings,
+            transform: Affine,
+            cell_w: f64,
+            cell_h: f64,
+        ) {
             // Keep a little space between the image and the videos on either side.
             const MARGIN: f64 = 0.9;
             let gap_w = cell_w - cell_h;
@@ -593,10 +614,15 @@
             let tx = cell_w - w * scale * 0.5;
             let ty = (cell_h - h * scale) * 0.5;
 
+            bindings.insert(STATIC_IMAGE_TEXTURE_ID, self.view.clone());
             scene.set_transform(transform * Affine::translate((tx, ty)) * Affine::scale(scale));
             scene.set_paint_transform(Affine::IDENTITY);
             scene.set_paint(Image {
-                image: ImageSource::opaque_id_with_transparency_hint(self.id, false),
+                image: ImageSource::external_texture(
+                    STATIC_IMAGE_TEXTURE_ID,
+                    TextureRegion::Full,
+                    false,
+                ),
                 sampler: ImageSampler {
                     x_extend: Extend::Pad,
                     y_extend: Extend::Pad,
diff --git a/vello_gpu/examples/wgpu_webgl/src/lib.rs b/vello_gpu/examples/wgpu_webgl/src/lib.rs
index 105879a..457c7dc 100644
--- a/vello_gpu/examples/wgpu_webgl/src/lib.rs
+++ b/vello_gpu/examples/wgpu_webgl/src/lib.rs
@@ -13,14 +13,13 @@
 use vello_common::{
     fearless_simd::Level,
     kurbo::{Affine, Point},
-    paint::{ImageId, ImageSource},
 };
 use vello_example_scenes::{
     AnyScene,
-    image::ImageScene,
+    image::{COWBOY_TEXTURE_ID, FLOWER_TEXTURE_ID, ImageScene},
     performance::{FrameTiming, PerformancePanel, PerformanceStage, WebGlGpuTimer, now},
 };
-use vello_gpu::{Pixmap, RenderSettings, RenderTargetConfig, Renderer, Scene};
+use vello_gpu::{Pixmap, RenderSettings, RenderTargetConfig, Renderer, Scene, TextureBindings};
 use wasm_bindgen::prelude::*;
 use web_sys::{Event, HtmlCanvasElement, KeyboardEvent, MouseEvent, WheelEvent};
 use wgpu::{
@@ -159,6 +158,7 @@
     width: u32,
     height: u32,
     renderer_wrapper: RendererWrapper,
+    texture_bindings: TextureBindings,
     performance: PerformancePanel<3>,
     canvas: HtmlCanvasElement,
 }
@@ -186,6 +186,7 @@
             width,
             height,
             renderer_wrapper,
+            texture_bindings: TextureBindings::new(),
             performance: PerformancePanel::new(
                 "Vello GPU · wgpu → WebGL2",
                 [
@@ -212,8 +213,7 @@
 
         update_page_url(app_state.current_scene);
         app_state.update_title();
-        // Upload images to the WebGL atlas
-        app_state.upload_images_to_atlas();
+        app_state.upload_images_to_textures();
 
         app_state
     }
@@ -270,7 +270,7 @@
                     view: &surface_texture_view,
                     depth_view: Some(&self.renderer_wrapper.depth_texture_view),
                 },
-                &vello_gpu::TextureBindings::new(),
+                &self.texture_bindings,
                 vello_gpu::RenderOptions::default(),
             )
             .unwrap();
@@ -419,36 +419,21 @@
         self.transform = self.transform.then_scale_about(zoom_factor, cursor_pos);
     }
 
-    fn upload_images_to_atlas(&mut self) {
-        let mut encoder =
-            self.renderer_wrapper
-                .device
-                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
-                    label: Some("Upload Image pass"),
-                });
-
-        // 1st example — uploading pixmap directly to WebGL atlas
-        let pixmap1 = ImageScene::read_flower_image();
-        self.renderer_wrapper.renderer.upload_image(
-            &mut self.renderer_wrapper.resources,
-            &mut encoder,
-            &pixmap1,
-        );
-
-        // 2nd example — uploading from a WebGL texture
-        let pixmap2 = ImageScene::read_cowboy_image();
-        let texture2 = self.upload_image_to_texture(
-            &self.renderer_wrapper.device,
-            &self.renderer_wrapper.queue,
-            &pixmap2,
-        );
-        self.renderer_wrapper.renderer.upload_image(
-            &mut self.renderer_wrapper.resources,
-            &mut encoder,
-            &texture2,
-        );
-
-        self.renderer_wrapper.queue.submit([encoder.finish()]);
+    fn upload_images_to_textures(&mut self) {
+        for (id, pixmap) in [
+            (FLOWER_TEXTURE_ID, ImageScene::read_flower_image()),
+            (COWBOY_TEXTURE_ID, ImageScene::read_cowboy_image()),
+        ] {
+            let texture = self.upload_image_to_texture(
+                &self.renderer_wrapper.device,
+                &self.renderer_wrapper.queue,
+                &pixmap,
+            );
+            self.texture_bindings.insert(
+                id,
+                texture.create_view(&wgpu::TextureViewDescriptor::default()),
+            );
+        }
     }
 
     fn upload_image_to_texture(
@@ -541,10 +526,7 @@
 
     let scenes = vello_example_scenes::get_example_scenes(
         vello_example_scenes::Capabilities::default(),
-        vec![
-            ImageSource::opaque_id(ImageId::new(0)),
-            ImageSource::opaque_id(ImageId::new(1)),
-        ],
+        ImageScene::external_texture_sources(),
     );
 
     let app_state = Rc::new(RefCell::new(AppState::new(canvas.clone(), scenes).await));
@@ -753,7 +735,7 @@
                 view: &surface_texture_view,
                 depth_view: Some(&depth_texture_view),
             },
-            &vello_gpu::TextureBindings::new(),
+            &TextureBindings::new(),
             vello_gpu::RenderOptions::default(),
         )
         .unwrap();
diff --git a/vello_gpu/examples/wgpu_webgpu/src/lib.rs b/vello_gpu/examples/wgpu_webgpu/src/lib.rs
index 39c7bd4..4776a25 100644
--- a/vello_gpu/examples/wgpu_webgpu/src/lib.rs
+++ b/vello_gpu/examples/wgpu_webgpu/src/lib.rs
@@ -13,14 +13,13 @@
 use vello_common::{
     fearless_simd::Level,
     kurbo::{Affine, Vec2},
-    paint::{ImageId, ImageSource},
 };
 use vello_example_scenes::{
     AnyScene,
-    image::ImageScene,
+    image::{COWBOY_TEXTURE_ID, FLOWER_TEXTURE_ID, ImageScene},
     performance::{FrameTiming, PerformancePanel, PerformanceStage, now},
 };
-use vello_gpu::{Pixmap, RenderSettings, RenderTargetConfig, Renderer, Scene};
+use vello_gpu::{Pixmap, RenderSettings, RenderTargetConfig, Renderer, Scene, TextureBindings};
 use wasm_bindgen::prelude::*;
 use web_sys::{Event, HtmlCanvasElement, KeyboardEvent, MouseEvent, WheelEvent};
 use wgpu::CurrentSurfaceTexture;
@@ -167,6 +166,7 @@
     width: u32,
     height: u32,
     renderer_wrapper: RendererWrapper,
+    texture_bindings: TextureBindings,
     performance: PerformancePanel<3>,
     canvas: HtmlCanvasElement,
 }
@@ -190,6 +190,7 @@
             width,
             height,
             renderer_wrapper,
+            texture_bindings: TextureBindings::new(),
             performance: PerformancePanel::new(
                 "Vello GPU · wgpu → WebGPU",
                 [
@@ -216,8 +217,7 @@
 
         update_page_url(app_state.current_scene);
         app_state.update_title();
-        // Upload images to the renderer's texture atlas.
-        app_state.upload_images_to_atlas();
+        app_state.upload_images_to_textures();
 
         app_state
     }
@@ -266,7 +266,7 @@
                     view: &surface_texture_view,
                     depth_view: Some(&self.renderer_wrapper.depth_texture_view),
                 },
-                &vello_gpu::TextureBindings::new(),
+                &self.texture_bindings,
                 vello_gpu::RenderOptions::default(),
             )
             .unwrap();
@@ -411,36 +411,21 @@
             * self.transform;
     }
 
-    fn upload_images_to_atlas(&mut self) {
-        let mut encoder =
-            self.renderer_wrapper
-                .device
-                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
-                    label: Some("Upload Image pass"),
-                });
-
-        // 1st example — uploading a pixmap directly to the texture atlas.
-        let pixmap1 = ImageScene::read_flower_image();
-        self.renderer_wrapper.renderer.upload_image(
-            &mut self.renderer_wrapper.resources,
-            &mut encoder,
-            &pixmap1,
-        );
-
-        // 2nd example — uploading from a wgpu texture.
-        let pixmap2 = ImageScene::read_cowboy_image();
-        let texture2 = self.upload_image_to_texture(
-            &self.renderer_wrapper.device,
-            &self.renderer_wrapper.queue,
-            &pixmap2,
-        );
-        self.renderer_wrapper.renderer.upload_image(
-            &mut self.renderer_wrapper.resources,
-            &mut encoder,
-            &texture2,
-        );
-
-        self.renderer_wrapper.queue.submit([encoder.finish()]);
+    fn upload_images_to_textures(&mut self) {
+        for (id, pixmap) in [
+            (FLOWER_TEXTURE_ID, ImageScene::read_flower_image()),
+            (COWBOY_TEXTURE_ID, ImageScene::read_cowboy_image()),
+        ] {
+            let texture = self.upload_image_to_texture(
+                &self.renderer_wrapper.device,
+                &self.renderer_wrapper.queue,
+                &pixmap,
+            );
+            self.texture_bindings.insert(
+                id,
+                texture.create_view(&wgpu::TextureViewDescriptor::default()),
+            );
+        }
     }
 
     fn upload_image_to_texture(
@@ -533,10 +518,7 @@
 
     let scenes = vello_example_scenes::get_example_scenes(
         vello_example_scenes::Capabilities::default(),
-        vec![
-            ImageSource::opaque_id(ImageId::new(0)),
-            ImageSource::opaque_id(ImageId::new(1)),
-        ],
+        ImageScene::external_texture_sources(),
     );
 
     let app_state = Rc::new(RefCell::new(AppState::new(canvas.clone(), scenes).await));
@@ -757,7 +739,7 @@
                 view: &surface_texture_view,
                 depth_view: Some(&depth_texture_view),
             },
-            &vello_gpu::TextureBindings::new(),
+            &TextureBindings::new(),
             vello_gpu::RenderOptions::default(),
         )
         .unwrap();
diff --git a/vello_gpu/examples/winit/src/main.rs b/vello_gpu/examples/winit/src/main.rs
index 4abac49..038383d 100644
--- a/vello_gpu/examples/winit/src/main.rs
+++ b/vello_gpu/examples/winit/src/main.rs
@@ -10,12 +10,10 @@
 use std::sync::Arc;
 use std::time::Instant;
 use vello_common::kurbo::{Affine, Point};
-use vello_common::paint::ImageId;
-use vello_common::paint::ImageSource;
-use vello_example_scenes::image::ImageScene;
+use vello_example_scenes::image::{COWBOY_TEXTURE_ID, FLOWER_TEXTURE_ID, ImageScene};
 use vello_example_scenes::spritesheet::{SPRITESHEET_TEXTURE_ID, SpritesheetScene};
 use vello_example_scenes::{AnyScene, Capabilities, get_example_scenes};
-use vello_gpu::{Pixmap, RenderSize, Renderer, Resources, Scene, TextureBindings};
+use vello_gpu::{Pixmap, RenderSize, Renderer, Resources, Scene, TextureBindings, TextureId};
 use wgpu::CurrentSurfaceTexture;
 use winit::{
     application::ApplicationHandler,
@@ -32,8 +30,7 @@
     scenes: Box<[AnyScene<Scene>]>,
     current_scene: usize,
     renderers: Vec<Option<(Renderer, Resources, RenderSize, wgpu::TextureView)>>,
-    uploaded_images: Vec<bool>,
-    spritesheet_textures: Vec<Option<wgpu::Texture>>,
+    external_textures: Vec<Option<[(TextureId, wgpu::Texture); 3]>>,
     render_state: RenderState<'s>,
     scene: Scene,
     transform: Affine,
@@ -64,10 +61,7 @@
                 }
             }
         }
-        let img_sources = vec![
-            ImageSource::opaque_id_with_transparency_hint(ImageId::new(0), false),
-            ImageSource::opaque_id(ImageId::new(1)),
-        ];
+        let img_sources = ImageScene::external_texture_sources();
         let capabilities = Capabilities {
             external_textures: true,
         };
@@ -86,10 +80,7 @@
             Capabilities {
                 external_textures: true,
             },
-            vec![
-                ImageSource::opaque_id(ImageId::new(0)),
-                ImageSource::opaque_id(ImageId::new(1)),
-            ],
+            ImageScene::external_texture_sources(),
         ),
         0,
     );
@@ -98,8 +89,7 @@
     let mut app = App {
         context: RenderContext::new(),
         renderers: vec![],
-        uploaded_images: vec![],
-        spritesheet_textures: vec![],
+        external_textures: vec![],
         scenes,
         current_scene: start_scene_index,
         render_state: RenderState::Suspended(None),
@@ -161,15 +151,12 @@
 
         self.renderers
             .resize_with(self.context.devices.len(), || None);
-        self.uploaded_images
-            .resize(self.context.devices.len(), false);
-        self.spritesheet_textures
+        self.external_textures
             .resize_with(self.context.devices.len(), || None);
         self.renderers[surface.dev_id]
             .get_or_insert_with(|| create_vello_renderer(&self.context, &surface));
 
-        self.upload_images_to_atlas(surface.dev_id);
-        self.ensure_spritesheet_uploaded(surface.dev_id);
+        self.ensure_textures_uploaded(surface.dev_id);
 
         self.render_state = RenderState::Active {
             surface: Box::new(surface),
@@ -377,9 +364,9 @@
                             label: Some("Vello Render to Surface pass"),
                         });
                 let mut texture_bindings = TextureBindings::new();
-                if let Some(texture) = self.spritesheet_textures[surface.dev_id].as_ref() {
+                for (id, texture) in self.external_textures[surface.dev_id].iter().flatten() {
                     texture_bindings.insert(
-                        SPRITESHEET_TEXTURE_ID,
+                        *id,
                         texture.create_view(&wgpu::TextureViewDescriptor::default()),
                     );
                 }
@@ -419,44 +406,22 @@
 }
 
 impl App<'_> {
-    fn upload_images_to_atlas(&mut self, device_id: usize) {
-        if self.uploaded_images[device_id] {
-            return;
-        }
-
-        let device_handle = &self.context.devices[device_id];
-        let mut encoder =
-            device_handle
-                .device
-                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
-                    label: Some("Upload Image pass"),
-                });
-
-        // 1st example — uploading pixmap directly
-        let pixmap1 = ImageScene::read_flower_image();
-        let (renderer, resources, ..) = self.renderers[device_id].as_mut().unwrap();
-        renderer.upload_image(resources, &mut encoder, &pixmap1);
-
-        // 2nd example — uploading from a texture (for cases where you already have a texture)
-        let pixmap2 = ImageScene::read_cowboy_image();
-        let texture2 =
-            self.upload_image_to_texture(&device_handle.device, &device_handle.queue, &pixmap2);
-        let (renderer, resources, ..) = self.renderers[device_id].as_mut().unwrap();
-        renderer.upload_image(resources, &mut encoder, &texture2);
-
-        device_handle.queue.submit([encoder.finish()]);
-        self.uploaded_images[device_id] = true;
-    }
-
-    fn ensure_spritesheet_uploaded(&mut self, device_id: usize) {
-        if self.spritesheet_textures[device_id].is_some() {
+    fn ensure_textures_uploaded(&mut self, device_id: usize) {
+        if self.external_textures[device_id].is_some() {
             return;
         }
         let device_handle = &self.context.devices[device_id];
-        let pixmap = SpritesheetScene::read_spritesheet();
-        let texture =
-            self.upload_image_to_texture(&device_handle.device, &device_handle.queue, &pixmap);
-        self.spritesheet_textures[device_id] = Some(texture);
+        let textures = [
+            (FLOWER_TEXTURE_ID, ImageScene::read_flower_image()),
+            (COWBOY_TEXTURE_ID, ImageScene::read_cowboy_image()),
+            (SPRITESHEET_TEXTURE_ID, SpritesheetScene::read_spritesheet()),
+        ]
+        .map(|(id, pixmap)| {
+            let texture =
+                self.upload_image_to_texture(&device_handle.device, &device_handle.queue, &pixmap);
+            (id, texture)
+        });
+        self.external_textures[device_id] = Some(textures);
     }
 
     fn upload_image_to_texture(
diff --git a/vello_gpu/src/draw.rs b/vello_gpu/src/draw.rs
index 4bc10b2..3216678 100644
--- a/vello_gpu/src/draw.rs
+++ b/vello_gpu/src/draw.rs
@@ -754,7 +754,7 @@
 
     fn atlas_image(image_id: ImageId) -> EncodedPaint {
         EncodedPaint::Image(EncodedImage {
-            source: ImageSource::opaque_id(image_id),
+            source: ImageSource::Atlas(image_id),
             sampler: ImageSampler {
                 x_extend: Extend::Pad,
                 y_extend: Extend::Pad,
diff --git a/vello_gpu/src/lib.rs b/vello_gpu/src/lib.rs
index 8a6a043..7b31fcd 100644
--- a/vello_gpu/src/lib.rs
+++ b/vello_gpu/src/lib.rs
@@ -34,8 +34,8 @@
 //! - `full_image_support` (enabled by default): If this feature is disabled, only
 //!   external textures using `TextureRegion::Full` with nearest or bilinear
 //!   sampling and opacity are supported. Enabling this feature also adds support
-//!   for uploaded images, glyph atlas caching, bicubic sampling, cropped external
-//!   textures, and colored or alpha-mask tints.
+//!   for glyph atlas caching, bicubic sampling, cropped external textures, and
+//!   colored or alpha-mask tints.
 //! - `gradient_sweep` (enabled by default): Enables rendering of sweep gradients
 //! - `wgpu_external_texture` (enabled by default): Adds a WGSL `texture_external`
 //!   binding when the wgpu device has `wgpu::Features::EXTERNAL_TEXTURE` enabled,
@@ -124,21 +124,19 @@
 pub(crate) mod draw;
 pub mod util;
 
+pub use render::{ClearSettings, Config, GpuStrip, RenderSize, TargetInit};
 #[cfg(feature = "webgl")]
 pub use render::{
-    AtlasTextureInfo, IncompatibleContextReason, IncompatiblePrecompiledShadersReason,
-    InvalidTextureTargetReason, ShaderCompilationStats, WebGlAtlasWriter, WebGlContextOperation,
-    WebGlDataTransferOperation, WebGlError, WebGlOperation, WebGlRenderOptions, WebGlRenderTarget,
-    WebGlRenderer, WebGlRendererInit, WebGlRendererInitStatus, WebGlResourceKind,
-    WebGlShaderInterfaceOperation, WebGlShaderProgramOperation, WebGlShaderStage,
-    WebGlTextureBindings, WebGlTextureTarget, WebGlTextureWithDimensions,
+    IncompatibleContextReason, IncompatiblePrecompiledShadersReason, InvalidTextureTargetReason,
+    ShaderCompilationStats, WebGlContextOperation, WebGlDataTransferOperation, WebGlError,
+    WebGlOperation, WebGlRenderOptions, WebGlRenderTarget, WebGlRenderer, WebGlRendererInit,
+    WebGlRendererInitStatus, WebGlResourceKind, WebGlShaderInterfaceOperation,
+    WebGlShaderProgramOperation, WebGlShaderStage, WebGlTextureBindings, WebGlTextureTarget,
 };
 #[cfg(feature = "wgpu")]
 pub use render::{
-    AtlasWriter, RenderOptions, RenderTarget, RenderTargetConfig, Renderer, TextureBinding,
-    TextureBindings,
+    RenderOptions, RenderTarget, RenderTargetConfig, Renderer, TextureBinding, TextureBindings,
 };
-pub use render::{ClearSettings, Config, GpuStrip, RenderSize, TargetInit};
 #[cfg(all(feature = "webgl", feature = "probe"))]
 pub use render::{WebGlPendingProbe, WebGlProbeError, WebGlProbeOperation, WebGlProbeStatus};
 #[cfg(all(feature = "wgpu", feature = "probe"))]
@@ -158,14 +156,12 @@
 pub use vello_gpu_shaders::webgl_bootstrap;
 
 use thiserror::Error;
+use vello_common::paint::ImageId;
 
 /// Errors that can occur during rendering.
 #[derive(Error, Debug, Clone)]
 #[non_exhaustive]
 pub enum RenderError {
-    /// An image atlas allocation failed.
-    #[error("Atlas allocation failed: {0}")]
-    AtlasError(#[from] AtlasError),
     /// A draw referenced a [`TextureId`] that was not provided at render time.
     #[error("Missing texture binding for {0:?}")]
     MissingTextureBinding(TextureId),
@@ -178,9 +174,9 @@
     /// tint other than opacity.
     #[error("Unsupported sampling of external texture binding {0:?}")]
     UnsupportedExternalTextureSampling(TextureId),
-    /// An image paint referenced an image that has not been uploaded.
-    #[error("Missing image cache entry for {0:?}")]
-    MissingImage(ImageId),
+    /// A glyph paint referenced an atlas entry that does not exist.
+    #[error("Missing atlas entry for {0:?}")]
+    MissingAtlasEntry(ImageId),
     /// An intermediate texture allocation failed.
     #[error(transparent)]
     IntermediateTexture(#[from] IntermediateTextureError),
diff --git a/vello_gpu/src/paint.rs b/vello_gpu/src/paint.rs
index a076b6f..823cf5c 100644
--- a/vello_gpu/src/paint.rs
+++ b/vello_gpu/src/paint.rs
@@ -221,7 +221,7 @@
 
                             (paint_type, Some(TextureSourceId::External(*id, sampler)))
                         }
-                        ImageSource::OpaqueId { id, .. } => {
+                        ImageSource::Atlas(id) => {
                             let image_resource = self.image_cache.unwrap().get(*id).unwrap();
                             (
                                 PAINT_TYPE_IMAGE,
diff --git a/vello_gpu/src/render/common/mod.rs b/vello_gpu/src/render/common/mod.rs
index 907b349..d4918cb 100644
--- a/vello_gpu/src/render/common/mod.rs
+++ b/vello_gpu/src/render/common/mod.rs
@@ -24,10 +24,6 @@
 use vello_common::geometry::{RectU16, SizeU16};
 use vello_common::record::CommandRecorder;
 
-// TODO: If we want to use native bilinear sampling for uploaded images,
-// we can pass 1 instead of 0 here.
-pub(crate) const IMAGE_PADDING: u16 = 0;
-
 /// Controls how the output target is cleared before drawing.
 #[derive(Debug, Clone, Copy, PartialEq)]
 pub enum ClearSettings<'a> {
@@ -150,17 +146,17 @@
 impl MemorySettings {
     pub(crate) fn normalize(&mut self, device_limits: &DeviceLimits) {
         let Self {
-            image_atlas_config,
+            glyph_atlas_config,
             layers_config,
         } = self;
 
-        image_atlas_config.atlas_size = SizeU16::from(image_atlas_config.atlas_size)
+        glyph_atlas_config.atlas_size = SizeU16::from(glyph_atlas_config.atlas_size)
             .clamp(1, device_limits.max_texture_dimension_2d)
             .into();
 
-        image_atlas_config.initial_atlas_count = image_atlas_config
+        glyph_atlas_config.initial_atlas_count = glyph_atlas_config
             .initial_atlas_count
-            .min(image_atlas_config.max_atlases);
+            .min(glyph_atlas_config.max_atlases);
 
         layers_config.min_texture_size = layers_config
             .min_texture_size
@@ -342,7 +338,7 @@
     #[test]
     fn normalize_memory_settings_clamps_atlas_dimensions_to_backend_limits() {
         let mut settings = MemorySettings {
-            image_atlas_config: AtlasConfig {
+            glyph_atlas_config: AtlasConfig {
                 initial_atlas_count: 8,
                 max_atlases: 16,
                 atlas_size: (8192, 2048),
@@ -353,7 +349,7 @@
 
         settings.normalize(&device_limits(4096));
 
-        let config = settings.image_atlas_config;
+        let config = settings.glyph_atlas_config;
         assert_eq!(config.initial_atlas_count, 8);
         assert_eq!(config.max_atlases, 16);
         assert_eq!(config.atlas_size, (4096, 2048));
@@ -368,7 +364,7 @@
     #[test]
     fn normalize_memory_settings_allows_lazy_initial_atlas_allocation() {
         let mut settings = MemorySettings {
-            image_atlas_config: AtlasConfig {
+            glyph_atlas_config: AtlasConfig {
                 initial_atlas_count: 0,
                 atlas_size: (0, 0),
                 ..Default::default()
@@ -378,7 +374,7 @@
 
         settings.normalize(&device_limits(4096));
 
-        let config = settings.image_atlas_config;
+        let config = settings.glyph_atlas_config;
         assert_eq!(config.initial_atlas_count, 0);
         assert_eq!(config.max_atlases, 8);
         assert_eq!(config.atlas_size, (1, 1));
diff --git a/vello_gpu/src/render/common/scene_preparer.rs b/vello_gpu/src/render/common/scene_preparer.rs
index 373a76f..fd40322 100644
--- a/vello_gpu/src/render/common/scene_preparer.rs
+++ b/vello_gpu/src/render/common/scene_preparer.rs
@@ -45,7 +45,7 @@
         device_limits: &DeviceLimits,
     ) -> (Self, Resources) {
         settings.memory_settings.normalize(device_limits);
-        let resources = Resources::new(settings.memory_settings.image_atlas_config);
+        let resources = Resources::new(settings.memory_settings.glyph_atlas_config);
         let resource_texture_dimension_2d = device_limits.resource_texture_dimension_2d();
         // Estimate the maximum number of gradient cache entries based on the resource texture
         // dimension and the maximum gradient LUT size - worst case scenario.
@@ -115,9 +115,10 @@
             self.paint_idxs[encoded_paint_idx] = current_idx;
             let gpu_paint = match paint {
                 EncodedPaint::Image(img) => match &img.source {
-                    ImageSource::OpaqueId { id, .. } => {
-                        let image_resource =
-                            image_cache.get(*id).ok_or(RenderError::MissingImage(*id))?;
+                    ImageSource::Atlas(id) => {
+                        let image_resource = image_cache
+                            .get(*id)
+                            .ok_or(RenderError::MissingAtlasEntry(*id))?;
                         encode_image_paint(img, image_resource)
                     }
                     ImageSource::ExternalTexture {
diff --git a/vello_gpu/src/render/mod.rs b/vello_gpu/src/render/mod.rs
index 64437d7..023f9b9 100644
--- a/vello_gpu/src/render/mod.rs
+++ b/vello_gpu/src/render/mod.rs
@@ -18,12 +18,11 @@
 
 #[cfg(feature = "webgl")]
 pub use webgl::{
-    AtlasTextureInfo, IncompatibleContextReason, IncompatiblePrecompiledShadersReason,
-    InvalidTextureTargetReason, ShaderCompilationStats, WebGlAtlasWriter, WebGlContextOperation,
-    WebGlDataTransferOperation, WebGlError, WebGlOperation, WebGlRenderOptions, WebGlRenderTarget,
-    WebGlRenderer, WebGlRendererInit, WebGlRendererInitStatus, WebGlResourceKind,
-    WebGlShaderInterfaceOperation, WebGlShaderProgramOperation, WebGlShaderStage,
-    WebGlTextureBindings, WebGlTextureTarget, WebGlTextureWithDimensions,
+    IncompatibleContextReason, IncompatiblePrecompiledShadersReason, InvalidTextureTargetReason,
+    ShaderCompilationStats, WebGlContextOperation, WebGlDataTransferOperation, WebGlError,
+    WebGlOperation, WebGlRenderOptions, WebGlRenderTarget, WebGlRenderer, WebGlRendererInit,
+    WebGlRendererInitStatus, WebGlResourceKind, WebGlShaderInterfaceOperation,
+    WebGlShaderProgramOperation, WebGlShaderStage, WebGlTextureBindings, WebGlTextureTarget,
 };
 #[cfg(all(feature = "webgl", feature = "probe"))]
 pub use webgl::{
@@ -32,8 +31,7 @@
 };
 #[cfg(feature = "wgpu")]
 pub use wgpu::{
-    AtlasWriter, RenderOptions, RenderTarget, RenderTargetConfig, Renderer, TextureBinding,
-    TextureBindings,
+    RenderOptions, RenderTarget, RenderTargetConfig, Renderer, TextureBinding, TextureBindings,
 };
 #[cfg(all(feature = "wgpu", feature = "probe"))]
 pub use wgpu::{WgpuPendingProbe, WgpuProbeError, WgpuProbeStatus};
diff --git a/vello_gpu/src/render/webgl/atlas.rs b/vello_gpu/src/render/webgl/atlas.rs
index fd5eb6a..fb05167 100644
--- a/vello_gpu/src/render/webgl/atlas.rs
+++ b/vello_gpu/src/render/webgl/atlas.rs
@@ -3,200 +3,35 @@
 
 //! Writing image data into atlas textures.
 
-use crate::render::webgl::bindings::WebGlTextureWithDimensions;
-use crate::render::webgl::gl::resource::Framebuffer;
 use crate::render::webgl::{
     WebGlDataTransferOperation, WebGlError, WebGlOperation, WebGlResultExt,
 };
-use alloc::sync::Arc;
 use vello_common::pixmap::Pixmap;
 use web_sys::{WebGl2RenderingContext, WebGlTexture};
 
-/// Trait for types that can write image data directly to the atlas texture in WebGL.
-///
-/// This allows efficient uploading from different sources:
-/// - `Pixmap`: Direct upload using raw pixel data
-/// - `ImageData`: Browser `ImageData` objects
-/// - Custom implementations for other image sources
-pub trait WebGlAtlasWriter {
-    /// Get the width of the image.
-    fn width(&self) -> u16;
-    /// Get the height of the image.
-    fn height(&self) -> u16;
-
-    /// Write image data to an atlas texture at the specified offset.
-    fn write_to_atlas(
-        &self,
-        gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
-    ) -> Result<(), WebGlError>;
-}
-
-/// Implementation for `Pixmap` - direct upload using raw pixel data.
-impl WebGlAtlasWriter for Pixmap {
-    fn width(&self) -> u16 {
-        self.width()
-    }
-
-    fn height(&self) -> u16 {
-        self.height()
-    }
-
-    fn write_to_atlas(
-        &self,
-        gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
-    ) -> Result<(), WebGlError> {
-        gl.active_texture(WebGl2RenderingContext::TEXTURE0);
-        gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(atlas_texture));
-
-        // Convert pixmap data to the format expected by WebGL
-        let rgba_data = self.data_as_u8_slice();
-
-        gl.tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_u8_array(
-            WebGl2RenderingContext::TEXTURE_2D,
-            0,
-            offset[0] as i32,
-            offset[1] as i32,
-            width as i32,
-            height as i32,
-            WebGl2RenderingContext::RGBA,
-            WebGl2RenderingContext::UNSIGNED_BYTE,
-            Some(rgba_data),
-        )
-        .map_js_error(WebGlOperation::DataTransfer(
-            WebGlDataTransferOperation::TextureUpload,
-        ))?;
-
-        Ok(())
-    }
-}
-
-/// Implementation for `Arc<Pixmap>`.
-impl WebGlAtlasWriter for Arc<Pixmap> {
-    fn width(&self) -> u16 {
-        self.as_ref().width()
-    }
-
-    fn height(&self) -> u16 {
-        self.as_ref().height()
-    }
-
-    fn write_to_atlas(
-        &self,
-        gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
-    ) -> Result<(), WebGlError> {
-        self.as_ref()
-            .write_to_atlas(gl, atlas_texture, offset, width, height)
-    }
-}
-
-/// Implementation for `WebGlTexture` - texture-to-texture copy.
-impl WebGlAtlasWriter for WebGlTexture {
-    fn width(&self) -> u16 {
-        // WebGL textures don't expose their dimensions directly
-        // This is a limitation - in practice, you'd need to track dimensions separately
-        // For now, we'll require the caller to provide correct width/height parameters
-        unreachable!("WebGlTexture width must be provided by caller")
-    }
-
-    fn height(&self) -> u16 {
-        // WebGL textures don't expose their dimensions directly
-        // This is a limitation - in practice, you'd need to track dimensions separately
-        // For now, we'll require the caller to provide correct width/height parameters
-        unreachable!("WebGlTexture height must be provided by caller")
-    }
-
-    fn write_to_atlas(
-        &self,
-        gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
-    ) -> Result<(), WebGlError> {
-        copy_to_texture(
-            gl,
-            |gl| {
-                // Attach source texture to read framebuffer
-                gl.framebuffer_texture_2d(
-                    WebGl2RenderingContext::READ_FRAMEBUFFER,
-                    WebGl2RenderingContext::COLOR_ATTACHMENT0,
-                    WebGl2RenderingContext::TEXTURE_2D,
-                    Some(self),
-                    0,
-                );
-            },
-            atlas_texture,
-            [u32::from(offset[0]), u32::from(offset[1])],
-            [u32::from(width), u32::from(height)],
-        )
-    }
-}
-
-impl WebGlAtlasWriter for WebGlTextureWithDimensions {
-    fn width(&self) -> u16 {
-        self.width
-    }
-
-    fn height(&self) -> u16 {
-        self.height
-    }
-
-    fn write_to_atlas(
-        &self,
-        gl: &WebGl2RenderingContext,
-        atlas_texture: &WebGlTexture,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
-    ) -> Result<(), WebGlError> {
-        self.texture
-            .write_to_atlas(gl, atlas_texture, offset, width, height)
-    }
-}
-
-/// Copy from a source texture/framebuffer to an atlas texture.
-pub(crate) fn copy_to_texture(
+pub(crate) fn write_pixmap_to_atlas(
     gl: &WebGl2RenderingContext,
-    source_setup: impl FnOnce(&WebGl2RenderingContext),
-    dest_texture: &WebGlTexture,
-    dest_offset: [u32; 2],
-    copy_size: [u32; 2],
+    pixmap: &Pixmap,
+    atlas_texture: &WebGlTexture,
+    offset: [u16; 2],
 ) -> Result<(), WebGlError> {
     gl.active_texture(WebGl2RenderingContext::TEXTURE0);
-    let read_framebuffer = Framebuffer::new(gl)?;
-    gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(dest_texture));
+    gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(atlas_texture));
 
-    // Bind the READ framebuffer
-    gl.bind_framebuffer(
-        WebGl2RenderingContext::READ_FRAMEBUFFER,
-        Some(&read_framebuffer),
-    );
-
-    // Let the caller set up the source (attach texture/layer to read framebuffer)
-    source_setup(gl);
-
-    gl.copy_tex_sub_image_2d(
+    gl.tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_u8_array(
         WebGl2RenderingContext::TEXTURE_2D,
         0,
-        dest_offset[0] as i32,
-        dest_offset[1] as i32,
-        0,
-        0,
-        copy_size[0] as i32,
-        copy_size[1] as i32,
-    );
+        offset[0] as i32,
+        offset[1] as i32,
+        i32::from(pixmap.width()),
+        i32::from(pixmap.height()),
+        WebGl2RenderingContext::RGBA,
+        WebGl2RenderingContext::UNSIGNED_BYTE,
+        Some(pixmap.data_as_u8_slice()),
+    )
+    .map_js_error(WebGlOperation::DataTransfer(
+        WebGlDataTransferOperation::TextureUpload,
+    ))?;
 
     Ok(())
 }
diff --git a/vello_gpu/src/render/webgl/bindings.rs b/vello_gpu/src/render/webgl/bindings.rs
index ad726c8..2af4b11 100644
--- a/vello_gpu/src/render/webgl/bindings.rs
+++ b/vello_gpu/src/render/webgl/bindings.rs
@@ -99,11 +99,11 @@
 
 /// Wrapper for `WebGlTexture` with known dimensions.
 #[derive(Debug, Clone)]
-pub struct WebGlTextureWithDimensions {
+pub(crate) struct WebGlTextureWithDimensions {
     /// The WebGL texture.
-    pub texture: WebGlTexture,
+    pub(crate) texture: WebGlTexture,
     /// The width of the texture.
-    pub width: u16,
+    pub(crate) width: u16,
     /// The height of the texture.
-    pub height: u16,
+    pub(crate) height: u16,
 }
diff --git a/vello_gpu/src/render/webgl/error.rs b/vello_gpu/src/render/webgl/error.rs
index d961b6f..acab953 100644
--- a/vello_gpu/src/render/webgl/error.rs
+++ b/vello_gpu/src/render/webgl/error.rs
@@ -16,9 +16,6 @@
     /// Rendering failed for a certain reason.
     #[error(transparent)]
     Render(#[from] RenderError),
-    /// An image upload was attempted without the `full_image_support` feature.
-    #[error("uploaded images require the `full_image_support` feature")]
-    FullImageSupportDisabled,
     /// A WebGL2 context could not be created for the canvas.
     #[error("WebGL2 context unavailable")]
     ContextUnavailable,
diff --git a/vello_gpu/src/render/webgl/mod.rs b/vello_gpu/src/render/webgl/mod.rs
index 2b534c2..dcd056f 100644
--- a/vello_gpu/src/render/webgl/mod.rs
+++ b/vello_gpu/src/render/webgl/mod.rs
@@ -20,6 +20,7 @@
 only break in edge cases, and some of them are also only related to conversions from f64 to f32."
 )]
 
+#[cfg(feature = "text")]
 mod atlas;
 mod bindings;
 mod error;
@@ -31,8 +32,7 @@
 mod resources;
 mod target;
 
-pub use atlas::WebGlAtlasWriter;
-pub use bindings::{WebGlTextureBindings, WebGlTextureWithDimensions};
+pub use bindings::WebGlTextureBindings;
 #[cfg(feature = "probe")]
 pub use error::WebGlProbeOperation;
 pub(crate) use error::WebGlResultExt;
@@ -45,25 +45,28 @@
 pub use init::{ShaderCompilationStats, WebGlRendererInit, WebGlRendererInitStatus};
 pub use target::{WebGlRenderTarget, WebGlTextureTarget};
 
-use crate::render::common::{IMAGE_PADDING, ScenePreparer};
+#[cfg(feature = "text")]
+use crate::RenderError;
+use crate::render::common::ScenePreparer;
 use crate::{
-    ClearSettings, RenderError, RenderSize, Resources, TargetInit,
+    ClearSettings, RenderSize, Resources, TargetInit,
     scene::Scene,
     schedule::IntermediateTextureAllocations,
     target::{DrawPassTarget, RootTarget},
 };
 use alloc::vec::Vec;
 #[cfg(feature = "text")]
+use atlas::write_pixmap_to_atlas;
+#[cfg(feature = "text")]
 use glifo::PendingClearRect;
 use passes::{WebGlPrograms, WebGlRendererContext};
 use resources::WebGlResources;
 use vello_common::color::{AlphaColor, Srgb};
+use vello_common::geometry::{RectU16, SizeU16};
 use vello_common::image_cache::ImageCache;
 use vello_common::multi_atlas::{AtlasConfig, AtlasId};
-use vello_common::{
-    geometry::{RectU16, SizeU16},
-    paint::ImageId,
-};
+#[cfg(feature = "text")]
+use vello_common::{paint::ImageId, pixmap::Pixmap};
 use web_sys::{WebGl2RenderingContext, WebGlRenderbuffer, WebGlTexture};
 
 /// Vello GPU's WebGL2 renderer.
@@ -112,17 +115,6 @@
     }
 }
 
-/// Current allocation state of the WebGL image/glyph atlas textures.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct AtlasTextureInfo {
-    /// Width of each atlas texture.
-    pub width: u16,
-    /// Height of each atlas texture.
-    pub height: u16,
-    /// Number of atlas textures available to the image cache.
-    pub texture_count: u32,
-}
-
 impl WebGlRenderer {
     /// Returns the shaders compiled while creating this renderer and the time spent.
     ///
@@ -160,6 +152,7 @@
     /// To render without any texture bindings, you can pass an empty [`WebGlTextureBindings`].
     ///
     /// [`TextureId`]: vello_common::TextureId
+    /// [`RenderError::MissingTextureBinding`]: crate::RenderError::MissingTextureBinding
     pub fn render(
         &mut self,
         scene: &Scene,
@@ -185,7 +178,7 @@
                     image_cache,
                     upload.image_id,
                     &upload.pixmap,
-                    Some([dst_x, dst_y]),
+                    [dst_x, dst_y],
                 )
             },
         )?;
@@ -240,8 +233,11 @@
     /// `texture_bindings` provides [externally bound textures](`WebGlTextureBindings`)
     /// referenced by the scene. Pass `&WebGlTextureBindings::new()` if the scene does
     /// not use any.
-    #[doc(hidden)]
-    pub fn render_to_atlas(
+    #[cfg_attr(
+        not(feature = "text"),
+        expect(dead_code, reason = "only glyph caching renders into the atlas")
+    )]
+    pub(crate) fn render_to_atlas(
         &mut self,
         scene: &Scene,
         atlas_count: u32,
@@ -404,126 +400,29 @@
         &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.
-    /// It allocates space in the image cache and uploads the image data to the atlas texture.
-    ///
-    /// # Errors
-    /// Returns [`WebGlError::FullImageSupportDisabled`] if the `full_image_support` feature
-    /// is disabled, or an error if allocating or uploading the image fails.
-    #[track_caller]
-    pub fn upload_image<T: WebGlAtlasWriter>(
-        &mut self,
-        resources: &mut Resources,
-        writer: &T,
-    ) -> Result<ImageId, WebGlError> {
-        self.upload_image_with(&mut resources.image_cache, writer, IMAGE_PADDING)
-    }
-
-    #[track_caller]
-    pub(crate) fn upload_image_with<T: WebGlAtlasWriter>(
-        &mut self,
-        image_cache: &mut ImageCache,
-        writer: &T,
-        padding: u16,
-    ) -> Result<ImageId, WebGlError> {
-        if !cfg!(feature = "full_image_support") {
-            return Err(WebGlError::FullImageSupportDisabled);
-        }
-
-        let width = writer.width();
-        let height = writer.height();
-        let image_id = image_cache
-            .allocate(width, height, padding)
-            .map_err(RenderError::AtlasError)?;
-
-        if let Err(err) = self.write_to_atlas(image_cache, image_id, writer, None) {
-            image_cache.deallocate(image_id);
-
-            return Err(err);
-        }
-
-        Ok(image_id)
-    }
-
-    /// Write pixel data to an existing atlas allocation.
-    ///
-    /// Unlike [`upload_image`](Self::upload_image), this does not allocate space in the image
-    /// cache. The `image_id` must have been previously allocated (e.g. via
-    /// `ImageCache::allocate`). This is useful for uploading CPU-side pixel data (such as
-    /// bitmap font glyphs) to a pre-allocated atlas region.
-    ///
-    /// If `offset_override` is `Some`, the provided offset is used instead of the
-    /// allocator-assigned position. Pass `None` to use the default atlas offset.
-    pub(crate) fn write_to_atlas<T: WebGlAtlasWriter>(
+    #[cfg(feature = "text")]
+    fn write_to_atlas(
         &mut self,
         image_cache: &ImageCache,
         image_id: ImageId,
-        writer: &T,
-        offset_override: Option<[u16; 2]>,
+        pixmap: &Pixmap,
+        offset: [u16; 2],
     ) -> Result<(), WebGlError> {
         let image_resource = image_cache
             .get(image_id)
-            .ok_or(RenderError::MissingImage(image_id))?;
+            .ok_or(RenderError::MissingAtlasEntry(image_id))?;
 
         self.resources
             .maybe_create_atlas_textures(&self.gl, image_cache.atlas_count() as u32)?;
-        let offset = offset_override.unwrap_or(image_resource.offset);
-        writer.write_to_atlas(
+        write_pixmap_to_atlas(
             &self.gl,
+            pixmap,
             &self.resources.atlas_textures[image_resource.atlas_id.as_u32() as usize],
             offset,
-            writer.width(),
-            writer.height(),
-        )?;
-
-        Ok(())
+        )
     }
 
-    /// Destroy an image from the cache and clear the allocated slot in the atlas.
-    pub fn destroy_image(
-        &mut self,
-        resources: &mut Resources,
-        image_id: ImageId,
-    ) -> Result<(), WebGlError> {
-        if let Some(image_resource) = resources.image_cache.get(image_id) {
-            let padding = image_resource.padding;
-            self.clear_atlas_region(
-                image_resource.atlas_id,
-                [
-                    image_resource.offset[0] - padding,
-                    image_resource.offset[1] - padding,
-                ],
-                image_resource.width + padding * 2,
-                image_resource.height + padding * 2,
-            )?;
-
-            let _ = resources.image_cache.deallocate(image_id);
-        }
-
-        Ok(())
-    }
-
-    /// Returns an individual image atlas texture.
-    pub fn atlas_texture(&self, atlas_id: AtlasId) -> &WebGlTexture {
-        self.resources
-            .atlas_textures
-            .get(atlas_id.as_u32() as usize)
-            .map(|texture| &**texture)
-            .unwrap()
-    }
-
-    /// Returns the current allocation state of the image/glyph atlas texture.
-    pub fn atlas_info(&self) -> AtlasTextureInfo {
-        let resources = &self.resources;
-        AtlasTextureInfo {
-            width: resources.atlas_size.0,
-            height: resources.atlas_size.1,
-            texture_count: u32::try_from(resources.atlas_textures.len()).unwrap(),
-        }
-    }
-
+    #[cfg(feature = "text")]
     /// Clear a specific region of an atlas texture.
     fn clear_atlas_region(
         &mut self,
diff --git a/vello_gpu/src/render/wgpu/atlas.rs b/vello_gpu/src/render/wgpu/atlas.rs
index 2dea355..49da5b7 100644
--- a/vello_gpu/src/render/wgpu/atlas.rs
+++ b/vello_gpu/src/render/wgpu/atlas.rs
@@ -3,150 +3,38 @@
 
 //! Writing image data into atlas textures.
 
-use alloc::sync::Arc;
 use vello_common::pixmap::Pixmap;
-use wgpu::{CommandEncoder, Device, Extent3d, Queue, Texture};
+use wgpu::{Extent3d, Queue, Texture};
 
-/// Trait for types that can write image data directly to the atlas texture.
-///
-/// This allows efficient uploading from different sources:
-/// - `Pixmap`: Direct upload without intermediate texture
-/// - `Texture`: Texture-to-texture copy
-/// - Custom implementations for other image sources
-pub trait AtlasWriter {
-    /// Get the width of the image.
-    fn width(&self) -> u16;
-    /// Get the height of the image.
-    fn height(&self) -> u16;
-
-    /// Write image data to an atlas texture at the specified offset.
-    fn write_to_atlas(
-        &self,
-        device: &Device,
-        queue: &Queue,
-        encoder: &mut CommandEncoder,
-        atlas_texture: &Texture,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
+pub(crate) fn write_pixmap_to_atlas(
+    queue: &Queue,
+    pixmap: &Pixmap,
+    atlas_texture: &Texture,
+    offset: [u16; 2],
+) {
+    let width = u32::from(pixmap.width());
+    let height = u32::from(pixmap.height());
+    queue.write_texture(
+        wgpu::TexelCopyTextureInfo {
+            texture: atlas_texture,
+            mip_level: 0,
+            origin: wgpu::Origin3d {
+                x: u32::from(offset[0]),
+                y: u32::from(offset[1]),
+                z: 0,
+            },
+            aspect: wgpu::TextureAspect::All,
+        },
+        pixmap.data_as_u8_slice(),
+        wgpu::TexelCopyBufferLayout {
+            offset: 0,
+            bytes_per_row: Some(4 * width),
+            rows_per_image: Some(height),
+        },
+        Extent3d {
+            width,
+            height,
+            depth_or_array_layers: 1,
+        },
     );
 }
-
-/// Implementation for `wgpu::Texture` - uses texture-to-texture copy
-impl AtlasWriter for Texture {
-    fn width(&self) -> u16 {
-        u16::try_from(self.width()).expect("texture width exceeds the u16 atlas domain")
-    }
-
-    fn height(&self) -> u16 {
-        u16::try_from(self.height()).expect("texture height exceeds the u16 atlas domain")
-    }
-
-    fn write_to_atlas(
-        &self,
-        _device: &Device,
-        _queue: &Queue,
-        encoder: &mut CommandEncoder,
-        atlas_texture: &Texture,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
-    ) {
-        encoder.copy_texture_to_texture(
-            wgpu::TexelCopyTextureInfo {
-                texture: self,
-                mip_level: 0,
-                origin: wgpu::Origin3d::ZERO,
-                aspect: wgpu::TextureAspect::All,
-            },
-            wgpu::TexelCopyTextureInfo {
-                texture: atlas_texture,
-                mip_level: 0,
-                origin: wgpu::Origin3d {
-                    x: u32::from(offset[0]),
-                    y: u32::from(offset[1]),
-                    z: 0,
-                },
-                aspect: wgpu::TextureAspect::All,
-            },
-            Extent3d {
-                width: u32::from(width),
-                height: u32::from(height),
-                depth_or_array_layers: 1,
-            },
-        );
-    }
-}
-
-/// Implementation for `Pixmap` - direct upload to atlas
-impl AtlasWriter for Pixmap {
-    fn width(&self) -> u16 {
-        self.width()
-    }
-
-    fn height(&self) -> u16 {
-        self.height()
-    }
-
-    fn write_to_atlas(
-        &self,
-        _device: &Device,
-        queue: &Queue,
-        _encoder: &mut CommandEncoder,
-        atlas_texture: &Texture,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
-    ) {
-        let width = u32::from(width);
-        let height = u32::from(height);
-        queue.write_texture(
-            wgpu::TexelCopyTextureInfo {
-                texture: atlas_texture,
-                mip_level: 0,
-                origin: wgpu::Origin3d {
-                    x: u32::from(offset[0]),
-                    y: u32::from(offset[1]),
-                    z: 0,
-                },
-                aspect: wgpu::TextureAspect::All,
-            },
-            self.data_as_u8_slice(),
-            wgpu::TexelCopyBufferLayout {
-                offset: 0,
-                bytes_per_row: Some(4 * width),
-                rows_per_image: Some(height),
-            },
-            Extent3d {
-                width,
-                height,
-                depth_or_array_layers: 1,
-            },
-        );
-    }
-}
-
-/// Implementation for `Arc<Pixmap>`
-impl AtlasWriter for Arc<Pixmap> {
-    fn width(&self) -> u16 {
-        self.as_ref().width()
-    }
-
-    fn height(&self) -> u16 {
-        self.as_ref().height()
-    }
-
-    fn write_to_atlas(
-        &self,
-        device: &Device,
-        queue: &Queue,
-        encoder: &mut CommandEncoder,
-        atlas_texture: &Texture,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
-    ) {
-        self.as_ref()
-            .write_to_atlas(device, queue, encoder, atlas_texture, offset, width, height);
-    }
-}
diff --git a/vello_gpu/src/render/wgpu/mod.rs b/vello_gpu/src/render/wgpu/mod.rs
index d9e6645..51bbaaf 100644
--- a/vello_gpu/src/render/wgpu/mod.rs
+++ b/vello_gpu/src/render/wgpu/mod.rs
@@ -9,6 +9,7 @@
 only break in edge cases, and some of them are also only related to conversions from f64 to f32."
 )]
 
+#[cfg(feature = "text")]
 mod atlas;
 mod bindings;
 mod passes;
@@ -16,18 +17,19 @@
 mod probe;
 mod resources;
 
-pub use atlas::AtlasWriter;
 pub use bindings::{TextureBinding, TextureBindings};
 #[cfg(feature = "probe")]
 pub use probe::{WgpuPendingProbe, WgpuProbeError, WgpuProbeStatus};
 
-use crate::render::common::{IMAGE_PADDING, ScenePreparer};
+use crate::render::common::ScenePreparer;
 use crate::{
     ClearSettings, GpuStrip, RenderError, RenderSettings, RenderSize, Resources, TargetInit,
     render::common::DeviceLimits, scene::Scene, schedule::IntermediateTextureAllocations,
     target::RootTarget,
 };
 use alloc::vec::Vec;
+#[cfg(feature = "text")]
+use atlas::write_pixmap_to_atlas;
 use core::fmt::Debug;
 #[cfg(feature = "text")]
 use glifo::PendingClearRect;
@@ -37,10 +39,11 @@
 use vello_common::geometry::{RectU16, SizeU16};
 use vello_common::image_cache::ImageCache;
 use vello_common::multi_atlas::{AtlasConfig, AtlasId};
-use wgpu::{
-    CommandEncoder, Device, Extent3d, Queue, RenderPassColorAttachment, RenderPassDescriptor,
-    Texture, TextureView, TextureViewDescriptor,
-};
+#[cfg(feature = "text")]
+use vello_common::pixmap::Pixmap;
+#[cfg(feature = "text")]
+use wgpu::Texture;
+use wgpu::{CommandEncoder, Device, Extent3d, Queue, TextureView, TextureViewDescriptor};
 
 /// Options for the renderer
 #[derive(Debug)]
@@ -250,10 +253,9 @@
                 |renderer, image_cache, upload, dst_x, dst_y| {
                     renderer.write_to_atlas(
                         image_cache,
-                        encoder,
                         upload.image_id,
                         &upload.pixmap,
-                        Some([dst_x, dst_y]),
+                        [dst_x, dst_y],
                     );
 
                     Ok(())
@@ -300,8 +302,11 @@
     /// `texture_bindings` provides [externally bound textures](`TextureBindings`)
     /// referenced by the scene. Pass `&TextureBindings::new()` if the scene does
     /// not use any.
-    #[doc(hidden)]
-    pub fn render_to_atlas(
+    #[cfg_attr(
+        not(feature = "text"),
+        expect(dead_code, reason = "only glyph caching renders into the atlas")
+    )]
+    pub(crate) fn render_to_atlas(
         &mut self,
         scene: &Scene,
         atlas_count: u32,
@@ -449,150 +454,33 @@
         Ok(())
     }
 
-    /// Upload image to cache and atlas in one step. Returns the `ImageId`.
-    ///
-    /// It's used when an image is not already in the cache.
-    ///
-    /// This is a convenience method that:
-    /// 1. Reserves space in the image cache
-    /// 2. Writes the image data directly to the atlas
-    /// 3. Returns the `ImageId` for use in rendering
-    #[track_caller]
-    pub fn upload_image<T: AtlasWriter>(
-        &mut self,
-        resources: &mut Resources,
-        encoder: &mut CommandEncoder,
-        writer: &T,
-    ) -> vello_common::paint::ImageId {
-        self.upload_image_with(&mut resources.image_cache, encoder, writer, IMAGE_PADDING)
-    }
-
-    #[track_caller]
-    pub(crate) fn upload_image_with<T: AtlasWriter>(
-        &mut self,
-        image_cache: &mut ImageCache,
-        encoder: &mut CommandEncoder,
-        writer: &T,
-        padding: u16,
-    ) -> vello_common::paint::ImageId {
-        if !cfg!(feature = "full_image_support") {
-            panic!("uploaded images require the `full_image_support` feature");
-        }
-
-        let width = writer.width();
-        let height = writer.height();
-        let image_id = image_cache.allocate(width, height, padding).unwrap();
-        self.write_to_atlas(image_cache, encoder, image_id, writer, None);
-        image_id
-    }
-
-    /// Write pixel data to an existing atlas allocation.
-    ///
-    /// Unlike [`upload_image`](Self::upload_image), this does not allocate space in the image
-    /// cache. The `image_id` must have been previously allocated (e.g. via
-    /// `ImageCache::allocate`). This is useful for uploading CPU-side pixel data (such as
-    /// bitmap font glyphs) to a pre-allocated atlas region.
-    ///
-    /// If `offset_override` is `Some`, the provided offset is used instead of the
-    /// allocator-assigned position. Pass `None` to use the default atlas offset.
-    pub(crate) fn write_to_atlas<T: AtlasWriter>(
+    #[cfg(feature = "text")]
+    fn write_to_atlas(
         &mut self,
         image_cache: &ImageCache,
-        encoder: &mut CommandEncoder,
         image_id: vello_common::paint::ImageId,
-        writer: &T,
-        offset_override: Option<[u16; 2]>,
+        pixmap: &Pixmap,
+        offset: [u16; 2],
     ) {
         let image_resource = image_cache.get(image_id).expect("Image resource not found");
 
         self.resources
             .maybe_create_atlas_textures(&self.device, image_cache.atlas_count() as u32);
-        let offset = offset_override.unwrap_or(image_resource.offset);
-        let atlas_texture =
-            &self.resources.atlas_textures[image_resource.atlas_id.as_u32() as usize];
-        writer.write_to_atlas(
-            &self.device,
+        write_pixmap_to_atlas(
             &self.queue,
-            encoder,
-            atlas_texture,
+            pixmap,
+            self.atlas_texture(image_resource.atlas_id),
             offset,
-            writer.width(),
-            writer.height(),
         );
     }
 
-    /// Destroy an image from the cache and clear the allocated slot in the atlas.
-    pub fn destroy_image(
-        &mut self,
-        resources: &mut Resources,
-        encoder: &mut CommandEncoder,
-        image_id: vello_common::paint::ImageId,
-    ) {
-        if let Some(image_resource) = resources.image_cache.deallocate(image_id) {
-            let padding = image_resource.padding;
-
-            self.clear_atlas_region(
-                encoder,
-                image_resource.atlas_id,
-                [
-                    image_resource.offset[0] - padding,
-                    image_resource.offset[1] - padding,
-                ],
-                image_resource.width + padding * 2,
-                image_resource.height + padding * 2,
-            );
-        }
-    }
-
-    /// Returns an individual image atlas texture.
-    pub fn atlas_texture(&self, atlas_id: AtlasId) -> &Texture {
+    #[cfg(feature = "text")]
+    fn atlas_texture(&self, atlas_id: AtlasId) -> &Texture {
         self.resources
             .atlas_textures
             .get(atlas_id.as_u32() as usize)
             .unwrap()
     }
-
-    /// Clear a specific region of the atlas texture.
-    fn clear_atlas_region(
-        &mut self,
-        encoder: &mut CommandEncoder,
-        atlas_id: AtlasId,
-        offset: [u16; 2],
-        width: u16,
-        height: u16,
-    ) {
-        let layer_view = self.resources.atlas_texture_views[atlas_id.as_u32() as usize].clone();
-
-        let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
-            label: Some("Clear Atlas Region"),
-            color_attachments: &[Some(RenderPassColorAttachment {
-                view: &layer_view,
-                resolve_target: None,
-                ops: wgpu::Operations {
-                    // Don't clear entire texture, just the scissor region
-                    load: wgpu::LoadOp::Load,
-                    store: wgpu::StoreOp::Store,
-                },
-                depth_slice: None,
-            })],
-            depth_stencil_attachment: None,
-            occlusion_query_set: None,
-            timestamp_writes: None,
-            multiview_mask: None,
-        });
-
-        // Set scissor rectangle to limit clearing to specific region
-        render_pass.set_scissor_rect(
-            u32::from(offset[0]),
-            u32::from(offset[1]),
-            u32::from(width),
-            u32::from(height),
-        );
-        // Use atlas clear pipeline to render transparent pixels
-        render_pass.set_pipeline(&self.programs.clear.atlas);
-        // Draw fullscreen quad
-        render_pass.draw(0..4, 0..1);
-    }
 }
 
 #[cfg(feature = "text")]
diff --git a/vello_gpu/src/render/wgpu/passes/clear.rs b/vello_gpu/src/render/wgpu/passes/clear.rs
index 0824b56..dd48835 100644
--- a/vello_gpu/src/render/wgpu/passes/clear.rs
+++ b/vello_gpu/src/render/wgpu/passes/clear.rs
@@ -13,15 +13,13 @@
     RenderPassColorAttachment, RenderPassDescriptor, RenderPipeline, TextureView, util::DeviceExt,
 };
 
-/// Pipelines for clearing rectangles of layer textures, the root target and atlas regions.
+/// Pipelines for clearing rectangles of layer textures and the root target.
 #[derive(Debug)]
 pub(crate) struct ClearPipelines {
     /// Layer-clear pipeline.
     pub(crate) layer: RenderPipeline,
     /// User-target rectangle-clear pipeline.
     pub(crate) root: RenderPipeline,
-    /// Pipeline for clearing atlas regions.
-    pub(crate) atlas: RenderPipeline,
 }
 
 impl ClearPipelines {
@@ -87,48 +85,9 @@
         let root_clear_pipeline =
             create_clear_pipeline("Root Clear Pipeline", render_target_config.format);
 
-        // Create atlas clear pipeline
-        let atlas_clear_pipeline_layout =
-            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
-                label: Some("Atlas Clear Pipeline Layout"),
-                bind_group_layouts: &[],
-                immediate_size: 0,
-            });
-        // TODO: Change the atlas clear pipeline to use the existing layer clear mechanism.
-        let atlas_clear_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
-            label: Some("Atlas Clear Pipeline"),
-            layout: Some(&atlas_clear_pipeline_layout),
-            vertex: wgpu::VertexState {
-                module: &clear_shader,
-                // Use a different vertex shader entry point
-                entry_point: Some("vs_main_fullscreen"),
-                buffers: &[],
-                compilation_options: PipelineCompilationOptions::default(),
-            },
-            fragment: Some(wgpu::FragmentState {
-                module: &clear_shader,
-                entry_point: Some("fs_transparent"),
-                targets: &[Some(ColorTargetState {
-                    format: wgpu::TextureFormat::Rgba8Unorm,
-                    blend: None,
-                    write_mask: ColorWrites::ALL,
-                })],
-                compilation_options: PipelineCompilationOptions::default(),
-            }),
-            primitive: wgpu::PrimitiveState {
-                topology: wgpu::PrimitiveTopology::TriangleStrip,
-                ..Default::default()
-            },
-            depth_stencil: None,
-            multisample: wgpu::MultisampleState::default(),
-            multiview_mask: None,
-            cache: pipeline_cache,
-        });
-
         Self {
             layer: clear_pipeline,
             root: root_clear_pipeline,
-            atlas: atlas_clear_pipeline,
         }
     }
 }
diff --git a/vello_gpu/src/resources.rs b/vello_gpu/src/resources.rs
index ba3ad9f..b55534b 100644
--- a/vello_gpu/src/resources.rs
+++ b/vello_gpu/src/resources.rs
@@ -23,9 +23,9 @@
 }
 
 impl Resources {
-    pub(crate) fn new(image_atlas_config: AtlasConfig) -> Self {
+    pub(crate) fn new(glyph_atlas_config: AtlasConfig) -> Self {
         Self {
-            image_cache: ImageCache::new_with_config(image_atlas_config),
+            image_cache: ImageCache::new_with_config(glyph_atlas_config),
             #[cfg(feature = "text")]
             glyph_prep_cache: GlyphPrepCache::default(),
             // Will be initialized lazily.
diff --git a/vello_gpu/src/scene.rs b/vello_gpu/src/scene.rs
index fceac0d..dbfe0ba 100644
--- a/vello_gpu/src/scene.rs
+++ b/vello_gpu/src/scene.rs
@@ -155,8 +155,8 @@
 /// Settings controlling usage of GPU memory.
 #[derive(Copy, Clone, Debug, Default)]
 pub struct MemorySettings {
-    /// Configuration for the atlas holding uploaded images.
-    pub image_atlas_config: AtlasConfig,
+    /// Configuration for the atlas holding cached glyphs.
+    pub glyph_atlas_config: AtlasConfig,
     /// Configuration for intermediate layer and scratch textures.
     pub layers_config: LayersConfig,
 }
diff --git a/vello_gpu/src/text.rs b/vello_gpu/src/text.rs
index f2ece83..b1d190b 100644
--- a/vello_gpu/src/text.rs
+++ b/vello_gpu/src/text.rs
@@ -12,7 +12,7 @@
 
 use core::ops::RangeInclusive;
 
-use crate::{AtlasId, Resources, Scene};
+use crate::{Resources, Scene};
 use glifo::atlas::{PendingBitmapUpload, PendingClearRect};
 use glifo::renderer::replay_atlas_commands;
 use glifo::{
@@ -23,7 +23,7 @@
 use peniko::color::palette::css::BLACK;
 use peniko::color::{AlphaColor, Srgb};
 use vello_common::kurbo::{Affine, BezPath, Rect};
-use vello_common::multi_atlas::AtlasConfig;
+use vello_common::multi_atlas::{AtlasConfig, AtlasId};
 use vello_common::paint::{Image, ImageSource, PaintType};
 use vello_common::peniko;
 
@@ -361,7 +361,7 @@
 
     #[inline]
     fn atlas_image_source(&self, atlas_slot: &AtlasSlot) -> ImageSource {
-        ImageSource::opaque_id(atlas_slot.image_id)
+        ImageSource::Atlas(atlas_slot.image_id)
     }
 
     #[inline]
diff --git a/vello_gpu_shaders/shaders/clear.wesl b/vello_gpu_shaders/shaders/clear.wesl
index 31f736a..5d76970 100644
--- a/vello_gpu_shaders/shaders/clear.wesl
+++ b/vello_gpu_shaders/shaders/clear.wesl
@@ -23,23 +23,6 @@
     return ClearVertexOutput(vec4<f32>(ndc, 0.0, 1.0), unpack4x8unorm(color));
 }
 
-// This vertex shader is used for clearing atlas regions.
-@vertex
-fn vs_main_fullscreen(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4<f32> {
-    // This generates a quad that covers the entire render target (hence "fullscreen"),
-    // but the actual clearing region is controlled by the scissor test set on the render pass.
-    // This approach is more efficient than generating region-specific geometry because:
-    // 1. No vertex buffer needed - geometry generated from vertex index alone
-    // 2. Same simple shader works for any region size
-    // 3. Hardware scissor test efficiently clips to the desired region
-    // 4. GPU rasterizer + scissor test is faster than complex vertex calculations
-    //
-    // Map vertex_index (0-3) to fullscreen quad corners in NDC:
-    // 0 → (-1,-1), 1 → (1,-1), 2 → (-1,1), 3 → (1,1)
-    let ndc = quad_corner(vertex_index) * 2.0 - vec2(1.0);
-    return vec4<f32>(ndc, 0.0, 1.0);
-}
-
 @fragment
 fn fs_main(
     @interpolate(flat)
@@ -47,8 +30,3 @@
 ) -> @location(0) vec4<f32> {
     return color;
 }
-
-@fragment
-fn fs_transparent(@builtin(position) position: vec4<f32>) -> @location(0) vec4<f32> {
-    return vec4<f32>(0.0, 0.0, 0.0, 0.0);
-}
diff --git a/vello_tests/snapshots/external_texture_atlas_interleaving.png b/vello_tests/snapshots/external_texture_atlas_interleaving.png
index a35dd48..23e425c 100644
--- a/vello_tests/snapshots/external_texture_atlas_interleaving.png
+++ b/vello_tests/snapshots/external_texture_atlas_interleaving.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:01045880a522829e8562753dd1e7df94d5fee0f701e9ee7a2f1ea763e00947d1
-size 154
+oid sha256:39be692c9350b7ad90f6dcf66b0c5a48b2e4ca7f3ea69881d1de3886c62431ed
+size 245
diff --git a/vello_tests/snapshots/image_with_transform_skew_x_1.png b/vello_tests/snapshots/image_with_transform_skew_x_1.png
index 61f26eb..278b26d 100644
--- a/vello_tests/snapshots/image_with_transform_skew_x_1.png
+++ b/vello_tests/snapshots/image_with_transform_skew_x_1.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:c8254c076f4696a530fb15126ec7d2f4f742db8669a98d606441eed269a1122a
-size 167
+oid sha256:708b03c57bed3a0316f9915fac717d82755e78407a815752c9b873fde3db9ed5
+size 193
diff --git a/vello_tests/snapshots/image_with_transform_skew_x_2.png b/vello_tests/snapshots/image_with_transform_skew_x_2.png
index 3231647..eb586ff 100644
--- a/vello_tests/snapshots/image_with_transform_skew_x_2.png
+++ b/vello_tests/snapshots/image_with_transform_skew_x_2.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:3621a2ec080f47882f1c43436b72ecf80bc493fa56195926cda2850826d4b8e8
-size 173
+oid sha256:c524e04209cebba067b1f46034e8db005f445fb32b24f47ee0d2bdd27cec062e
+size 198
diff --git a/vello_tests/snapshots/image_with_transform_skew_y_1.png b/vello_tests/snapshots/image_with_transform_skew_y_1.png
index f90a2e1..dc4be15 100644
--- a/vello_tests/snapshots/image_with_transform_skew_y_1.png
+++ b/vello_tests/snapshots/image_with_transform_skew_y_1.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:4e4845f4b959226b1a0c43e7b91f6f2d145aa3215fb726ddbed712686fff7ce7
-size 394
+oid sha256:6c15a14c85183c474fa41e1b4e36404517d69844c17089aa8d5e8f8666ae5201
+size 466
diff --git a/vello_tests/snapshots/image_with_transform_skew_y_2.png b/vello_tests/snapshots/image_with_transform_skew_y_2.png
index 4cf719b..fc2fe82 100644
--- a/vello_tests/snapshots/image_with_transform_skew_y_2.png
+++ b/vello_tests/snapshots/image_with_transform_skew_y_2.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:fd39139982043996e84c1d220ddc58c7c6a58cb818ae46543d4b045bc1a4abf5
-size 383
+oid sha256:3c1b03a0c68595eef96b5352cd2306942b548cc0d231537d919373727632618e
+size 477
diff --git a/vello_tests/src/renderer.rs b/vello_tests/src/renderer.rs
index 2210cf9..908a00b 100644
--- a/vello_tests/src/renderer.rs
+++ b/vello_tests/src/renderer.rs
@@ -9,7 +9,7 @@
 use vello_common::filter_effects::Filter;
 use vello_common::kurbo::{Affine, BezPath, Rect, Stroke};
 use vello_common::mask::Mask;
-use vello_common::paint::{ImageId, ImageSource, PaintType, TextureRegion, Tint};
+use vello_common::paint::{ImageSource, PaintType, TextureRegion, Tint};
 use vello_common::peniko::{BlendMode, Fill, FontData};
 use vello_common::pixmap::Pixmap;
 use vello_cpu::{
@@ -102,7 +102,6 @@
     fn snapshot(&mut self) -> Pixmap;
     fn register_external_texture(&mut self, pixmap: Arc<Pixmap>) -> TextureId;
     fn get_image_source(&mut self, pixmap: Arc<Pixmap>) -> ImageSource;
-    fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId;
 }
 
 pub struct CpuRenderer {
@@ -354,12 +353,7 @@
     }
 
     fn get_image_source(&mut self, pixmap: Arc<Pixmap>) -> ImageSource {
-        let id = self.resources.register_image(Arc::clone(&pixmap));
-        ImageSource::opaque_id_with_transparency_hint(id, pixmap.may_have_transparency())
-    }
-
-    fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
-        self.resources.register_image(pixmap)
+        ImageSource::Pixmap(pixmap)
     }
 }
 
@@ -507,21 +501,6 @@
             self.gpu_test_guard = Some(WGPU_TEST_MUTEX.lock().unwrap());
         }
     }
-
-    fn upload_image_with_resources(
-        &mut self,
-        pixmap: &Arc<Pixmap>,
-        label: &'static str,
-    ) -> ImageId {
-        let mut encoder = self
-            .device
-            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
-        let image_id = self
-            .renderer
-            .upload_image(&mut self.resources, &mut encoder, pixmap);
-        self.queue.submit([encoder.finish()]);
-        image_id
-    }
 }
 
 #[cfg(not(all(target_arch = "wasm32", feature = "webgl")))]
@@ -882,12 +861,9 @@
     }
 
     fn get_image_source(&mut self, pixmap: Arc<Pixmap>) -> ImageSource {
-        let image_id = self.upload_image_with_resources(&pixmap, "Upload Test Image");
-        ImageSource::opaque_id_with_transparency_hint(image_id, pixmap.may_have_transparency())
-    }
-
-    fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
-        self.upload_image_with_resources(&pixmap, "Register Test Image")
+        let may_have_transparency = pixmap.may_have_transparency();
+        let texture_id = self.register_external_texture(pixmap);
+        ImageSource::external_texture(texture_id, TextureRegion::Full, may_have_transparency)
     }
 }
 
@@ -1027,12 +1003,6 @@
             depth.as_ref(),
         ));
     }
-
-    fn upload_image(&mut self, pixmap: &Arc<Pixmap>) -> ImageId {
-        self.renderer
-            .upload_image(&mut self.resources, pixmap)
-            .unwrap()
-    }
 }
 
 #[cfg(all(target_arch = "wasm32", feature = "webgl"))]
@@ -1355,12 +1325,9 @@
     }
 
     fn get_image_source(&mut self, pixmap: Arc<Pixmap>) -> ImageSource {
-        let image_id = self.upload_image(&pixmap);
-        ImageSource::opaque_id_with_transparency_hint(image_id, pixmap.may_have_transparency())
-    }
-
-    fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId {
-        self.upload_image(&pixmap)
+        let may_have_transparency = pixmap.may_have_transparency();
+        let texture_id = self.register_external_texture(pixmap);
+        ImageSource::external_texture(texture_id, TextureRegion::Full, may_have_transparency)
     }
 }
 
diff --git a/vello_tests/tests/external_texture.rs b/vello_tests/tests/external_texture.rs
index 4677b5a..758b630 100644
--- a/vello_tests/tests/external_texture.rs
+++ b/vello_tests/tests/external_texture.rs
@@ -17,6 +17,7 @@
 
     use crate::load_image;
     use crate::renderer::Renderer;
+    use crate::util::layout_glyphs_roboto;
 
     const SPRITES: [RectU16; 4] = [
         // Checkmark.
@@ -106,18 +107,17 @@
         ));
     }
 
-    fn draw_atlas_rect(ctx: &mut impl Renderer, image: ImageSource, rect: Rect) {
-        ctx.set_paint_transform(Affine::IDENTITY);
-        ctx.set_paint(Image {
-            image,
-            sampler: ImageSampler {
-                x_extend: Extend::Pad,
-                y_extend: Extend::Pad,
-                quality: ImageQuality::Low,
-                alpha: 1.0,
-            },
-        });
-        ctx.fill_rect(&rect);
+    fn fill_cached_glyph(ctx: &mut impl Renderer, color: AlphaColor<Srgb>, x: f64, y: f64) {
+        let font_size = 32.;
+        let (font, glyphs) = layout_glyphs_roboto("H", font_size);
+        ctx.set_transform(Affine::translate((x, y)));
+        ctx.set_paint(color);
+        ctx.glyph_run(&font)
+            .font_size(font_size)
+            .atlas_cache(true)
+            .fill_glyphs(glyphs.into_iter())
+            .unwrap();
+        ctx.set_transform(Affine::IDENTITY);
     }
 
     fn texture_circle(ctx: &mut impl Renderer, texture_id: TextureId, circle: Circle) {
@@ -420,17 +420,14 @@
 
     #[vello_test]
     fn external_texture_atlas_interleaving(ctx: &mut impl Renderer) {
-        let atlas_red = ctx.get_image_source(solid_pixmap(254, 0, 0, 254));
         let external_green = ctx.register_external_texture(solid_pixmap(0, 254, 0, 254));
-        let atlas_blue = ctx.get_image_source(solid_pixmap(0, 0, 254, 254));
         let external_yellow = ctx.register_external_texture(solid_pixmap(254, 254, 0, 254));
-        let atlas_magenta = ctx.get_image_source(solid_pixmap(254, 0, 254, 254));
 
-        draw_atlas_rect(ctx, atlas_red, Rect::new(10., 10., 34., 34.));
+        fill_cached_glyph(ctx, AlphaColor::from_rgba8(254, 0, 0, 254), 10., 34.);
         fill_texture_rect(ctx, external_green, 66., 10., 24., 24., true);
-        draw_atlas_rect(ctx, atlas_blue, Rect::new(38., 38., 62., 62.));
+        fill_cached_glyph(ctx, AlphaColor::from_rgba8(0, 0, 254, 254), 38., 62.);
         fill_texture_rect(ctx, external_yellow, 10., 66., 24., 24., true);
-        draw_atlas_rect(ctx, atlas_magenta, Rect::new(66., 66., 90., 90.));
+        fill_cached_glyph(ctx, AlphaColor::from_rgba8(254, 0, 254, 254), 66., 90.);
     }
 
     #[vello_test]
diff --git a/vello_tests/tests/image.rs b/vello_tests/tests/image.rs
index 46f33bb..e126a9b 100644
--- a/vello_tests/tests/image.rs
+++ b/vello_tests/tests/image.rs
@@ -210,11 +210,11 @@
     );
 }
 
-#[vello_test(gpu_diff_pixels = 64)]
+#[vello_test]
 fn image_with_transform_skew_x_1(ctx: &mut impl Renderer) {
     transform(
         ctx,
-        Affine::translate((-50.0, 0.0)) * Affine::skew(tan_45(), 0.0),
+        Affine::translate((-49.5, 0.0)) * Affine::skew(tan_45(), 0.0),
         25.0,
         25.0,
         75.0,
@@ -222,11 +222,11 @@
     );
 }
 
-#[vello_test(gpu_diff_pixels = 64)]
+#[vello_test]
 fn image_with_transform_skew_x_2(ctx: &mut impl Renderer) {
     transform(
         ctx,
-        Affine::translate((50.0, 0.0)) * Affine::skew(-tan_45(), 0.0),
+        Affine::translate((50.5, 0.0)) * Affine::skew(-tan_45(), 0.0),
         25.0,
         25.0,
         75.0,
@@ -234,11 +234,11 @@
     );
 }
 
-#[vello_test(gpu_diff_pixels = 64)]
+#[vello_test]
 fn image_with_transform_skew_y_1(ctx: &mut impl Renderer) {
     transform(
         ctx,
-        Affine::translate((0.0, 50.0)) * Affine::skew(0.0, -tan_45()),
+        Affine::translate((0.0, 50.5)) * Affine::skew(0.0, -tan_45()),
         25.0,
         25.0,
         75.0,
@@ -246,11 +246,11 @@
     );
 }
 
-#[vello_test(gpu_diff_pixels = 64)]
+#[vello_test]
 fn image_with_transform_skew_y_2(ctx: &mut impl Renderer) {
     transform(
         ctx,
-        Affine::translate((0.0, -50.0)) * Affine::skew(0.0, tan_45()),
+        Affine::translate((0.0, -49.5)) * Affine::skew(0.0, tan_45()),
         25.0,
         25.0,
         75.0,
@@ -491,7 +491,7 @@
     );
 }
 
-#[vello_test]
+#[vello_test(gpu_tolerance = 1)]
 fn image_bilinear_5x_scale(ctx: &mut impl Renderer) {
     let image_source = rgb_img_2x2(ctx);
     quality(
@@ -503,7 +503,7 @@
     );
 }
 
-#[vello_test]
+#[vello_test(gpu_tolerance = 1)]
 fn image_bilinear_10x_scale(ctx: &mut impl Renderer) {
     let image_source = rgb_img_2x2(ctx);
     quality(
@@ -515,7 +515,7 @@
     );
 }
 
-#[vello_test]
+#[vello_test(gpu_tolerance = 1)]
 fn image_bilinear_with_rotation(ctx: &mut impl Renderer) {
     let image_source = rgb_img_2x2(ctx);
     quality(
@@ -527,7 +527,7 @@
     );
 }
 
-#[vello_test]
+#[vello_test(gpu_tolerance = 1)]
 fn image_bilinear_with_translation(ctx: &mut impl Renderer) {
     let image_source = rgb_img_2x2(ctx);
     quality(
@@ -708,11 +708,9 @@
 ];
 
 /// Test rendering "hello" from a glyph atlas (spritesheet-style).
-/// Uses `ImageSource::OpaqueId` to demonstrate the image registry pattern.
 #[vello_test(width = 60, height = 30, skip_gpu)]
 fn image_spritesheet(ctx: &mut impl Renderer) {
-    let atlas_id = ctx.register_image(load_image!("glyph_atlas"));
-    let atlas_src = ImageSource::opaque_id(atlas_id);
+    let atlas_src = ctx.get_image_source(load_image!("glyph_atlas"));
 
     let start_x = 10.0;
     let start_y = 8.0;
@@ -755,8 +753,7 @@
 /// Same as `image_spritesheet`, but renders "hello world" with a purple tint.
 #[vello_test(width = 60, height = 30, skip_multithreaded)]
 fn image_spritesheet_tinted(ctx: &mut impl Renderer) {
-    let atlas_id = ctx.register_image(load_image!("glyph_atlas"));
-    let atlas_src = ImageSource::opaque_id(atlas_id);
+    let atlas_src = ctx.get_image_source(load_image!("glyph_atlas"));
 
     let start_x = 10.0;
     let start_y = 8.0;
diff --git a/vello_tests/tests/image_atlas.rs b/vello_tests/tests/image_atlas.rs
deleted file mode 100644
index 4add876..0000000
--- a/vello_tests/tests/image_atlas.rs
+++ /dev/null
@@ -1,152 +0,0 @@
-// Copyright 2026 the Vello Authors
-// SPDX-License-Identifier: Apache-2.0 OR MIT
-
-//! Tests for the image/glyph atlas configuration of the WebGL renderer.
-
-use vello_common::{
-    kurbo::Rect,
-    paint::{Image, ImageSource, TextureRegion},
-    peniko::ImageSampler,
-    pixmap::Pixmap,
-};
-use vello_gpu::{
-    AtlasConfig, AtlasId, AtlasTextureInfo, MemorySettings, RenderError, RenderSettings, Scene,
-    TextureId, WebGlError, WebGlRenderer, WebGlTextureBindings,
-};
-use wasm_bindgen::JsCast;
-use wasm_bindgen_test::*;
-use web_sys::{HtmlCanvasElement, WebGl2RenderingContext};
-
-fn create_canvas() -> HtmlCanvasElement {
-    web_sys::window()
-        .unwrap()
-        .document()
-        .unwrap()
-        .create_element("canvas")
-        .unwrap()
-        .dyn_into()
-        .unwrap()
-}
-
-#[wasm_bindgen_test]
-fn image_atlas_texture_is_created_on_first_upload() {
-    let canvas = create_canvas();
-    canvas.set_width(100);
-    canvas.set_height(100);
-
-    let atlas_config = AtlasConfig {
-        initial_atlas_count: 0,
-        atlas_size: (10, 10),
-        ..AtlasConfig::default()
-    };
-    let settings = RenderSettings::default().with_memory_settings(MemorySettings {
-        image_atlas_config: atlas_config,
-        ..MemorySettings::default()
-    });
-    let (mut renderer, mut resources) = WebGlRenderer::new_with(&canvas, settings, true).unwrap();
-
-    assert_eq!(
-        renderer.atlas_info(),
-        AtlasTextureInfo {
-            width: 10,
-            height: 10,
-            texture_count: 0,
-        },
-        "renderer should start without allocated atlas textures"
-    );
-    assert_eq!(
-        renderer.gl_context().get_error(),
-        WebGl2RenderingContext::NO_ERROR,
-        "renderer initialization should not produce a WebGL error"
-    );
-
-    renderer
-        .upload_image(&mut resources, &Pixmap::new(2, 2))
-        .unwrap();
-
-    assert_eq!(
-        renderer.atlas_info(),
-        AtlasTextureInfo {
-            width: 10,
-            height: 10,
-            texture_count: 1,
-        },
-        "first upload should allocate the first configured atlas texture"
-    );
-    assert_eq!(
-        renderer.gl_context().get_error(),
-        WebGl2RenderingContext::NO_ERROR,
-        "first atlas upload should not produce a WebGL error"
-    );
-}
-
-/// Uploading an image that is larger than the configured atlas must fail with
-/// `AtlasError::TextureTooLarge`.
-///
-/// The renderer constructor configures the allocator in the returned resources, which runs the
-/// `TextureTooLarge` check.
-#[wasm_bindgen_test]
-fn image_atlas_upload_larger_than_atlas_fails() {
-    let canvas = create_canvas();
-    canvas.set_width(100);
-    canvas.set_height(100);
-
-    let atlas_config = AtlasConfig {
-        atlas_size: (10, 10),
-        ..AtlasConfig::default()
-    };
-    let settings = RenderSettings::default().with_memory_settings(MemorySettings {
-        image_atlas_config: atlas_config,
-        ..MemorySettings::default()
-    });
-
-    let (mut renderer, mut resources) = WebGlRenderer::new_with(&canvas, settings, true).unwrap();
-
-    // The image is much larger than the 10x10 atlas, so the upload must fail.
-    let image = Pixmap::new(64, 64);
-    assert!(
-        matches!(
-            renderer.upload_image(&mut resources, &image),
-            Err(WebGlError::Render(RenderError::AtlasError(_)))
-        ),
-        "oversized image upload should return an atlas error"
-    );
-}
-
-#[wasm_bindgen_test]
-fn image_atlas_rejects_sampling_from_its_render_target() {
-    let canvas = create_canvas();
-    let atlas_config = AtlasConfig {
-        initial_atlas_count: 1,
-        atlas_size: (1, 1),
-        ..AtlasConfig::default()
-    };
-    let settings = RenderSettings::default().with_memory_settings(MemorySettings {
-        image_atlas_config: atlas_config,
-        ..MemorySettings::default()
-    });
-    let (mut renderer, _) = WebGlRenderer::new_with(&canvas, settings, false).unwrap();
-    let texture_id = TextureId(0);
-    let mut bindings = WebGlTextureBindings::new();
-    bindings.insert(
-        texture_id,
-        renderer.atlas_texture(AtlasId::new(0)).clone(),
-        1,
-        1,
-    );
-
-    let mut scene = Scene::new(1, 1);
-    scene.set_paint(Image {
-        image: ImageSource::external_texture(texture_id, TextureRegion::Full, false),
-        sampler: ImageSampler::default(),
-    });
-    scene.fill_rect(&Rect::new(0.0, 0.0, 1.0, 1.0));
-
-    assert!(
-        matches!(
-            renderer.render_to_atlas(&scene, 1, atlas_config, AtlasId::new(0), &bindings),
-            Err(WebGlError::Render(RenderError::TextureFeedbackLoop(id))) if id == texture_id
-        ),
-        "sampling from the render target should fail"
-    );
-}
diff --git a/vello_tests/tests/mod.rs b/vello_tests/tests/mod.rs
index e6258fa..acceb30 100644
--- a/vello_tests/tests/mod.rs
+++ b/vello_tests/tests/mod.rs
@@ -42,8 +42,6 @@
 mod gpu_schedule;
 mod gradient;
 mod image;
-#[cfg(all(target_arch = "wasm32", feature = "webgl"))]
-mod image_atlas;
 mod issues;
 mod layer;
 mod mask;
@@ -66,4 +64,6 @@
 mod wgpu_probe;
 #[cfg(not(target_arch = "wasm32"))]
 mod wgpu_resource_texture;
+#[cfg(not(target_arch = "wasm32"))]
+mod wgpu_texture_target;
 mod wide_tile;
diff --git a/vello_tests/tests/webgl_texture_target.rs b/vello_tests/tests/webgl_texture_target.rs
index 9d557fe..8cdcf06 100644
--- a/vello_tests/tests/webgl_texture_target.rs
+++ b/vello_tests/tests/webgl_texture_target.rs
@@ -7,13 +7,16 @@
 use vello_common::color::palette::css::{BLUE, RED};
 use vello_common::color::{AlphaColor, Srgb};
 use vello_common::kurbo::Rect;
+use vello_common::paint::{Image, ImageSource, TextureRegion};
+use vello_common::peniko::ImageSampler;
 use vello_gpu::{
-    RenderSettings, Resources, Scene, WebGlRenderOptions, WebGlRenderTarget, WebGlRenderer,
-    WebGlTextureBindings,
+    RenderError, RenderSettings, Resources, Scene, TextureId, WebGlError, WebGlRenderOptions,
+    WebGlRenderTarget, WebGlRenderer, WebGlTextureBindings,
 };
 use vello_tests::renderer::{TestTextureTarget, create_webgl_depth_buffer, read_webgl_pixels};
 use wasm_bindgen::JsCast;
 use wasm_bindgen_test::*;
+use web_sys::WebGl2RenderingContext;
 
 const SIZE: u16 = 8;
 
@@ -109,3 +112,47 @@
 
     assert_all(&second.read_pixels(&gl), BLUE);
 }
+
+#[wasm_bindgen_test]
+fn texture_target_rejects_sampling_from_itself() {
+    let (mut renderer, mut resources) = renderer();
+    let gl = renderer.gl_context().clone();
+    let texture = gl.create_texture().unwrap();
+    gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture));
+    gl.tex_storage_2d(
+        WebGl2RenderingContext::TEXTURE_2D,
+        1,
+        WebGl2RenderingContext::RGBA8,
+        SIZE.into(),
+        SIZE.into(),
+    );
+    gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, None);
+    let target = renderer
+        .create_texture_target(&texture, None, SIZE, SIZE)
+        .unwrap();
+    let texture_id = TextureId(0);
+    let mut bindings = WebGlTextureBindings::new();
+    bindings.insert(texture_id, texture, SIZE, SIZE);
+
+    let mut scene = Scene::new(SIZE, SIZE);
+    scene.set_paint(Image {
+        image: ImageSource::external_texture(texture_id, TextureRegion::Full, false),
+        sampler: ImageSampler::default(),
+    });
+    scene.fill_rect(&Rect::new(0.0, 0.0, f64::from(SIZE), f64::from(SIZE)));
+
+    let result = renderer.render(
+        &scene,
+        &mut resources,
+        WebGlRenderTarget::Texture(&target),
+        &bindings,
+        WebGlRenderOptions::default(),
+    );
+    assert!(
+        matches!(
+            result,
+            Err(WebGlError::Render(RenderError::TextureFeedbackLoop(id))) if id == texture_id
+        ),
+        "sampling from the render target should fail, got {result:?}"
+    );
+}
diff --git a/vello_tests/tests/wgpu_texture_target.rs b/vello_tests/tests/wgpu_texture_target.rs
new file mode 100644
index 0000000..5159bf4
--- /dev/null
+++ b/vello_tests/tests/wgpu_texture_target.rs
@@ -0,0 +1,71 @@
+// Copyright 2026 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Tests for rendering into a texture with the wgpu renderer.
+
+use vello_common::kurbo::Rect;
+use vello_common::paint::{Image, ImageSource, TextureRegion};
+use vello_common::peniko::ImageSampler;
+use vello_cpu::{Level, RenderMode};
+use vello_gpu::{
+    RenderError, RenderOptions, RenderTarget, RenderTargetConfig, Scene, TextureBindings, TextureId,
+};
+
+use crate::renderer::{GpuRenderer, Renderer};
+
+#[test]
+fn texture_target_rejects_sampling_from_itself() {
+    let ctx = GpuRenderer::new(1, 1, 0, Level::baseline(), RenderMode::OptimizeSpeed);
+    let device = ctx.device();
+    let texture = device.create_texture(&wgpu::TextureDescriptor {
+        label: None,
+        size: wgpu::Extent3d {
+            width: 1,
+            height: 1,
+            depth_or_array_layers: 1,
+        },
+        mip_level_count: 1,
+        sample_count: 1,
+        dimension: wgpu::TextureDimension::D2,
+        format: wgpu::TextureFormat::Rgba8Unorm,
+        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
+        view_formats: &[],
+    });
+    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
+    let (mut renderer, mut resources) = vello_gpu::Renderer::new(
+        device,
+        ctx.queue(),
+        &RenderTargetConfig {
+            format: texture.format(),
+            width: 1,
+            height: 1,
+        },
+    );
+    let texture_id = TextureId(0);
+    let mut bindings = TextureBindings::new();
+    bindings.insert(texture_id, view.clone());
+
+    let mut scene = Scene::new(1, 1);
+    scene.set_paint(Image {
+        image: ImageSource::external_texture(texture_id, TextureRegion::Full, false),
+        sampler: ImageSampler::default(),
+    });
+    scene.fill_rect(&Rect::new(0.0, 0.0, 1.0, 1.0));
+
+    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
+    let result = renderer.render(
+        &scene,
+        &mut resources,
+        &mut encoder,
+        RenderTarget {
+            view: &view,
+            depth_view: None,
+        },
+        &bindings,
+        RenderOptions::default(),
+    );
+    assert!(
+        matches!(result, Err(RenderError::TextureFeedbackLoop(id)) if id == texture_id),
+        "sampling from the render target should fail, got {result:?}"
+    );
+}
diff --git a/vello_tests/vello_example_scenes/src/image.rs b/vello_tests/vello_example_scenes/src/image.rs
index 9efa731..71ed040 100644
--- a/vello_tests/vello_example_scenes/src/image.rs
+++ b/vello_tests/vello_example_scenes/src/image.rs
@@ -10,12 +10,18 @@
 use vello_common::pixmap::{PixelMetadata, Pixmap};
 use vello_common::{
     kurbo::{Affine, Rect},
-    paint::{Image, ImageSource},
+    paint::{Image, ImageSource, TextureRegion},
     peniko::{Extend, ImageQuality},
 };
+use vello_gpu::TextureId;
 
 use crate::{ExampleScene, RenderingContext};
 
+/// The flower image's texture ID.
+pub const FLOWER_TEXTURE_ID: TextureId = TextureId(1);
+/// The cowboy image's texture ID.
+pub const COWBOY_TEXTURE_ID: TextureId = TextureId(2);
+
 /// Image scene state
 #[derive(Debug, Default)]
 pub struct ImageScene {
@@ -174,6 +180,14 @@
 }
 
 impl ImageScene {
+    /// The image sources for the external textures.
+    pub fn external_texture_sources() -> Vec<ImageSource> {
+        vec![
+            ImageSource::external_texture(FLOWER_TEXTURE_ID, TextureRegion::Full, false),
+            ImageSource::external_texture(COWBOY_TEXTURE_ID, TextureRegion::Full, true),
+        ]
+    }
+
     /// Read the flower image
     pub fn read_flower_image() -> Pixmap {
         let image_data = include_bytes!("../../../assets/splash-flower.jpg");