.
diff --git a/sparse_strips/vello_common/src/encode.rs b/sparse_strips/vello_common/src/encode.rs
index 02f244a..113b70a 100644
--- a/sparse_strips/vello_common/src/encode.rs
+++ b/sparse_strips/vello_common/src/encode.rs
@@ -614,7 +614,7 @@
     pub sampler: ImageSampler,
     /// Whether the sampled content may contain non-opaque pixels.
     pub may_have_transparency: bool,
-    /// Inverse destination transform, mapping scene coordinates to local source-rect space.
+    /// Inverse paint transform, mapping scene coordinates to local source-region space.
     pub transform: Affine,
     /// Optional tint applied to the sampled color.
     pub tint: Option<Tint>,
diff --git a/sparse_strips/vello_example_scenes/src/lib.rs b/sparse_strips/vello_example_scenes/src/lib.rs
index 0003d52..83d0ab8 100644
--- a/sparse_strips/vello_example_scenes/src/lib.rs
+++ b/sparse_strips/vello_example_scenes/src/lib.rs
@@ -30,8 +30,8 @@
 pub use vello_common::peniko::{BlendMode, Fill, FontData, ImageQuality};
 #[cfg(feature = "cpu")]
 use vello_cpu::{RenderContext, Resources as CpuResources};
+pub use vello_hybrid::{ExternalTextureRect, TextureId};
 use vello_hybrid::{Resources as HybridResources, Scene};
-pub use vello_hybrid::{SampleRect, TextureId};
 
 /// Renderer capability flags controlling which scenes are listed by [`get_example_scenes`].
 ///
@@ -40,7 +40,7 @@
 #[derive(Default, Clone, Copy, Debug)]
 pub struct Capabilities {
     /// Whether the renderer supports externally bound textures and
-    /// [`RenderingContext::draw_texture_rects`].
+    /// [`RenderingContext::draw_texture_rect`].
     pub external_textures: bool,
 }
 
@@ -105,14 +105,8 @@
     fn pop_layer(&mut self);
     /// Pop the last clip path.
     fn pop_clip_path(&mut self);
-    /// Sample rectangular regions from an externally bound texture and draw them with the
-    /// corresponding transforms.
-    fn draw_texture_rects(
-        &mut self,
-        texture_id: TextureId,
-        quality: ImageQuality,
-        rects: impl IntoIterator<Item = SampleRect>,
-    );
+    /// Sample a rectangular region from an externally bound texture and draw it.
+    fn draw_texture_rect(&mut self, rect: ExternalTextureRect);
 }
 
 #[cfg(feature = "cpu")]
@@ -211,12 +205,7 @@
         Self::pop_clip_path(self);
     }
 
-    fn draw_texture_rects(
-        &mut self,
-        _texture_id: TextureId,
-        _quality: ImageQuality,
-        _rects: impl IntoIterator<Item = SampleRect>,
-    ) {
+    fn draw_texture_rect(&mut self, _rect: ExternalTextureRect) {
         unimplemented!("vello_cpu does not yet support external textures");
     }
 }
@@ -316,13 +305,8 @@
         Self::pop_clip_path(self);
     }
 
-    fn draw_texture_rects(
-        &mut self,
-        texture_id: TextureId,
-        quality: ImageQuality,
-        rects: impl IntoIterator<Item = SampleRect>,
-    ) {
-        self.draw_texture_rects(texture_id, quality, rects);
+    fn draw_texture_rect(&mut self, rect: ExternalTextureRect) {
+        self.draw_texture_rect(rect);
     }
 }
 
diff --git a/sparse_strips/vello_example_scenes/src/spritesheet.rs b/sparse_strips/vello_example_scenes/src/spritesheet.rs
index 083c627..70974e5 100644
--- a/sparse_strips/vello_example_scenes/src/spritesheet.rs
+++ b/sparse_strips/vello_example_scenes/src/spritesheet.rs
@@ -6,10 +6,10 @@
 use std::io::Cursor;
 
 use vello_common::geometry::RectU16;
-use vello_common::kurbo::Affine;
-use vello_common::peniko::ImageQuality;
+use vello_common::kurbo::{Affine, Rect};
+use vello_common::peniko::{Extend, ImageQuality, ImageSampler};
 use vello_common::pixmap::Pixmap;
-use vello_hybrid::{SampleRect, TextureId};
+use vello_hybrid::{ExternalTextureRect, TextureId};
 
 use crate::{ExampleScene, RenderingContext};
 
@@ -30,9 +30,8 @@
 
 /// The spritesheet scene.
 ///
-/// Draws many rectangular regions of one externally bound texture in a single
-/// [`RenderingContext::draw_texture_rects`] call. The host is responsible for uploading the
-/// spritesheet (see [`SpritesheetScene::read_spritesheet`]) and binding it under
+/// Draws many rectangular regions of one externally bound texture. The host is responsible for
+/// uploading the spritesheet (see [`SpritesheetScene::read_spritesheet`]) and binding it under
 /// [`SPRITESHEET_TEXTURE_ID`] at render time.
 #[derive(Debug, Default)]
 pub struct SpritesheetScene {}
@@ -79,9 +78,6 @@
         const CELL_W: f64 = 80.;
         const CELL_H: f64 = 80.;
 
-        ctx.set_transform(root_transform);
-
-        let mut rects = Vec::with_capacity(COLS * ROWS);
         for row in 0..ROWS {
             for col in 0..COLS {
                 let i = row * COLS + col;
@@ -96,18 +92,32 @@
                 let half_w = f64::from(sprite.width()) * 0.5;
                 let half_h = f64::from(sprite.height()) * 0.5;
 
-                // This per-rect transform maps the local source region (with origin (0,0) at
-                // the sampled region's top-left corner) into the destination.
                 let transform = Affine::translate((cx, cy))
                     * Affine::rotate(rotation)
                     * Affine::skew(skew_x, skew_y)
                     * Affine::scale(scale)
                     * Affine::translate((-half_w, -half_h));
 
-                rects.push(SampleRect::new(sprite, transform));
+                ctx.set_transform(root_transform * transform);
+                ctx.set_paint_transform(Affine::IDENTITY);
+                ctx.draw_texture_rect(ExternalTextureRect {
+                    texture_id: SPRITESHEET_TEXTURE_ID,
+                    source_region: sprite,
+                    destination_rect: Rect::new(
+                        0.0,
+                        0.0,
+                        f64::from(sprite.width()),
+                        f64::from(sprite.height()),
+                    ),
+                    sampler: ImageSampler {
+                        x_extend: Extend::Pad,
+                        y_extend: Extend::Pad,
+                        quality: ImageQuality::Medium,
+                        alpha: 1.0,
+                    },
+                    may_have_transparency: true,
+                });
             }
         }
-
-        ctx.draw_texture_rects(SPRITESHEET_TEXTURE_ID, ImageQuality::Medium, rects);
     }
 }
diff --git a/sparse_strips/vello_hybrid/src/draw.rs b/sparse_strips/vello_hybrid/src/draw.rs
index 7f09e0f..730a24a 100644
--- a/sparse_strips/vello_hybrid/src/draw.rs
+++ b/sparse_strips/vello_hybrid/src/draw.rs
@@ -691,6 +691,20 @@
     }
 
     #[test]
+    fn texture_runs_coalesce_distinct_paints_for_same_texture() {
+        let texture = TextureId(10);
+        let encoded = [external(texture), external(texture)];
+        let resolver = PaintResolver::new(&encoded, &[0, 3]);
+        let mut case = DrawCase::new(RootTarget::UserSurface, RectU16::new(0, 0, 8, 8));
+        let mut draw = Draw::default();
+
+        case.rect(&mut draw, rect(0.0), indexed(0), resolver);
+        case.rect(&mut draw, rect(4.0), indexed(1), resolver);
+
+        assert_eq!(run_starts(&draw.external_texture_runs), [(texture, 0)]);
+    }
+
+    #[test]
     fn texture_runs_collapse_across_atlas_images() {
         let texture = TextureId(10);
         let encoded = [external(texture), atlas_image()];
diff --git a/sparse_strips/vello_hybrid/src/lib.rs b/sparse_strips/vello_hybrid/src/lib.rs
index b1ed16c..2163d6c 100644
--- a/sparse_strips/vello_hybrid/src/lib.rs
+++ b/sparse_strips/vello_hybrid/src/lib.rs
@@ -101,7 +101,7 @@
 #[cfg(all(feature = "webgl", feature = "probe"))]
 pub use render::{WebGlPendingProbe, WebGlProbeError, WebGlProbeStatus};
 pub use resources::Resources;
-pub use sampling::SampleRect;
+pub use sampling::ExternalTextureRect;
 pub use scene::{LayersConfig, MemorySettings, RenderSettings, Scene};
 #[cfg(feature = "text")]
 pub use text::{GlyphRunBuilder, HybridGlyphRunBackend};
diff --git a/sparse_strips/vello_hybrid/src/sampling.rs b/sparse_strips/vello_hybrid/src/sampling.rs
index 812eb84..c2edb4b 100644
--- a/sparse_strips/vello_hybrid/src/sampling.rs
+++ b/sparse_strips/vello_hybrid/src/sampling.rs
@@ -3,16 +3,29 @@
 
 //! Sampling helpers for image drawing.
 
+use vello_common::TextureId;
 use vello_common::geometry::RectU16;
-use vello_common::kurbo::Affine;
+use vello_common::kurbo::Rect;
+use vello_common::peniko::ImageSampler;
 
-/// A rectangular source region sampled from an image input (e.g., [`crate::TextureId`]), paired
-/// with a transform of the rectangle into the destination.
+/// A rectangular region sampled from an externally bound texture and drawn into a destination
+/// rectangle.
 #[derive(Debug, Clone, Copy)]
-pub struct SampleRect {
+pub struct ExternalTextureRect {
+    /// The external texture to sample.
+    pub texture_id: TextureId,
+
     /// Source region in texel coordinates.
     pub source_region: RectU16,
 
+    /// Destination rectangle in local scene coordinates.
+    pub destination_rect: Rect,
+
+    /// Sampling parameters.
+    ///
+    /// A sampler alpha other than `1.0` is not currently supported.
+    pub sampler: ImageSampler,
+
     /// Whether the sampled source region may contain non-opaque pixels.
     ///
     /// Only set this to `false` if every pixel is guaranteed to be opaque (e.g. in a texture
@@ -21,28 +34,4 @@
     ///
     /// If unsure, always set this to `true`.
     pub may_have_transparency: bool,
-
-    /// Transform mapping the local source region to the destination.
-    ///
-    /// This maps from the *local* rectangle into the destination, ignoring the origin of
-    /// [`Self::source_region`].
-    pub transform: Affine,
-}
-
-impl SampleRect {
-    /// Create a new [`SampleRect`].
-    pub fn new(source_region: RectU16, transform: Affine) -> Self {
-        Self {
-            source_region,
-            may_have_transparency: true,
-            transform,
-        }
-    }
-
-    /// Indicate that the sample rect only contains opaque pixels.
-    #[must_use]
-    pub fn with_opaque_hint(mut self) -> Self {
-        self.may_have_transparency = false;
-        self
-    }
 }
diff --git a/sparse_strips/vello_hybrid/src/scene.rs b/sparse_strips/vello_hybrid/src/scene.rs
index d6a8504..041cab0 100644
--- a/sparse_strips/vello_hybrid/src/scene.rs
+++ b/sparse_strips/vello_hybrid/src/scene.rs
@@ -5,7 +5,7 @@
 
 #[cfg(feature = "text")]
 use crate::Resources;
-use crate::sampling::SampleRect;
+use crate::sampling::ExternalTextureRect;
 #[cfg(feature = "text")]
 use crate::text::GlyphRunBuilder;
 use alloc::vec;
@@ -27,7 +27,7 @@
 #[cfg(feature = "text")]
 use vello_common::peniko::FontData;
 use vello_common::peniko::color::palette::css::BLACK;
-use vello_common::peniko::{BlendMode, Extend, Fill, ImageQuality, ImageSampler};
+use vello_common::peniko::{BlendMode, Fill, ImageSampler};
 use vello_common::record::{CommandRecorder, Drawable, LayerClip, LayerProps, PoppedLayer};
 use vello_common::render_state::RenderState;
 use vello_common::strip::Strip;
@@ -307,19 +307,16 @@
         &mut self,
         texture_id: TextureId,
         source_region: RectU16,
-        quality: ImageQuality,
-        x_extend: Extend,
-        y_extend: Extend,
+        sampler: ImageSampler,
         transform: Affine,
         may_have_transparency: bool,
     ) -> Paint {
+        assert_eq!(
+            sampler.alpha, 1.0,
+            "external textures currently only support alpha of 1"
+        );
+
         let idx = self.encoded_paints.len();
-        let sampler = ImageSampler {
-            x_extend,
-            y_extend,
-            quality,
-            alpha: 1.0,
-        };
         let has_opacity = self
             .render_state
             .tint
@@ -493,92 +490,36 @@
         });
     }
 
-    /// Sample rectangular regions from an externally bound texture and draw them with the
-    /// corresponding transforms.
+    /// Draw a region from an externally bound texture.
     ///
-    /// The per-rect transforms are composed with the current
-    /// [scene transform][`Self::set_transform`]. This transform is relative to the local region
-    /// defined by each [`SampleRect`]: i.e., the origin of each [`SampleRect`] is used only to
-    /// determine the region to sample in the source [`TextureId`], and is ignored for determining
-    /// the destination. Note that the [`paint transform`](Self::set_paint_transform) has no impact
-    /// on this method.
-    ///
-    /// A texture with the given [`TextureId`] must be supplied at render time. The given
-    /// [source regions][`SampleRect::source_region`] must be within bounds of that texture. The
-    /// texture is treated as premultiplied alpha in the render target's color space. See the
-    /// backend's binding type for more information on texture requirements.
-    pub fn draw_texture_rects(
-        &mut self,
-        texture_id: TextureId,
-        quality: ImageQuality,
-        rects: impl IntoIterator<Item = SampleRect>,
-    ) {
-        // This API currently doesn't take extend mode parameters: as of writing, the
-        // `render.wesl` shader does not use extend modes to sample across boundaries, i.e.,
-        // sampling near a boundary doesn't take extend modes into account when determining where
-        // the sample should be taken.
-        //
-        // Because in this API the destination drawn is always the transformed input rect, this
-        // means extend modes don't currently materially impact rendering. In general drawing with
-        // an external texture brush, extend modes would matter, so we still encode them.
-        let x_extend = Extend::Pad;
-        let y_extend = Extend::Pad;
+    /// See the documentation of [`ExternalTextureRect`] for more information.
+    pub fn draw_texture_rect(&mut self, rect: ExternalTextureRect) {
+        if rect.source_region.is_empty() || rect.destination_rect.is_zero_area() {
+            return;
+        }
 
         self.with_optional_filter_or_blend_layer(|ctx| {
-            let use_fast_rect = ctx.can_emit_fast_strips();
+            let paint = ctx.encode_external_texture_paint(
+                rect.texture_id,
+                rect.source_region,
+                rect.sampler,
+                ctx.effective_paint_transform(),
+                rect.may_have_transparency,
+            );
 
-            for rect in rects {
-                if rect.source_region.is_empty() {
-                    continue;
-                }
-
-                let w = f64::from(rect.source_region.width());
-                let h = f64::from(rect.source_region.height());
-                let transform = ctx.effective_path_transform() * rect.transform;
-
-                if use_fast_rect && is_axis_aligned(&transform) {
-                    let dst_rect = Rect::new(0., 0., w, h);
-                    let transformed_rect = transform
-                        .transform_rect_bbox(dst_rect)
-                        .intersect(ctx.active_rect());
-
-                    // Skip mirrored or zero-sized rectangles.
-                    if transformed_rect.is_zero_area() {
-                        continue;
-                    }
-
-                    let paint = ctx.encode_external_texture_paint(
-                        texture_id,
-                        rect.source_region,
-                        quality,
-                        x_extend,
-                        y_extend,
-                        transform,
-                        rect.may_have_transparency,
-                    );
-
-                    ctx.recorder
-                        .push_draw(RecordedDraw::new_rect(transformed_rect, paint), &[]);
-                } else {
-                    let paint = ctx.encode_external_texture_paint(
-                        texture_id,
-                        rect.source_region,
-                        quality,
-                        x_extend,
-                        y_extend,
-                        transform,
-                        rect.may_have_transparency,
-                    );
-                    let dst_rect = Rect::new(0., 0., w, h);
-                    ctx.fill_path_with(
-                        &dst_rect.to_path(DEFAULT_TOLERANCE),
-                        transform,
-                        ctx.render_state.fill_rule,
-                        paint,
-                        ctx.aliasing_threshold,
-                    );
-                }
+            if let Some(bounds) = ctx.fast_rect_bounds(&rect.destination_rect) {
+                ctx.recorder
+                    .push_draw(RecordedDraw::new_rect(bounds, paint), &[]);
+                return;
             }
+
+            ctx.fill_path_with(
+                &rect.destination_rect.to_path(DEFAULT_TOLERANCE),
+                ctx.effective_path_transform(),
+                ctx.render_state.fill_rule,
+                paint,
+                ctx.aliasing_threshold,
+            );
         });
     }
 
@@ -1015,8 +956,8 @@
     use alloc::sync::Arc;
     #[cfg(feature = "text")]
     use glifo::Glyph;
-    use vello_common::kurbo::BezPath;
-    use vello_common::kurbo::Rect;
+    use vello_common::geometry::RectU16;
+    use vello_common::kurbo::{BezPath, Rect};
     use vello_common::paint::{Paint, PremulColor};
     use vello_common::peniko::color::palette::css::{BLUE, TRANSPARENT};
     #[cfg(feature = "text")]
@@ -1030,10 +971,7 @@
             Paint::Solid(PremulColor::from_alpha_color(BLUE)),
         );
 
-        assert_eq!(
-            draw.bbox(&[]),
-            Some(vello_common::geometry::RectU16::new(0, 0, 8, 8))
-        );
+        assert_eq!(draw.bbox(&[]), Some(RectU16::new(0, 0, 8, 8)));
     }
 
     #[test]
diff --git a/sparse_strips/vello_sparse_tests/snapshots/external_texture_repeat.png b/sparse_strips/vello_sparse_tests/snapshots/external_texture_repeat.png
new file mode 100644
index 0000000..f1d6213
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/external_texture_repeat.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3b46560a6660134c2f6c58de742ae3534b78cd0609991075b06cdf6e54b38210
+size 232
diff --git a/sparse_strips/vello_sparse_tests/snapshots/external_texture_with_cropped_source.png b/sparse_strips/vello_sparse_tests/snapshots/external_texture_with_cropped_source.png
new file mode 100644
index 0000000..09e2e4f
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/external_texture_with_cropped_source.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d48c74f7a240f82024027429fc1adb7924fb61ebc53342e511837db9a7b57b6e
+size 1343
diff --git a/sparse_strips/vello_sparse_tests/snapshots/external_texture_with_paint_transform.png b/sparse_strips/vello_sparse_tests/snapshots/external_texture_with_paint_transform.png
new file mode 100644
index 0000000..e230b64
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/external_texture_with_paint_transform.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d913f32e0b85273b0a8861464006fc2af4932f382da7742387c94f085544dd43
+size 10591
diff --git a/sparse_strips/vello_sparse_tests/snapshots/external_texture_with_scene_transform_2.png b/sparse_strips/vello_sparse_tests/snapshots/external_texture_with_scene_transform_2.png
new file mode 100644
index 0000000..4ca670b
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/external_texture_with_scene_transform_2.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f835051b66e43aeca0579ce31145e4507d3e95ce5ba1ade542be44b8d2d1fcb3
+size 1207
diff --git a/sparse_strips/vello_sparse_tests/tests/assets/color_grid_16x16.png b/sparse_strips/vello_sparse_tests/tests/assets/color_grid_16x16.png
new file mode 100644
index 0000000..e7ea562
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/tests/assets/color_grid_16x16.png
Binary files differ
diff --git a/sparse_strips/vello_sparse_tests/tests/external_texture.rs b/sparse_strips/vello_sparse_tests/tests/external_texture.rs
index 8a1bd2f..8483854 100644
--- a/sparse_strips/vello_sparse_tests/tests/external_texture.rs
+++ b/sparse_strips/vello_sparse_tests/tests/external_texture.rs
@@ -13,7 +13,7 @@
     use vello_common::peniko::{Color, Extend, ImageQuality, ImageSampler};
     use vello_common::pixmap::Pixmap;
     use vello_dev_macros::vello_test;
-    use vello_hybrid::SampleRect;
+    use vello_hybrid::{ExternalTextureRect, TextureId};
 
     use crate::load_image;
     use crate::renderer::Renderer;
@@ -37,26 +37,55 @@
         ))
     }
 
+    fn pad_sampler(quality: ImageQuality) -> ImageSampler {
+        ImageSampler {
+            x_extend: Extend::Pad,
+            y_extend: Extend::Pad,
+            quality,
+            alpha: 1.0,
+        }
+    }
+
     fn texture_rect(
+        texture_id: TextureId,
         x: f64,
         y: f64,
         width: f64,
         height: f64,
         may_have_transparency: bool,
-    ) -> SampleRect {
-        let rect = SampleRect::new(
-            RectU16::new(0, 0, 1, 1),
-            Affine::translate((x, y)) * Affine::scale_non_uniform(width, height),
-        );
-        if may_have_transparency {
-            rect
-        } else {
-            rect.with_opaque_hint()
+    ) -> ExternalTextureRect {
+        ExternalTextureRect {
+            texture_id,
+            source_region: RectU16::new(0, 0, 1, 1),
+            destination_rect: Rect::new(x, y, x + width, y + height),
+            sampler: pad_sampler(ImageQuality::Low),
+            may_have_transparency,
         }
     }
 
-    fn texture_rect_at(x: f64, y: f64) -> [SampleRect; 1] {
-        [texture_rect(x, y, 40., 40., true)]
+    fn texture_rect_at(texture_id: TextureId, x: f64, y: f64) -> ExternalTextureRect {
+        texture_rect(texture_id, x, y, 40., 40., true)
+    }
+
+    fn sprite_rect(
+        texture_id: TextureId,
+        source_region: RectU16,
+        x: f64,
+        y: f64,
+        quality: ImageQuality,
+    ) -> ExternalTextureRect {
+        ExternalTextureRect {
+            texture_id,
+            source_region,
+            destination_rect: Rect::new(
+                x,
+                y,
+                x + f64::from(source_region.width()),
+                y + f64::from(source_region.height()),
+            ),
+            sampler: pad_sampler(quality),
+            may_have_transparency: true,
+        }
     }
 
     fn draw_atlas_rect(ctx: &mut impl Renderer, image: ImageSource, rect: Rect) {
@@ -73,24 +102,18 @@
         ctx.fill_rect(&rect);
     }
 
-    fn texture_circle(
-        ctx: &mut impl Renderer,
-        texture_id: vello_hybrid::TextureId,
-        circle: Circle,
-    ) {
+    fn texture_circle(ctx: &mut impl Renderer, texture_id: TextureId, circle: Circle) {
         let clip = circle.to_path(0.1);
         let rect = circle.bounding_box();
 
         ctx.push_clip_path(&clip);
-        ctx.draw_texture_rects(
+        ctx.draw_texture_rect(ExternalTextureRect {
             texture_id,
-            ImageQuality::Medium,
-            [SampleRect::new(
-                RectU16::new(0, 0, 1, 1),
-                Affine::translate((rect.x0, rect.y0))
-                    * Affine::scale_non_uniform(rect.width(), rect.height()),
-            )],
-        );
+            source_region: RectU16::new(0, 0, 1, 1),
+            destination_rect: rect,
+            sampler: pad_sampler(ImageQuality::Medium),
+            may_have_transparency: true,
+        });
         ctx.pop_clip_path();
     }
 
@@ -102,18 +125,99 @@
     #[vello_test(width = 96, height = 96, hybrid_only)]
     fn external_texture_composite(ctx: &mut impl Renderer) {
         let texture_id = ctx.register_external_texture(load_image!("glyphs_colr_noto"));
-        ctx.draw_texture_rects(
+        ctx.set_paint_transform(Affine::translate((12., 15.)));
+        ctx.draw_texture_rect(sprite_rect(
             texture_id,
+            SPRITES[0],
+            12.,
+            15.,
             ImageQuality::Low,
-            [SampleRect::new(SPRITES[0], Affine::translate((12., 15.)))],
-        );
+        ));
         ctx.set_paint(color::palette::css::PALE_GOLDENROD.with_alpha(0.7));
         ctx.fill_rect(&Rect::new(10., 7., 65., 55.));
-        ctx.draw_texture_rects(
+        ctx.set_paint_transform(Affine::translate((25., 25.)));
+        ctx.draw_texture_rect(sprite_rect(
             texture_id,
+            SPRITES[3],
+            25.,
+            25.,
             ImageQuality::Low,
-            [SampleRect::new(SPRITES[3], Affine::translate((25., 25.)))],
-        );
+        ));
+    }
+
+    #[vello_test(hybrid_only)]
+    fn external_texture_repeat(ctx: &mut impl Renderer) {
+        let texture_id = ctx.register_external_texture(load_image!("color_grid_16x16"));
+
+        ctx.draw_texture_rect(ExternalTextureRect {
+            texture_id,
+            source_region: RectU16::new(0, 0, 16, 16),
+            destination_rect: Rect::new(5., 5., 95., 95.),
+            sampler: ImageSampler {
+                x_extend: Extend::Repeat,
+                y_extend: Extend::Repeat,
+                quality: ImageQuality::Medium,
+                alpha: 1.0,
+            },
+            may_have_transparency: false,
+        });
+    }
+
+    #[vello_test(hybrid_only)]
+    fn external_texture_with_paint_transform(ctx: &mut impl Renderer) {
+        let texture_id = ctx.register_external_texture(load_image!("color_grid_16x16"));
+
+        ctx.set_paint_transform(Affine::rotate(0.35) * Affine::scale(2.));
+        ctx.draw_texture_rect(ExternalTextureRect {
+            texture_id,
+            source_region: RectU16::new(2, 2, 14, 14),
+            destination_rect: Rect::new(5., 5., 95., 95.),
+            sampler: ImageSampler {
+                x_extend: Extend::Reflect,
+                y_extend: Extend::Reflect,
+                quality: ImageQuality::Medium,
+                alpha: 1.0,
+            },
+            may_have_transparency: false,
+        });
+    }
+
+    #[vello_test(hybrid_only)]
+    fn external_texture_with_scene_transform_2(ctx: &mut impl Renderer) {
+        let texture_id = ctx.register_external_texture(load_image!("color_grid_16x16"));
+
+        ctx.set_transform(Affine::translate((5., 5.)) * Affine::scale(5.625));
+        ctx.draw_texture_rect(ExternalTextureRect {
+            texture_id,
+            source_region: RectU16::new(0, 0, 16, 16),
+            destination_rect: Rect::new(0., 0., 16., 16.),
+            sampler: ImageSampler {
+                x_extend: Extend::Repeat,
+                y_extend: Extend::Repeat,
+                quality: ImageQuality::Medium,
+                alpha: 1.0,
+            },
+            may_have_transparency: false,
+        });
+    }
+
+    #[vello_test(hybrid_only)]
+    fn external_texture_with_cropped_source(ctx: &mut impl Renderer) {
+        let texture_id = ctx.register_external_texture(load_image!("color_grid_16x16"));
+
+        ctx.set_paint_transform(Affine::translate((14.0, 14.0)) * Affine::scale(6.0));
+        ctx.draw_texture_rect(ExternalTextureRect {
+            texture_id,
+            source_region: RectU16::new(2, 2, 14, 14),
+            destination_rect: Rect::new(5., 5., 95., 95.),
+            sampler: ImageSampler {
+                x_extend: Extend::Reflect,
+                y_extend: Extend::Reflect,
+                quality: ImageQuality::Medium,
+                alpha: 1.0,
+            },
+            may_have_transparency: false,
+        });
     }
 
     #[vello_test(width = 96, height = 96, hybrid_only, hybrid_no_depth)]
@@ -123,7 +227,7 @@
         ctx.set_paint(AlphaColor::from_rgba8(0, 0, 255, 255));
         ctx.fill_rect(&Rect::new(8., 8., 64., 64.));
 
-        ctx.draw_texture_rects(texture_id, ImageQuality::Low, texture_rect_at(28., 28.));
+        ctx.draw_texture_rect(texture_rect_at(texture_id, 28., 28.));
 
         ctx.set_paint(AlphaColor::from_rgba8(0, 255, 0, 255));
         ctx.fill_rect(&Rect::new(48., 16., 88., 56.));
@@ -137,13 +241,13 @@
         ctx.set_paint(AlphaColor::from_rgba8(32, 32, 32, 255));
         ctx.fill_rect(&Rect::new(4., 4., 92., 92.));
 
-        ctx.draw_texture_rects(red_texture, ImageQuality::Low, texture_rect_at(8., 8.));
+        ctx.draw_texture_rect(texture_rect_at(red_texture, 8., 8.));
 
         ctx.set_paint(AlphaColor::from_rgba8(255, 255, 255, 128));
         ctx.fill_rect(&Rect::new(24., 24., 72., 72.));
 
-        ctx.draw_texture_rects(green_texture, ImageQuality::Low, texture_rect_at(28., 28.));
-        ctx.draw_texture_rects(red_texture, ImageQuality::Low, texture_rect_at(48., 48.));
+        ctx.draw_texture_rect(texture_rect_at(green_texture, 28., 28.));
+        ctx.draw_texture_rect(texture_rect_at(red_texture, 48., 48.));
     }
 
     #[vello_test(hybrid_only, hybrid_no_depth)]
@@ -154,55 +258,21 @@
         let gold = ctx.register_external_texture(solid_pixmap(153, 102, 61, 160));
         let tint_source = ctx.register_external_texture(solid_pixmap(255, 255, 255, 255));
 
-        ctx.draw_texture_rects(
-            coral,
-            ImageQuality::Low,
-            [texture_rect(6., 6., 88., 88., false)],
-        );
-        ctx.draw_texture_rects(
-            gold,
-            ImageQuality::Low,
-            [
-                texture_rect(11.5, 33.5, 77., 33., true),
-                texture_rect(33.5, 11.5, 33., 77., true),
-            ],
-        );
-        ctx.draw_texture_rects(
-            teal,
-            ImageQuality::Low,
-            [texture_rect(21.5, 21.5, 57., 57., false)],
-        );
+        ctx.draw_texture_rect(texture_rect(coral, 6., 6., 88., 88., false));
+        ctx.draw_texture_rect(texture_rect(gold, 11.5, 33.5, 77., 33., true));
+        ctx.draw_texture_rect(texture_rect(gold, 33.5, 11.5, 33., 77., true));
+        ctx.draw_texture_rect(texture_rect(teal, 21.5, 21.5, 57., 57., false));
         ctx.set_tint(Some(Tint {
             color: Color::from_rgba8(128, 102, 204, 160),
             mode: TintMode::Multiply,
         }));
-        ctx.draw_texture_rects(
-            tint_source,
-            ImageQuality::Low,
-            [
-                texture_rect(16., 38., 68., 24., false),
-                texture_rect(38., 16., 24., 68., false),
-            ],
-        );
+        ctx.draw_texture_rect(texture_rect(tint_source, 16., 38., 68., 24., false));
+        ctx.draw_texture_rect(texture_rect(tint_source, 38., 16., 24., 68., false));
         ctx.set_tint(None);
-        ctx.draw_texture_rects(
-            navy,
-            ImageQuality::Low,
-            [texture_rect(33.5, 33.5, 33., 33., false)],
-        );
-        ctx.draw_texture_rects(
-            gold,
-            ImageQuality::Low,
-            [
-                texture_rect(28., 44., 44., 12., true),
-                texture_rect(44., 28., 12., 44., true),
-            ],
-        );
-        ctx.draw_texture_rects(
-            coral,
-            ImageQuality::Low,
-            [texture_rect(44., 44., 12., 12., false)],
-        );
+        ctx.draw_texture_rect(texture_rect(navy, 33.5, 33.5, 33., 33., false));
+        ctx.draw_texture_rect(texture_rect(gold, 28., 44., 44., 12., true));
+        ctx.draw_texture_rect(texture_rect(gold, 44., 28., 12., 44., true));
+        ctx.draw_texture_rect(texture_rect(coral, 44., 44., 12., 12., false));
     }
 
     #[vello_test(hybrid_only)]
@@ -214,23 +284,9 @@
         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.));
-        ctx.draw_texture_rects(
-            external_green,
-            ImageQuality::Low,
-            [SampleRect::new(
-                RectU16::new(0, 0, 1, 1),
-                Affine::translate((66., 10.)) * Affine::scale(24.),
-            )],
-        );
+        ctx.draw_texture_rect(texture_rect(external_green, 66., 10., 24., 24., true));
         draw_atlas_rect(ctx, atlas_blue, Rect::new(38., 38., 62., 62.));
-        ctx.draw_texture_rects(
-            external_yellow,
-            ImageQuality::Low,
-            [SampleRect::new(
-                RectU16::new(0, 0, 1, 1),
-                Affine::translate((10., 66.)) * Affine::scale(24.),
-            )],
-        );
+        ctx.draw_texture_rect(texture_rect(external_yellow, 10., 66., 24., 24., true));
         draw_atlas_rect(ctx, atlas_magenta, Rect::new(66., 66., 90., 90.));
     }
 
@@ -244,7 +300,7 @@
         ctx.pop_layer();
 
         ctx.push_layer(None, None, None, None, None);
-        ctx.draw_texture_rects(texture_id, ImageQuality::Medium, texture_rect_at(40., 40.));
+        ctx.draw_texture_rect(texture_rect_at(texture_id, 40., 40.));
         ctx.pop_layer();
     }
 
@@ -253,7 +309,7 @@
         let texture_id = ctx.register_external_texture(solid_pixmap(0, 255, 0, 255));
 
         ctx.push_layer(None, None, None, None, None);
-        ctx.draw_texture_rects(texture_id, ImageQuality::Medium, texture_rect_at(20., 20.));
+        ctx.draw_texture_rect(texture_rect_at(texture_id, 20., 20.));
         ctx.pop_layer();
 
         ctx.push_layer(None, None, None, None, None);
@@ -280,7 +336,7 @@
 
         #[derive(Clone, Copy)]
         enum CircleOp {
-            Texture(CirclePos, vello_hybrid::TextureId),
+            Texture(CirclePos, TextureId),
             Paint(CirclePos, AlphaColor<Srgb>),
         }
 
@@ -342,14 +398,21 @@
     #[vello_test(width = 96, height = 96, hybrid_only)]
     fn external_texture_skewed(ctx: &mut impl Renderer) {
         let texture_id = ctx.register_external_texture(load_image!("glyphs_colr_noto"));
-        ctx.draw_texture_rects(
+        let source_region = SPRITES[0];
+        ctx.set_transform(Affine::translate((15., 15.)) * Affine::skew(0.2, 0.1));
+        ctx.set_paint_transform(Affine::IDENTITY);
+        ctx.draw_texture_rect(ExternalTextureRect {
             texture_id,
-            ImageQuality::High,
-            [SampleRect::new(
-                SPRITES[0],
-                Affine::translate((15., 15.)) * Affine::skew(0.2, 0.1),
-            )],
-        );
+            source_region,
+            destination_rect: Rect::new(
+                0.,
+                0.,
+                f64::from(source_region.width()),
+                f64::from(source_region.height()),
+            ),
+            sampler: pad_sampler(ImageQuality::High),
+            may_have_transparency: true,
+        });
     }
 
     #[vello_test(width = 96, height = 96, hybrid_only)]
@@ -358,14 +421,22 @@
         let clip = Circle::new((48., 48.), 24.).to_path(0.1);
 
         ctx.push_clip_layer(&clip);
-        ctx.draw_texture_rects(
+        ctx.set_paint_transform(Affine::translate((18., 18.)));
+        ctx.draw_texture_rect(sprite_rect(
             texture_id,
+            SPRITES[1],
+            18.,
+            18.,
             ImageQuality::Medium,
-            [
-                SampleRect::new(SPRITES[1], Affine::translate((18., 18.))),
-                SampleRect::new(SPRITES[3], Affine::translate((34., 34.))),
-            ],
-        );
+        ));
+        ctx.set_paint_transform(Affine::translate((34., 34.)));
+        ctx.draw_texture_rect(sprite_rect(
+            texture_id,
+            SPRITES[3],
+            34.,
+            34.,
+            ImageQuality::Medium,
+        ));
         ctx.pop_layer();
     }
 
@@ -378,11 +449,14 @@
         });
 
         ctx.push_filter_layer(blur);
-        ctx.draw_texture_rects(
+        ctx.set_paint_transform(Affine::translate((20., 20.)));
+        ctx.draw_texture_rect(sprite_rect(
             texture_id,
+            SPRITES[2],
+            20.,
+            20.,
             ImageQuality::Low,
-            [SampleRect::new(SPRITES[2], Affine::translate((20., 20.)))],
-        );
+        ));
         ctx.pop_layer();
     }
 
@@ -404,35 +478,48 @@
             (SPRITES[1], 134., 67.),
         ];
 
-        ctx.draw_texture_rects(
-            texture_id,
-            ImageQuality::Low,
-            placements.map(|(source_region, x, y)| {
-                SampleRect::new(source_region, Affine::translate((x, y)))
-            }),
-        );
+        for (source_region, x, y) in placements {
+            ctx.set_paint_transform(Affine::translate((x, y)));
+            ctx.draw_texture_rect(sprite_rect(
+                texture_id,
+                source_region,
+                x,
+                y,
+                ImageQuality::Low,
+            ));
+        }
     }
 
     #[vello_test(width = 96, height = 96, hybrid_only, hybrid_tolerance = 2)]
     fn external_texture_with_scene_transform(ctx: &mut impl Renderer) {
         let texture_id = ctx.register_external_texture(load_image!("glyphs_colr_noto"));
 
-        ctx.set_transform(
-            Affine::translate((20., 0.))
-                * Affine::rotate(0.35)
-                * Affine::scale_non_uniform(0.85, 1.1),
-        );
-        ctx.draw_texture_rects(
-            texture_id,
-            ImageQuality::Medium,
-            [
-                SampleRect::new(SPRITES[0], Affine::translate((6., 8.))),
-                SampleRect::new(
-                    SPRITES[3],
-                    Affine::translate((28., 5.)) * Affine::skew(0.18, -0.08),
+        let scene_transform = Affine::translate((20., 0.))
+            * Affine::rotate(0.35)
+            * Affine::scale_non_uniform(0.85, 1.1);
+
+        for (source_region, local_transform) in [
+            (SPRITES[0], Affine::translate((6., 8.))),
+            (
+                SPRITES[3],
+                Affine::translate((28., 5.)) * Affine::skew(0.18, -0.08),
+            ),
+            (SPRITES[2], Affine::translate((48., 6.))),
+        ] {
+            ctx.set_transform(scene_transform * local_transform);
+            ctx.set_paint_transform(Affine::IDENTITY);
+            ctx.draw_texture_rect(ExternalTextureRect {
+                texture_id,
+                source_region,
+                destination_rect: Rect::new(
+                    0.,
+                    0.,
+                    f64::from(source_region.width()),
+                    f64::from(source_region.height()),
                 ),
-                SampleRect::new(SPRITES[2], Affine::translate((48., 6.))),
-            ],
-        );
+                sampler: pad_sampler(ImageQuality::Medium),
+                may_have_transparency: true,
+            });
+        }
     }
 }
diff --git a/sparse_strips/vello_sparse_tests/tests/renderer.rs b/sparse_strips/vello_sparse_tests/tests/renderer.rs
index 4043a98..dd66772 100644
--- a/sparse_strips/vello_sparse_tests/tests/renderer.rs
+++ b/sparse_strips/vello_sparse_tests/tests/renderer.rs
@@ -10,12 +10,12 @@
 use vello_common::kurbo::{Affine, BezPath, Rect, Stroke};
 use vello_common::mask::Mask;
 use vello_common::paint::{ImageId, ImageSource, PaintType, Tint};
-use vello_common::peniko::{BlendMode, Fill, FontData, ImageQuality};
+use vello_common::peniko::{BlendMode, Fill, FontData};
 use vello_common::pixmap::Pixmap;
 use vello_cpu::{Level, RasterizerSettings, RenderContext, RenderMode, RenderSettings, Resources};
 use vello_hybrid::{
-    RenderSettings as HybridRenderSettings, Resources as HybridResources, SampleRect, Scene,
-    TextureId,
+    ExternalTextureRect, RenderSettings as HybridRenderSettings, Resources as HybridResources,
+    Scene, TextureId,
 };
 #[cfg(all(target_arch = "wasm32", feature = "webgl"))]
 use web_sys::WebGl2RenderingContext;
@@ -88,12 +88,7 @@
     fn width(&self) -> u16;
     fn height(&self) -> u16;
     fn register_external_texture(&mut self, pixmap: Arc<Pixmap>) -> TextureId;
-    fn draw_texture_rects(
-        &mut self,
-        texture_id: TextureId,
-        quality: ImageQuality,
-        rects: impl IntoIterator<Item = SampleRect>,
-    );
+    fn draw_texture_rect(&mut self, rect: ExternalTextureRect);
     fn get_image_source(&mut self, pixmap: Arc<Pixmap>) -> ImageSource;
     fn register_image(&mut self, pixmap: Arc<Pixmap>) -> ImageId;
 }
@@ -269,12 +264,7 @@
         unimplemented!("external textures are only supported by hybrid renderer tests")
     }
 
-    fn draw_texture_rects(
-        &mut self,
-        _: TextureId,
-        _: ImageQuality,
-        _: impl IntoIterator<Item = SampleRect>,
-    ) {
+    fn draw_texture_rect(&mut self, _: ExternalTextureRect) {
         unimplemented!("external textures are only supported by hybrid renderer tests")
     }
 
@@ -720,13 +710,8 @@
         texture_id
     }
 
-    fn draw_texture_rects(
-        &mut self,
-        texture_id: TextureId,
-        quality: ImageQuality,
-        rects: impl IntoIterator<Item = SampleRect>,
-    ) {
-        self.scene.draw_texture_rects(texture_id, quality, rects);
+    fn draw_texture_rect(&mut self, rect: ExternalTextureRect) {
+        self.scene.draw_texture_rect(rect);
     }
 
     fn get_image_source(&mut self, pixmap: Arc<Pixmap>) -> ImageSource {
@@ -1046,13 +1031,8 @@
         texture_id
     }
 
-    fn draw_texture_rects(
-        &mut self,
-        texture_id: TextureId,
-        quality: ImageQuality,
-        rects: impl IntoIterator<Item = SampleRect>,
-    ) {
-        self.scene.draw_texture_rects(texture_id, quality, rects);
+    fn draw_texture_rect(&mut self, rect: ExternalTextureRect) {
+        self.scene.draw_texture_rect(rect);
     }
 
     fn get_image_source(&mut self, pixmap: Arc<Pixmap>) -> ImageSource {