.
diff --git a/Cargo.lock b/Cargo.lock
index 7915a4e..e295a7a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4018,6 +4018,7 @@
 name = "vello_cpu_winit"
 version = "0.0.0"
 dependencies = [
+ "parley_draw",
  "softbuffer",
  "vello_common",
  "vello_cpu",
@@ -4089,6 +4090,7 @@
 name = "vello_hybrid_winit"
 version = "0.0.0"
 dependencies = [
+ "parley_draw",
  "pollster",
  "vello_common",
  "vello_example_scenes",
diff --git a/Cargo.toml b/Cargo.toml
index 9e5d170..5f3559f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -167,3 +167,7 @@
 inherits = "release"
 debug = true
 strip = "none"
+
+[profile.profiling]
+inherits = "release"
+debug = true
diff --git a/sparse_strips/vello_cpu/Cargo.toml b/sparse_strips/vello_cpu/Cargo.toml
index 3f94d66..912ac19 100644
--- a/sparse_strips/vello_cpu/Cargo.toml
+++ b/sparse_strips/vello_cpu/Cargo.toml
@@ -48,6 +48,7 @@
 u8_pipeline = []
 # Quality focussed rendering using f32 math
 f32_pipeline = []
+text = []
 
 [lints]
 workspace = true
diff --git a/sparse_strips/vello_cpu/examples/winit/Cargo.toml b/sparse_strips/vello_cpu/examples/winit/Cargo.toml
index 73bc454..85ad9dd 100644
--- a/sparse_strips/vello_cpu/examples/winit/Cargo.toml
+++ b/sparse_strips/vello_cpu/examples/winit/Cargo.toml
@@ -11,6 +11,7 @@
 
 [dependencies]
 winit = { workspace = true }
+parley_draw = { workspace = true, features = ["std", "vello_cpu"] }
 vello_common = { workspace = true }
 vello_cpu = { workspace = true, features = ["multithreading"] }
 vello_example_scenes = { workspace = true, features = ["cpu"] }
diff --git a/sparse_strips/vello_cpu/examples/winit/src/main.rs b/sparse_strips/vello_cpu/examples/winit/src/main.rs
index de1eda0..a6ac2b9 100644
--- a/sparse_strips/vello_cpu/examples/winit/src/main.rs
+++ b/sparse_strips/vello_cpu/examples/winit/src/main.rs
@@ -8,6 +8,10 @@
     reason = "truncation has no appreciable impact in this demo"
 )]
 
+use parley_draw::renderers::vello_renderer::replay_atlas_commands;
+use parley_draw::{
+    AtlasConfig, CpuGlyphCaches, GlyphCache, GlyphCacheConfig, ImageCache, PendingClearRect,
+};
 #[cfg(not(target_arch = "wasm32"))]
 use std::env;
 use std::num::NonZeroU32;
@@ -18,7 +22,7 @@
 use vello_common::pixmap::Pixmap;
 use vello_cpu::{RenderContext, RenderSettings};
 use vello_example_scenes::image::ImageScene;
-use vello_example_scenes::{AnyScene, get_example_scenes};
+use vello_example_scenes::{AnyScene, TextConfig, get_example_scenes};
 use winit::{
     application::ApplicationHandler,
     event::{ElementState, KeyEvent, Modifiers, MouseButton, MouseScrollDelta, WindowEvent},
@@ -36,6 +40,10 @@
     current_scene: usize,
     render_state: RenderState,
     renderer: RenderContext,
+    glyph_renderer: RenderContext,
+    glyph_caches: CpuGlyphCaches,
+    image_cache: ImageCache,
+    text_config: TextConfig,
     pixmap: Pixmap,
     transform: Affine,
     mouse_down: bool,
@@ -44,6 +52,7 @@
     frame_count: u32,
     fps_update_time: Instant,
     accumulated_frame_time: f64,
+    accumulated_render_time: f64,
     rotating: bool,
     rotation_speed: f64,
     shearing: bool,
@@ -62,11 +71,9 @@
         let mut svg_paths: Vec<&str> = Vec::new();
 
         if args.len() > 1 {
-            // Check if the first argument is a number (scene index)
             if let Ok(index) = args[1].parse::<usize>() {
                 start_scene_index = index;
             } else {
-                // Collect all arguments as SVG paths
                 for arg in args.iter().skip(1) {
                     svg_paths.push(arg);
                 }
@@ -112,10 +119,31 @@
             width,
             height,
             RenderSettings {
-                num_threads: 0, // 0 means use default (number of CPU cores)
+                num_threads: 0,
                 ..Default::default()
             },
         ),
+        glyph_renderer: RenderContext::new_with(
+            512,
+            512,
+            RenderSettings {
+                num_threads: 0,
+                ..Default::default()
+            },
+        ),
+        glyph_caches: CpuGlyphCaches::with_config(
+            512,
+            512,
+            GlyphCacheConfig {
+                max_entry_age: u32::MAX,
+                eviction_frequency: u32::MAX,
+            },
+        ),
+        image_cache: ImageCache::new_with_config(AtlasConfig {
+            atlas_size: (512, 512),
+            ..AtlasConfig::default()
+        }),
+        text_config: TextConfig::default(),
         pixmap: Pixmap::new(width, height),
         transform: Affine::IDENTITY,
         mouse_down: false,
@@ -124,15 +152,13 @@
         frame_count: 0,
         fps_update_time: now,
         accumulated_frame_time: 0.0,
+        accumulated_render_time: 0.0,
         rotating: false,
         rotation_speed: 1.0,
         shearing: false,
-        // shear rate (units of tan(angle)) per second
         shear_speed: 0.8,
-        // maximum |shear| to oscillate between
         shear_amplitude: 0.35,
         current_shear: 0.0,
-        // 1 for increasing toward +amplitude, -1 toward -amplitude
         shear_direction: 1.0,
         modifiers: Modifiers::default(),
     };
@@ -247,31 +273,39 @@
                         window.request_redraw();
                     }
                     Key::Named(NamedKey::Space) => {
-                        // Reset transform on spacebar
                         self.transform = Affine::IDENTITY;
                         window.request_redraw();
                     }
                     Key::Named(NamedKey::Escape) => {
                         event_loop.exit();
                     }
-                    Key::Character(ch) => {
-                        if ch.as_str() == "r" {
-                            if is_cmd {
-                                // Cmd+r: Toggle continuous rotation around the window center
-                                self.rotating = !self.rotating;
-                                window.request_redraw();
-                            } else {
-                                // r: Single-step rotation around the window center
-                                let center = Point {
-                                    x: 0.5 * self.pixmap.width() as f64,
-                                    y: 0.5 * self.pixmap.height() as f64,
-                                };
-                                self.transform =
-                                    self.transform.then_rotate_about(ROTATION_STEP, center);
-                                window.request_redraw();
-                            }
-                        } else if ch.as_str() == "R" {
-                            // R: Counter-rotation step (opposite direction of r)
+                    Key::Character(ch) => match ch.as_str() {
+                        "a" | "A" => {
+                            self.text_config.use_atlas_cache = !self.text_config.use_atlas_cache;
+                            println!(
+                                "Atlas cache: {}",
+                                if self.text_config.use_atlas_cache {
+                                    "ON"
+                                } else {
+                                    "OFF"
+                                }
+                            );
+                            window.request_redraw();
+                        }
+                        "r" if is_cmd => {
+                            self.rotating = !self.rotating;
+                            window.request_redraw();
+                        }
+                        "r" => {
+                            let center = Point {
+                                x: 0.5 * self.pixmap.width() as f64,
+                                y: 0.5 * self.pixmap.height() as f64,
+                            };
+                            self.transform =
+                                self.transform.then_rotate_about(ROTATION_STEP, center);
+                            window.request_redraw();
+                        }
+                        "R" => {
                             let center = Point {
                                 x: 0.5 * self.pixmap.width() as f64,
                                 y: 0.5 * self.pixmap.height() as f64,
@@ -279,25 +313,23 @@
                             self.transform =
                                 self.transform.then_rotate_about(-ROTATION_STEP, center);
                             window.request_redraw();
-                        } else if ch.as_str() == "s" {
-                            if is_cmd {
-                                // Cmd+s: Toggle shear oscillation around the window center
-                                self.shearing = !self.shearing;
-                                window.request_redraw();
-                            } else {
-                                // s: Single-step shear about the window center in X
-                                let center = Point {
-                                    x: 0.5 * self.pixmap.width() as f64,
-                                    y: 0.5 * self.pixmap.height() as f64,
-                                };
-                                let about_center = Affine::translate((-center.x, -center.y))
-                                    * Affine::skew(SHEAR_STEP, 0.0)
-                                    * Affine::translate((center.x, center.y));
-                                self.transform *= about_center;
-                                window.request_redraw();
-                            }
-                        } else if ch.as_str() == "S" {
-                            // S: Counter-shear step (opposite direction of s)
+                        }
+                        "s" if is_cmd => {
+                            self.shearing = !self.shearing;
+                            window.request_redraw();
+                        }
+                        "s" => {
+                            let center = Point {
+                                x: 0.5 * self.pixmap.width() as f64,
+                                y: 0.5 * self.pixmap.height() as f64,
+                            };
+                            let about_center = Affine::translate((-center.x, -center.y))
+                                * Affine::skew(SHEAR_STEP, 0.0)
+                                * Affine::translate((center.x, center.y));
+                            self.transform *= about_center;
+                            window.request_redraw();
+                        }
+                        "S" => {
                             let center = Point {
                                 x: 0.5 * self.pixmap.width() as f64,
                                 y: 0.5 * self.pixmap.height() as f64,
@@ -307,12 +339,15 @@
                                 * Affine::translate((center.x, center.y));
                             self.transform *= about_center;
                             window.request_redraw();
-                        } else if let Some(scene) = self.scenes.get_mut(self.current_scene)
-                            && scene.handle_key(ch.as_str())
-                        {
-                            window.request_redraw();
                         }
-                    }
+                        _ => {
+                            if let Some(scene) = self.scenes.get_mut(self.current_scene)
+                                && scene.handle_key(ch.as_str())
+                            {
+                                window.request_redraw();
+                            }
+                        }
+                    },
                     _ => {}
                 }
             }
@@ -320,7 +355,6 @@
                 if button == MouseButton::Left {
                     self.mouse_down = state == ElementState::Pressed;
                     if !self.mouse_down {
-                        // Mouse button released
                         self.last_cursor_position = None;
                     }
                 }
@@ -332,7 +366,6 @@
                 };
 
                 if self.mouse_down {
-                    // Pan the scene if mouse is down
                     if let Some(last_pos) = self.last_cursor_position {
                         self.transform = self.transform.then_translate(current_pos - last_pos);
                         window.request_redraw();
@@ -342,7 +375,6 @@
                 self.last_cursor_position = Some(current_pos);
             }
             WindowEvent::MouseWheel { delta, .. } => {
-                // Handle zoom with mouse wheel
                 let delta_y = match delta {
                     MouseScrollDelta::LineDelta(_, y) => y as f64,
                     MouseScrollDelta::PixelDelta(pos) => pos.y / 100.0,
@@ -350,18 +382,12 @@
 
                 if let Some(cursor_pos) = self.last_cursor_position {
                     let zoom_factor = (1.0 + delta_y * ZOOM_STEP).max(0.1);
-
-                    // Zoom centered at cursor position
                     self.transform = self.transform.then_scale_about(zoom_factor, cursor_pos);
-
                     window.request_redraw();
                 }
             }
             WindowEvent::PinchGesture { delta, .. } => {
-                // Handle pinch-to-zoom on touchpad.
                 let zoom_factor = 1.0 + delta * ZOOM_STEP * 5.0;
-
-                // Zoom centered at cursor position, or the center if no position is set.
                 self.transform = self.transform.then_scale_about(
                     zoom_factor,
                     self.last_cursor_position.unwrap_or(Point {
@@ -369,34 +395,39 @@
                         y: 0.5 * self.pixmap.height() as f64,
                     }),
                 );
-
                 window.request_redraw();
             }
             WindowEvent::RedrawRequested => {
-                // Measure frame time
                 let now = Instant::now();
                 let delta_s = self
                     .last_frame_time
                     .map(|t| now.duration_since(t).as_secs_f64())
                     .unwrap_or(0.0);
                 if let Some(last_time) = self.last_frame_time {
-                    let frame_time = now.duration_since(last_time).as_secs_f64() * 1000.0; // Convert to milliseconds
+                    let frame_time = now.duration_since(last_time).as_secs_f64() * 1000.0;
                     self.accumulated_frame_time += frame_time;
                     self.frame_count += 1;
 
-                    // Update window title every second with average FPS
                     if now.duration_since(self.fps_update_time).as_secs_f64() >= 1.0 {
                         let avg_frame_time = self.accumulated_frame_time / self.frame_count as f64;
                         let avg_fps = 1000.0 / avg_frame_time;
-                        println!("Average FPS: {avg_fps:.1}");
+                        let avg_render_time =
+                            self.accumulated_render_time / self.frame_count as f64;
+                        let status = self.scenes[self.current_scene]
+                            .status()
+                            .map(|s| format!(" - {s}"))
+                            .unwrap_or_default();
+                        println!(
+                            "FPS: {avg_fps:.1} | render: {avg_render_time:.2}ms | frame: {avg_frame_time:.2}ms{status}"
+                        );
                         window.set_title(&format!(
-                            "Vello CPU - Scene {} - {:.1} FPS ({:.2}ms avg)",
-                            self.current_scene, avg_fps, avg_frame_time
+                            "Vello CPU - Scene {} - {:.1} FPS (render {:.2}ms){status}",
+                            self.current_scene, avg_fps, avg_render_time
                         ));
 
-                        // Reset counters
                         self.frame_count = 0;
                         self.accumulated_frame_time = 0.0;
+                        self.accumulated_render_time = 0.0;
                         self.fps_update_time = now;
                     }
                 }
@@ -412,7 +443,7 @@
                     self.transform = self.transform.then_rotate_about(angle, center);
                 }
 
-                // Apply shear oscillation if enabled (bounded back-and-forth)
+                // Apply shear oscillation if enabled
                 if self.shearing && delta_s > 0.0 {
                     let old = self.current_shear;
                     let mut new = old + self.shear_speed * delta_s * self.shear_direction;
@@ -433,7 +464,6 @@
                             x: 0.5 * self.pixmap.width() as f64,
                             y: 0.5 * self.pixmap.height() as f64,
                         };
-                        // Shear about window center in X; Y shear remains 0.0
                         let about_center = Affine::translate((-center.x, -center.y))
                             * Affine::skew(delta_shear, 0.0)
                             * Affine::translate((center.x, center.y));
@@ -442,32 +472,135 @@
                     }
                 }
 
+                let render_start = Instant::now();
+
                 // Render the scene
                 self.renderer.reset();
+                self.renderer.set_transform(self.transform);
+                self.scenes[self.current_scene].render(
+                    &mut self.renderer,
+                    self.transform,
+                    &mut self.glyph_caches,
+                    &mut self.image_cache,
+                    &self.text_config,
+                );
 
-                self.scenes[self.current_scene].render(&mut self.renderer, self.transform);
+                // Replay outline/COLR draw commands into each atlas page's pixmap.
+                for mut recorder in self.glyph_caches.glyph_atlas.take_pending_atlas_commands() {
+                    self.glyph_renderer.reset();
+                    replay_atlas_commands(&mut recorder.commands, &mut self.glyph_renderer);
+                    self.glyph_renderer.flush();
+                    if let Some(atlas_pixmap) = self
+                        .glyph_caches
+                        .glyph_atlas
+                        .page_pixmap_mut(recorder.page_index as usize)
+                    {
+                        self.glyph_renderer
+                            .composite_to_pixmap_at_offset(atlas_pixmap, 0, 0);
+                    }
+                }
+
+                // Upload bitmap glyphs to atlas pages.
+                for upload in self.glyph_caches.glyph_atlas.take_pending_uploads() {
+                    let page_index = upload.atlas_slot.page_index as usize;
+                    if let Some(atlas_pixmap) =
+                        self.glyph_caches.glyph_atlas.page_pixmap_mut(page_index)
+                    {
+                        copy_pixmap_to_atlas(
+                            &upload.pixmap,
+                            atlas_pixmap,
+                            upload.atlas_slot.x,
+                            upload.atlas_slot.y,
+                            upload.atlas_slot.width,
+                            upload.atlas_slot.height,
+                        );
+                    }
+                }
+
+                // Share atlas page pixmaps with the renderer.
+                let page_count = self.glyph_caches.glyph_atlas.page_count();
+                for page_index in 0..page_count {
+                    if let Some(page_pixmap) = self.glyph_caches.glyph_atlas.page_pixmap(page_index)
+                    {
+                        self.renderer.register_image(page_pixmap.clone());
+                    }
+                }
+
                 self.renderer.flush();
                 self.renderer.render_to_pixmap(&mut self.pixmap);
+                self.renderer.clear_images();
+
+                // Maintain caches (eviction, etc.)
+                self.glyph_caches.maintain(&mut self.image_cache);
+
+                // Clear stale atlas regions after eviction.
+                for rect in self.glyph_caches.glyph_atlas.take_pending_clear_rects() {
+                    if let Some(atlas_pixmap) = self
+                        .glyph_caches
+                        .glyph_atlas
+                        .page_pixmap_mut(rect.page_index as usize)
+                    {
+                        clear_pixmap_region(atlas_pixmap, &rect);
+                    }
+                }
+
+                self.accumulated_render_time += render_start.elapsed().as_secs_f64() * 1000.0;
 
                 // Copy pixmap to window surface
                 let mut buffer = surface.buffer_mut().unwrap();
                 let pixmap_data = self.pixmap.data();
 
-                // Convert RGBA to BGRA/XRGB format expected by softbuffer
                 for (buffer_pixel, pixel) in buffer.iter_mut().zip(pixmap_data.iter()) {
-                    // softbuffer expects 0RGB format (little-endian: B, G, R, 0)
-                    // Our pixmap is premultiplied RGBA
                     *buffer_pixel = u32::from_le_bytes([pixel.b, pixel.g, pixel.r, 0]);
                 }
 
                 buffer.present().unwrap();
 
                 // Request continuous redraw for FPS measurement
-                if self.rotating || self.shearing {
-                    window.request_redraw();
-                }
+                window.request_redraw();
             }
             _ => {}
         }
     }
 }
+
+/// Zero out a rectangular region in the atlas pixmap.
+fn clear_pixmap_region(dst: &mut Pixmap, rect: &PendingClearRect) {
+    let dst_stride = dst.width() as usize;
+    let dst_data = dst.data_as_u8_slice_mut();
+    let clear_width = rect.width as usize;
+    let clear_height = rect.height as usize;
+
+    for y in 0..clear_height {
+        let row_start = ((rect.y as usize + y) * dst_stride + rect.x as usize) * 4;
+        let row_end = row_start + clear_width * 4;
+        dst_data[row_start..row_end].fill(0);
+    }
+}
+
+/// Copy bitmap glyph pixels into a rectangular region of an atlas page.
+fn copy_pixmap_to_atlas(
+    src: &Pixmap,
+    dst: &mut Pixmap,
+    dst_x: u16,
+    dst_y: u16,
+    width: u16,
+    height: u16,
+) {
+    let copy_width = width as usize;
+    let copy_height = height as usize;
+    let src_stride = src.width() as usize;
+    let dst_stride = dst.width() as usize;
+
+    let src_data = src.data_as_u8_slice();
+    let dst_data = dst.data_as_u8_slice_mut();
+
+    for y in 0..copy_height {
+        let src_row_start = y * src_stride * 4;
+        let src_row_end = src_row_start + copy_width * 4;
+        let dst_row_start = ((dst_y as usize + y) * dst_stride + dst_x as usize) * 4;
+        let dst_row_end = dst_row_start + copy_width * 4;
+
+        dst_data[dst_row_start..dst_row_end].copy_from_slice(&src_data[src_row_start..src_row_end]);
+    }
+}
diff --git a/sparse_strips/vello_cpu/src/render.rs b/sparse_strips/vello_cpu/src/render.rs
index 9d657df..870fca1 100644
--- a/sparse_strips/vello_cpu/src/render.rs
+++ b/sparse_strips/vello_cpu/src/render.rs
@@ -244,22 +244,39 @@
                     &ctx.encoded_paints,
                 );
             } else {
-                // Fall back to path-based rendering for rotated/skewed transforms.
-                ctx.rect_to_temp_path(rect);
-                ctx.dispatcher.fill_path(
-                    &ctx.temp_path,
-                    ctx.fill_rule,
-                    ctx.transform,
-                    paint,
-                    ctx.blend_mode,
-                    ctx.aliasing_threshold,
-                    ctx.mask.clone(),
-                    &ctx.encoded_paints,
-                );
+                ctx.fill_rect_slow(rect, paint);
             }
         });
     }
 
+    /// Fill a rectangle, bypassing the pixel-alignment fast-path check.
+    ///
+    /// Use this when the caller already knows the transform/rect combination is
+    /// *not* pixel-aligned (e.g. glyph atlas rendering with fractional bearing
+    /// offsets or non-identity paint transforms). This avoids the per-call cost
+    /// of `is_integer_translation` and `is_integer_rect`.
+    #[inline]
+    pub fn fill_rect_pixel_aligned(&mut self, rect: &Rect) {
+        self.with_optional_filter(|ctx| {
+            let paint = ctx.encode_current_paint();
+            ctx.fill_rect_slow(rect, paint);
+        });
+    }
+
+    fn fill_rect_slow(&mut self, rect: &Rect, paint: Paint) {
+        self.rect_to_temp_path(rect);
+        self.dispatcher.fill_path(
+            &self.temp_path,
+            self.fill_rule,
+            self.transform,
+            paint,
+            self.blend_mode,
+            self.aliasing_threshold,
+            self.mask.clone(),
+            &self.encoded_paints,
+        );
+    }
+
     /// Stroke a rectangle.
     pub fn stroke_rect(&mut self, rect: &Rect) {
         self.with_optional_filter(|ctx| {
diff --git a/sparse_strips/vello_example_scenes/src/blend.rs b/sparse_strips/vello_example_scenes/src/blend.rs
index ea3cabe..ea66b97 100644
--- a/sparse_strips/vello_example_scenes/src/blend.rs
+++ b/sparse_strips/vello_example_scenes/src/blend.rs
@@ -3,7 +3,10 @@
 
 //! Example compositing an image using blend layers.
 
-use crate::{ExampleScene, RenderingContext};
+use core::any::Any;
+use parley_draw::ImageCache;
+
+use crate::{ExampleScene, RenderingContext, TextConfig};
 use vello_common::color::palette::css::{BLUE, GREEN, PURPLE, RED, YELLOW};
 use vello_common::kurbo::{Affine, Circle, Point, Rect, Shape};
 use vello_common::peniko::{BlendMode, Color, Compose, Mix};
@@ -13,7 +16,14 @@
 pub struct BlendScene {}
 
 impl ExampleScene for BlendScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         render(ctx, root_transform);
     }
 }
diff --git a/sparse_strips/vello_example_scenes/src/clip.rs b/sparse_strips/vello_example_scenes/src/clip.rs
index 95a600c..1df7df7 100644
--- a/sparse_strips/vello_example_scenes/src/clip.rs
+++ b/sparse_strips/vello_example_scenes/src/clip.rs
@@ -9,7 +9,10 @@
 only break in edge cases, and some of them are also only related to conversions from f64 to f32."
 )]
 
-use crate::{ExampleScene, RenderingContext};
+use core::any::Any;
+use parley_draw::ImageCache;
+
+use crate::{ExampleScene, RenderingContext, TextConfig};
 use vello_common::color::palette::css::{
     BLACK, BLUE, DARK_BLUE, DARK_GREEN, GREEN, REBECCA_PURPLE, RED,
 };
@@ -24,7 +27,14 @@
 }
 
 impl ExampleScene for ClipScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         render(ctx, root_transform, self.use_clip_path, self.num_circles);
     }
 
diff --git a/sparse_strips/vello_example_scenes/src/filter.rs b/sparse_strips/vello_example_scenes/src/filter.rs
index fd598d5..1b29624 100644
--- a/sparse_strips/vello_example_scenes/src/filter.rs
+++ b/sparse_strips/vello_example_scenes/src/filter.rs
@@ -6,7 +6,10 @@
 //! This scene is based on the `filter_varying_depths_clips_and_compositions` test.
 //! See: `sparse_strips/vello_sparse_tests/tests/filter.rs`
 
-use crate::{ExampleScene, RenderingContext};
+use core::any::Any;
+use parley_draw::ImageCache;
+
+use crate::{ExampleScene, RenderingContext, TextConfig};
 use vello_common::color::AlphaColor;
 use vello_common::color::palette::css::{PURPLE, ROYAL_BLUE, SEA_GREEN, TOMATO, VIOLET};
 use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
@@ -18,7 +21,14 @@
 pub struct FilterScene {}
 
 impl ExampleScene for FilterScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         ctx.set_transform(root_transform);
 
         let filter_drop_shadow = Filter::from_primitive(FilterPrimitive::DropShadow {
diff --git a/sparse_strips/vello_example_scenes/src/gradient.rs b/sparse_strips/vello_example_scenes/src/gradient.rs
index e97d413..241b18a 100644
--- a/sparse_strips/vello_example_scenes/src/gradient.rs
+++ b/sparse_strips/vello_example_scenes/src/gradient.rs
@@ -9,7 +9,10 @@
 //! - `RadialScene`:
 //!     - `two_point_radial` method from `https://github.com/linebender/vello/blob/0f3ef03a823eb10b0d7a60164e286cde77ffa222/examples/scenes/src/test_scenes.rs#L882`
 
-use crate::{ExampleScene, RenderingContext};
+use core::any::Any;
+use parley_draw::ImageCache;
+
+use crate::{ExampleScene, RenderingContext, TextConfig};
 use smallvec::smallvec;
 use vello_common::color::palette::css::{BLACK, BLUE, LIME, RED, WHITE, YELLOW};
 use vello_common::kurbo::{Affine, Ellipse, Point, Rect, Shape, Stroke};
@@ -28,7 +31,14 @@
 }
 
 impl ExampleScene for GradientExtendScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         enum Kind {
             Linear,
             Radial,
@@ -148,7 +158,14 @@
 }
 
 impl ExampleScene for RadialScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         /// Helper function to create color stops
         fn create_color_stops(colors: &[Color]) -> ColorStops {
             ColorStops(smallvec![
diff --git a/sparse_strips/vello_example_scenes/src/image.rs b/sparse_strips/vello_example_scenes/src/image.rs
index 4f5a56c..ba2c0e6 100644
--- a/sparse_strips/vello_example_scenes/src/image.rs
+++ b/sparse_strips/vello_example_scenes/src/image.rs
@@ -4,6 +4,8 @@
 //! Image rendering example scene.
 use std::f64::consts::PI;
 
+use core::any::Any;
+use parley_draw::ImageCache;
 use vello_common::color::PremulRgba8;
 use vello_common::kurbo::{BezPath, Point, Shape, Vec2};
 use vello_common::peniko::ImageFormat;
@@ -15,7 +17,7 @@
     peniko::{Extend, ImageQuality},
 };
 
-use crate::{ExampleScene, RenderingContext};
+use crate::{ExampleScene, RenderingContext, TextConfig};
 
 /// Image scene state
 #[derive(Debug, Default)]
@@ -31,7 +33,14 @@
 }
 
 impl ExampleScene for ImageScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         let splash_flower_id = self.img_sources[0].clone();
         let cowboy_id = self.img_sources[1].clone();
 
diff --git a/sparse_strips/vello_example_scenes/src/lib.rs b/sparse_strips/vello_example_scenes/src/lib.rs
index 11c9cdc..e37e4b3 100644
--- a/sparse_strips/vello_example_scenes/src/lib.rs
+++ b/sparse_strips/vello_example_scenes/src/lib.rs
@@ -31,6 +31,24 @@
 use vello_cpu::RenderContext;
 use vello_hybrid::Scene;
 
+/// Configuration for text rendering passed through to example scenes.
+#[derive(Clone, Copy, Debug)]
+pub struct TextConfig {
+    /// Whether font hinting is enabled.
+    pub hint: bool,
+    /// Whether the glyph atlas cache is enabled.
+    pub use_atlas_cache: bool,
+}
+
+impl Default for TextConfig {
+    fn default() -> Self {
+        Self {
+            hint: true,
+            use_atlas_cache: true,
+        }
+    }
+}
+
 /// A generic rendering context.
 pub trait RenderingContext: Sized {
     /// Width of the render target in pixels.
@@ -87,22 +105,21 @@
     /// Get the current transform.
     fn transform(&self) -> Affine;
 
-    /// Create a new set of glyph caches for this renderer backend.
-    ///
-    /// Returns a type-erased cache that must be passed to [`Self::fill_glyphs`].
-    fn create_glyph_caches(&self) -> Box<dyn Any>;
-
     /// Fill glyphs using the renderer's glyph pipeline.
     ///
-    /// `glyph_caches` must be the value returned by [`Self::create_glyph_caches`].
+    /// `glyph_caches` is a type-erased backend-specific glyph cache
+    /// (e.g. `GpuGlyphCaches` or `CpuGlyphCaches`).
+    /// `image_cache` is the shared atlas allocator that must be the same
+    /// instance used by the GPU renderer for atlas lookups.
     fn fill_glyphs(
         &mut self,
         font: &FontData,
         font_size: f32,
-        hint: bool,
         normalized_coords: &[i16],
         glyphs: impl Iterator<Item = Glyph>,
         glyph_caches: &mut dyn Any,
+        image_cache: &mut ImageCache,
+        text_config: &TextConfig,
     );
 }
 
@@ -203,41 +220,29 @@
         *self.transform()
     }
 
-    fn create_glyph_caches(&self) -> Box<dyn Any> {
-        Box::new(CpuGlyphState {
-            glyph_caches: parley_draw::CpuGlyphCaches::new(512, 512),
-            image_cache: ImageCache::new_with_config(parley_draw::AtlasConfig::default()),
-        })
-    }
-
     fn fill_glyphs(
         &mut self,
         font: &FontData,
         font_size: f32,
-        hint: bool,
         normalized_coords: &[i16],
         glyphs: impl Iterator<Item = Glyph>,
         glyph_caches: &mut dyn Any,
+        image_cache: &mut ImageCache,
+        text_config: &TextConfig,
     ) {
-        let state = glyph_caches
-            .downcast_mut::<CpuGlyphState>()
+        let caches = glyph_caches
+            .downcast_mut::<parley_draw::CpuGlyphCaches>()
             .expect("wrong glyph cache type for CPU renderer");
         let transform = *self.transform();
         GlyphRunBuilder::new(font.clone(), transform, self)
             .font_size(font_size)
-            .hint(hint)
+            .hint(text_config.hint)
             .normalized_coords(bytemuck::cast_slice(normalized_coords))
-            .fill_glyphs(glyphs, &mut state.glyph_caches, &mut state.image_cache);
+            .atlas_cache(text_config.use_atlas_cache)
+            .fill_glyphs(glyphs, caches, image_cache);
     }
 }
 
-/// Glyph caches for the CPU renderer backend.
-#[cfg(feature = "cpu")]
-struct CpuGlyphState {
-    glyph_caches: parley_draw::CpuGlyphCaches,
-    image_cache: ImageCache,
-}
-
 impl RenderingContext for Scene {
     fn width(&self) -> u16 {
         self.width()
@@ -334,46 +339,43 @@
         *self.transform()
     }
 
-    fn create_glyph_caches(&self) -> Box<dyn Any> {
-        Box::new(GpuGlyphState {
-            glyph_caches: parley_draw::GpuGlyphCaches::with_config(
-                parley_draw::GlyphCacheConfig::default(),
-            ),
-            image_cache: ImageCache::new_with_config(parley_draw::AtlasConfig::default()),
-        })
-    }
-
     fn fill_glyphs(
         &mut self,
         font: &FontData,
         font_size: f32,
-        hint: bool,
         normalized_coords: &[i16],
         glyphs: impl Iterator<Item = Glyph>,
         glyph_caches: &mut dyn Any,
+        image_cache: &mut ImageCache,
+        text_config: &TextConfig,
     ) {
-        let state = glyph_caches
-            .downcast_mut::<GpuGlyphState>()
+        let caches = glyph_caches
+            .downcast_mut::<parley_draw::GpuGlyphCaches>()
             .expect("wrong glyph cache type for hybrid renderer");
         let transform = *self.transform();
         GlyphRunBuilder::new(font.clone(), transform, self)
             .font_size(font_size)
-            .hint(hint)
+            .hint(text_config.hint)
             .normalized_coords(bytemuck::cast_slice(normalized_coords))
-            .fill_glyphs(glyphs, &mut state.glyph_caches, &mut state.image_cache);
+            .atlas_cache(text_config.use_atlas_cache)
+            .fill_glyphs(glyphs, caches, image_cache);
     }
 }
 
-/// Glyph caches for the hybrid (GPU) renderer backend.
-struct GpuGlyphState {
-    glyph_caches: parley_draw::GpuGlyphCaches,
-    image_cache: ImageCache,
-}
-
 /// Example scene that can maintain state between renders.
 pub trait ExampleScene {
     /// Render the scene using the current state.
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine);
+    ///
+    /// `glyph_caches` and `image_cache` are provided by the application so that
+    /// text-rendering scenes can share atlas state with the GPU renderer.
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        glyph_caches: &mut dyn Any,
+        image_cache: &mut ImageCache,
+        text_config: &TextConfig,
+    );
 
     /// Handle key press events (optional).
     /// Returns true if the key was handled, false otherwise.
@@ -400,7 +402,7 @@
 }
 
 /// A type-erased render function.
-type RenderFn<T> = Box<dyn FnMut(&mut T, Affine)>;
+type RenderFn<T> = Box<dyn FnMut(&mut T, Affine, &mut dyn Any, &mut ImageCache, &TextConfig)>;
 
 /// A type-erased key handler function.
 type KeyHandlerFn = Box<dyn FnMut(&str) -> bool>;
@@ -424,7 +426,9 @@
         let scene_status = scene.clone();
 
         Self {
-            render_fn: Box::new(move |s, transform| scene.borrow_mut().render(s, transform)),
+            render_fn: Box::new(move |s, transform, gc, ic, tc| {
+                scene.borrow_mut().render(s, transform, gc, ic, tc);
+            }),
             key_handler_fn: Box::new(move |key| scene_clone.borrow_mut().handle_key(key)),
             status_fn: Box::new(move || scene_status.borrow().status()),
             show_widetile_columns: false,
@@ -432,11 +436,16 @@
     }
 
     /// Render the scene.
-    pub fn render(&mut self, ctx: &mut T, root_transform: Affine) {
-        // Render the actual scene content
-        (self.render_fn)(ctx, root_transform);
+    pub fn render(
+        &mut self,
+        ctx: &mut T,
+        root_transform: Affine,
+        glyph_caches: &mut dyn Any,
+        image_cache: &mut ImageCache,
+        text_config: &TextConfig,
+    ) {
+        (self.render_fn)(ctx, root_transform, glyph_caches, image_cache, text_config);
 
-        // Draw tile grid overlay if enabled
         if self.show_widetile_columns {
             self.draw_widetile_columns(ctx);
         }
diff --git a/sparse_strips/vello_example_scenes/src/multi_image.rs b/sparse_strips/vello_example_scenes/src/multi_image.rs
index 7c58ea2..77bcb86 100644
--- a/sparse_strips/vello_example_scenes/src/multi_image.rs
+++ b/sparse_strips/vello_example_scenes/src/multi_image.rs
@@ -4,7 +4,10 @@
 //! Scene that renders multiple axis-aligned images at randomized positions across the viewport.
 //! Press "a"/"A" to add 50/1 images, "d"/"D" to remove 50/1 images.
 
-use crate::{ExampleScene, RenderingContext};
+use core::any::Any;
+use parley_draw::ImageCache;
+
+use crate::{ExampleScene, RenderingContext, TextConfig};
 use std::fmt::{Debug, Formatter, Result};
 use vello_common::color::palette::css::WHITE;
 use vello_common::kurbo::{Affine, Rect, Vec2};
@@ -71,7 +74,14 @@
 }
 
 impl ExampleScene for MultiImageScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         let vw = ctx.width() as f64;
         let vh = ctx.height() as f64;
         let t = root_transform.translation();
diff --git a/sparse_strips/vello_example_scenes/src/path.rs b/sparse_strips/vello_example_scenes/src/path.rs
index 54e124d..d4f87c7 100644
--- a/sparse_strips/vello_example_scenes/src/path.rs
+++ b/sparse_strips/vello_example_scenes/src/path.rs
@@ -10,7 +10,10 @@
 //! - `fill_types` method
 //! - `robust_paths` method
 
-use crate::{ExampleScene, RenderingContext};
+use core::any::Any;
+use parley_draw::ImageCache;
+
+use crate::{ExampleScene, RenderingContext, TextConfig};
 use vello_common::color::palette::css::{AQUA, BLUE, GRAY, LIME, YELLOW};
 use vello_common::kurbo::{Affine, BezPath, Cap, Join, Point, Rect, Shape, Stroke};
 use vello_common::peniko::{Color, Fill};
@@ -48,7 +51,14 @@
 const X_OFFSET: f64 = 450.0;
 
 impl ExampleScene for StrokeStylesScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         let colors = [
             Color::from_rgb8(140, 181, 236),
             Color::from_rgb8(246, 236, 202),
@@ -193,7 +203,14 @@
 
 // TODO: fix issue https://github.com/linebender/vello/issues/1240
 impl ExampleScene for FunkyPathsScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         // Missing movetos path
         let mut missing_movetos = BezPath::new();
         missing_movetos.move_to((0.0, 0.0));
@@ -242,7 +259,14 @@
 }
 
 impl ExampleScene for TrickyStrokesScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         let colors = [
             Color::from_rgb8(140, 181, 236),
             Color::from_rgb8(246, 236, 202),
@@ -372,7 +396,14 @@
 }
 
 impl ExampleScene for FillTypesScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         let rect = Rect::from_origin_size(Point::new(0.0, 0.0), (500.0, 500.0));
 
         // Create star path
@@ -469,7 +500,14 @@
 }
 
 impl ExampleScene for RobustPathsScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         let mut path = BezPath::new();
         path.move_to((16.0, 16.0));
         path.line_to((32.0, 16.0));
diff --git a/sparse_strips/vello_example_scenes/src/simple.rs b/sparse_strips/vello_example_scenes/src/simple.rs
index 23539fc..3e9357e 100644
--- a/sparse_strips/vello_example_scenes/src/simple.rs
+++ b/sparse_strips/vello_example_scenes/src/simple.rs
@@ -3,17 +3,26 @@
 
 //! Simple example scene with basic shapes.
 
+use core::any::Any;
+use parley_draw::ImageCache;
 use vello_common::kurbo::{Affine, BezPath, Stroke};
 use vello_common::peniko::color::palette;
 
-use crate::{ExampleScene, RenderingContext};
+use crate::{ExampleScene, RenderingContext, TextConfig};
 
 /// Simple scene state
 #[derive(Debug)]
 pub struct SimpleScene {}
 
 impl ExampleScene for SimpleScene {
-    fn render(&mut self, target: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        target: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         render(target, root_transform);
     }
 }
diff --git a/sparse_strips/vello_example_scenes/src/svg.rs b/sparse_strips/vello_example_scenes/src/svg.rs
index 9165ebb..afb34c3 100644
--- a/sparse_strips/vello_example_scenes/src/svg.rs
+++ b/sparse_strips/vello_example_scenes/src/svg.rs
@@ -3,6 +3,8 @@
 
 //! SVG rendering example scene.
 
+use core::any::Any;
+use parley_draw::ImageCache;
 use std::fmt;
 use vello_common::kurbo::{Affine, Stroke};
 use vello_common::pico_svg::{Item, PicoSvg};
@@ -11,7 +13,7 @@
 #[cfg(not(target_arch = "wasm32"))]
 use std::path::{Path, PathBuf};
 
-use crate::{ExampleScene, RenderingContext};
+use crate::{ExampleScene, RenderingContext, TextConfig};
 
 /// SVG scene that renders an SVG file
 pub struct SvgScene {
@@ -30,7 +32,14 @@
 }
 
 impl ExampleScene for SvgScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        _glyph_caches: &mut dyn Any,
+        _image_cache: &mut ImageCache,
+        _text_config: &TextConfig,
+    ) {
         let current_transform = root_transform * self.transform;
 
         if self.recording_enabled {
diff --git a/sparse_strips/vello_example_scenes/src/text.rs b/sparse_strips/vello_example_scenes/src/text.rs
index de70a53..9c45eca 100644
--- a/sparse_strips/vello_example_scenes/src/text.rs
+++ b/sparse_strips/vello_example_scenes/src/text.rs
@@ -13,19 +13,55 @@
     Alignment, AlignmentOptions, FontContext, FontFamily, FontWeight, GenericFamily, GlyphRun,
     Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
 };
-use parley_draw::Glyph;
+use parley_draw::{Glyph, ImageCache};
 use vello_common::kurbo::{Affine, Rect, Vec2};
 use vello_common::peniko::Color;
 
-use crate::{ExampleScene, RenderingContext};
+use crate::{ExampleScene, RenderingContext, TextConfig};
 
-const PADDING: u32 = 20;
-const MAX_ADVANCE: f32 = 200.0;
-const FONT_SIZE: f32 = 16.0;
+const PADDING: u32 = 100;
+const MAX_ADVANCE: f32 = 1780.0;
+const FONT_SIZE: f32 = 32.0;
 
 const SIMPLE_TEXT: &str = "Some text here. Let's make it a bit longer so that \
     line wrapping kicks in easily. This demonstrates basic glyph caching with \
-    plain Latin text and common punctuation???";
+    plain Latin text and common punctuation??? The quick brown fox jumps over \
+    the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick \
+    daft zebras jump! The five boxing wizards jump quickly. Sphinx of black \
+    quartz, judge my vow. Two driven jocks help fax my big quiz. The jay, pig, \
+    fox, zebra and my wolves quack! Crazy Frederick bought many very exquisite \
+    opal jewels. We promptly judged antique ivory buckles for the next prize. \
+    A mad boxer shot a quick, gloved jab to the jaw of his dizzy opponent. \
+    Jived fox nymph grabs quick waltz. Glib jocks quiz nymph to vex dwarf. \
+    How quickly daft jumping zebras vex! Jackdaws love my big sphinx of quartz. \
+    The quick brown fox jumps over the lazy dog again and again and again. \
+    Amazingly few discotheques provide jukeboxes. My girl wove six dozen plaid \
+    jackets before she quit. Six big devils from Japan quickly forgot how to \
+    waltz. Big July earthquakes confound zany experimental vow. Foxy parsons \
+    quiz and cajole the lovably dim wiki-Loss. Have a pick: twenty-six letters, \
+    no more, no less. Each sentence is a pangram, using every letter at least \
+    once. This block of text is designed to stress test glyph caching by \
+    exercising the full Latin alphabet repeatedly across many lines of wrapped \
+    text at a small font size, ensuring the atlas must handle hundreds of glyph \
+    instances with varying subpixel positions. Some text here. Let's make it a bit longer so that \
+    line wrapping kicks in easily. This demonstrates basic glyph caching with \
+    plain Latin text and common punctuation??? The quick brown fox jumps over \
+    the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick \
+    daft zebras jump! The five boxing wizards jump quickly. Sphinx of black \
+    quartz, judge my vow. Two driven jocks help fax my big quiz. The jay, pig, \
+    fox, zebra and my wolves quack! Crazy Frederick bought many very exquisite \
+    opal jewels. We promptly judged antique ivory buckles for the next prize. \
+    A mad boxer shot a quick, gloved jab to the jaw of his dizzy opponent. \
+    Jived fox nymph grabs quick waltz. Glib jocks quiz nymph to vex dwarf. \
+    How quickly daft jumping zebras vex! Jackdaws love my big sphinx of quartz. \
+    The quick brown fox jumps over the lazy dog again and again and again. \
+    Amazingly few discotheques provide jukeboxes. My girl wove six dozen plaid \
+    jackets before she quit. Six big devils from Japan quickly forgot how to \
+    waltz. Big July earthquakes confound zany experimental vow.";
+
+// const SIMPLE_TEXT: &str = "Some text here. Let's make it a bit longer so that \
+//     line wrapping kicks in easily. This demonstrates basic glyph caching with \
+//     plain Latin text and common punctuation???";
 
 const RICH_TEXT: &str = "Some text here. Let's make it a bit longer so that \
     line wrapping kicks in. Bitmap emoji 😊 and COLR emoji 🎉.\n\
@@ -53,8 +89,7 @@
 /// State for the text example.
 pub struct TextScene {
     layout: Layout<ColorBrush>,
-    /// Type-erased glyph caches, lazily initialized per backend.
-    glyph_caches: Option<Box<dyn Any>>,
+    repeat_count: u32,
 }
 
 impl fmt::Debug for TextScene {
@@ -64,28 +99,47 @@
 }
 
 impl ExampleScene for TextScene {
-    fn render(&mut self, ctx: &mut impl RenderingContext, root_transform: Affine) {
-        if self.glyph_caches.is_none() {
-            self.glyph_caches = Some(ctx.create_glyph_caches());
-        }
+    fn render(
+        &mut self,
+        ctx: &mut impl RenderingContext,
+        root_transform: Affine,
+        glyph_caches: &mut dyn Any,
+        image_cache: &mut ImageCache,
+        text_config: &TextConfig,
+    ) {
+        for i in 0..self.repeat_count {
+            let offset = i as f64 * 10.0;
+            let content_transform = root_transform
+                * Affine::translate(Vec2::new(PADDING as f64 + offset, PADDING as f64 + offset));
+            ctx.set_transform(content_transform);
 
-        let content_transform =
-            root_transform * Affine::translate(Vec2::new(PADDING as f64, PADDING as f64));
-        ctx.set_transform(content_transform);
-
-        let glyph_caches = self
-            .glyph_caches
-            .as_mut()
-            .expect("glyph caches not initialized");
-
-        for line in self.layout.lines() {
-            for item in line.items() {
-                if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
-                    render_glyph_run(ctx, &glyph_run, glyph_caches.as_mut());
+            for line in self.layout.lines() {
+                for item in line.items() {
+                    if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
+                        render_glyph_run(ctx, &glyph_run, glyph_caches, image_cache, text_config);
+                    }
                 }
             }
         }
     }
+
+    fn handle_key(&mut self, key: &str) -> bool {
+        match key {
+            "+" | "=" => {
+                self.repeat_count += 1;
+                true
+            }
+            "-" | "_" => {
+                self.repeat_count = self.repeat_count.saturating_sub(1).max(1);
+                true
+            }
+            _ => false,
+        }
+    }
+
+    fn status(&self) -> Option<String> {
+        Some(format!("repeats: {}", self.repeat_count))
+    }
 }
 
 impl TextScene {
@@ -103,7 +157,7 @@
 
         Self {
             layout,
-            glyph_caches: None,
+            repeat_count: 1,
         }
     }
 
@@ -121,7 +175,7 @@
 
         Self {
             layout,
-            glyph_caches: None,
+            repeat_count: 1,
         }
     }
 }
@@ -218,6 +272,8 @@
     ctx: &mut impl RenderingContext,
     glyph_run: &GlyphRun<'_, ColorBrush>,
     glyph_caches: &mut dyn Any,
+    image_cache: &mut ImageCache,
+    text_config: &TextConfig,
 ) {
     let style = glyph_run.style();
     ctx.set_paint(style.brush.color);
@@ -227,22 +283,18 @@
     let font_size = run.font_size();
     let normalized_coords = run.normalized_coords();
 
-    let glyphs: Vec<Glyph> = glyph_run
-        .positioned_glyphs()
-        .map(|g| Glyph {
-            id: u32::from(g.id),
-            x: g.x,
-            y: g.y,
-        })
-        .collect();
-
     ctx.fill_glyphs(
         font,
         font_size,
-        true,
         normalized_coords,
-        glyphs.into_iter(),
+        glyph_run.positioned_glyphs().map(|g| Glyph {
+            id: u32::from(g.id),
+            x: g.x,
+            y: g.y,
+        }),
         glyph_caches,
+        image_cache,
+        text_config,
     );
 
     if let Some(decoration) = &style.underline {
diff --git a/sparse_strips/vello_hybrid/Cargo.toml b/sparse_strips/vello_hybrid/Cargo.toml
index c3eb0c7..ecfd779 100644
--- a/sparse_strips/vello_hybrid/Cargo.toml
+++ b/sparse_strips/vello_hybrid/Cargo.toml
@@ -54,6 +54,7 @@
 # with the features which you need enabled.
 wgpu_default = ["wgpu", "wgpu/default"]
 webgl = ["dep:js-sys", "dep:web-sys", "dep:vello_sparse_shaders", "vello_sparse_shaders/glsl"]
+text = []
 
 [lints]
 workspace = true
diff --git a/sparse_strips/vello_hybrid/examples/winit/Cargo.toml b/sparse_strips/vello_hybrid/examples/winit/Cargo.toml
index 23e8426..f233dc3 100644
--- a/sparse_strips/vello_hybrid/examples/winit/Cargo.toml
+++ b/sparse_strips/vello_hybrid/examples/winit/Cargo.toml
@@ -15,4 +15,5 @@
 vello_common = { workspace = true }
 vello_hybrid = { workspace = true }
 vello_example_scenes = { workspace = true }
+parley_draw = { workspace = true, features = ["std", "vello_hybrid"] }
 pollster = { workspace = true }
diff --git a/sparse_strips/vello_hybrid/examples/winit/src/main.rs b/sparse_strips/vello_hybrid/examples/winit/src/main.rs
index 113967f..f81641f 100644
--- a/sparse_strips/vello_hybrid/examples/winit/src/main.rs
+++ b/sparse_strips/vello_hybrid/examples/winit/src/main.rs
@@ -4,6 +4,8 @@
 //! Renders our example scenes with Vello Hybrid.
 
 mod render_context;
+use parley_draw::renderers::vello_renderer::replay_atlas_commands;
+use parley_draw::{GLYPH_PADDING, GlyphCache, GlyphCacheConfig, GpuGlyphCaches, PendingClearRect};
 use render_context::{RenderContext, RenderSurface, create_vello_renderer, create_winit_window};
 #[cfg(not(target_arch = "wasm32"))]
 use std::env;
@@ -13,8 +15,8 @@
 use vello_common::paint::ImageId;
 use vello_common::paint::ImageSource;
 use vello_example_scenes::image::ImageScene;
-use vello_example_scenes::{AnyScene, get_example_scenes};
-use vello_hybrid::{Pixmap, RenderSize, Renderer, Scene};
+use vello_example_scenes::{AnyScene, TextConfig, get_example_scenes};
+use vello_hybrid::{AtlasId, Pixmap, RenderSize, Renderer, Scene};
 use winit::{
     application::ApplicationHandler,
     event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
@@ -32,6 +34,9 @@
     renderers: Vec<Option<Renderer>>,
     render_state: RenderState<'s>,
     scene: Scene,
+    glyph_renderer: Scene,
+    glyph_caches: GpuGlyphCaches,
+    text_config: TextConfig,
     transform: Affine,
     mouse_down: bool,
     last_cursor_position: Option<Point>,
@@ -40,6 +45,9 @@
     fps_update_time: Instant,
     accumulated_frame_time: f64,
     accumulated_render_time: f64,
+    accumulated_acquire_time: f64,
+    accumulated_scene_time: f64,
+    accumulated_gpu_time: f64,
 }
 
 fn main() {
@@ -90,6 +98,12 @@
         current_scene: start_scene_index,
         render_state: RenderState::Suspended(None),
         scene: Scene::new(1800, 1200),
+        glyph_renderer: Scene::new(4096, 4096),
+        glyph_caches: GpuGlyphCaches::with_config(GlyphCacheConfig {
+            max_entry_age: u32::MAX,
+            eviction_frequency: u32::MAX,
+        }),
+        text_config: TextConfig::default(),
         transform: Affine::IDENTITY,
         mouse_down: false,
         last_cursor_position: None,
@@ -98,6 +112,9 @@
         fps_update_time: now,
         accumulated_frame_time: 0.0,
         accumulated_render_time: 0.0,
+        accumulated_acquire_time: 0.0,
+        accumulated_scene_time: 0.0,
+        accumulated_gpu_time: 0.0,
     };
 
     let event_loop = EventLoop::new().unwrap();
@@ -213,13 +230,27 @@
                 Key::Named(NamedKey::Escape) => {
                     event_loop.exit();
                 }
-                Key::Character(ch) => {
-                    if let Some(scene) = self.scenes.get_mut(self.current_scene)
-                        && scene.handle_key(ch.as_str())
-                    {
+                Key::Character(ch) => match ch.as_str() {
+                    "a" | "A" => {
+                        self.text_config.use_atlas_cache = !self.text_config.use_atlas_cache;
+                        println!(
+                            "Atlas cache: {}",
+                            if self.text_config.use_atlas_cache {
+                                "ON"
+                            } else {
+                                "OFF"
+                            }
+                        );
                         window.request_redraw();
                     }
-                }
+                    _ => {
+                        if let Some(scene) = self.scenes.get_mut(self.current_scene)
+                            && scene.handle_key(ch.as_str())
+                        {
+                            window.request_redraw();
+                        }
+                    }
+                },
                 _ => {}
             },
             WindowEvent::MouseInput { state, button, .. } => {
@@ -292,58 +323,114 @@
                         let avg_fps = 1000.0 / avg_frame_time;
                         let avg_render_time =
                             self.accumulated_render_time / self.frame_count as f64;
+                        let avg_acquire_time =
+                            self.accumulated_acquire_time / self.frame_count as f64;
+                        let avg_scene_time = self.accumulated_scene_time / self.frame_count as f64;
+                        let avg_gpu_time = self.accumulated_gpu_time / self.frame_count as f64;
                         let status = self.scenes[self.current_scene]
                             .status()
                             .map(|s| format!(" - {s}"))
                             .unwrap_or_default();
                         println!(
-                            "FPS: {avg_fps:.1} | render: {avg_render_time:.2}ms | frame: {avg_frame_time:.2}ms{status}"
+                            "FPS: {avg_fps:.1} | scene: {avg_scene_time:.2}ms | gpu: {avg_gpu_time:.2}ms | render: {avg_render_time:.2}ms | acquire: {avg_acquire_time:.2}ms | frame: {avg_frame_time:.2}ms{status}"
                         );
+                        let atlas_label = if self.text_config.use_atlas_cache {
+                            "atlas ON"
+                        } else {
+                            "atlas OFF"
+                        };
                         window.set_title(&format!(
-                            "Vello Hybrid - Scene {} - {:.1} FPS (render {:.2}ms){status}",
-                            self.current_scene, avg_fps, avg_render_time
+                            "Vello Hybrid - Scene {} - {:.1} FPS (scene {:.2}ms, gpu {:.2}ms, acquire {:.2}ms) [{atlas_label}]{status}",
+                            self.current_scene, avg_fps, avg_scene_time, avg_gpu_time, avg_acquire_time
                         ));
 
                         // Reset counters
                         self.frame_count = 0;
                         self.accumulated_frame_time = 0.0;
                         self.accumulated_render_time = 0.0;
+                        self.accumulated_acquire_time = 0.0;
+                        self.accumulated_scene_time = 0.0;
+                        self.accumulated_gpu_time = 0.0;
                         self.fps_update_time = now;
                     }
                 }
                 self.last_frame_time = Some(now);
 
-                self.scene.reset();
-
-                let render_start = Instant::now();
-
-                self.scene.set_transform(self.transform);
-                self.scenes[self.current_scene].render(&mut self.scene, self.transform);
-
-                let device_handle = &self.context.devices[surface.dev_id];
                 let render_size = RenderSize {
                     width: surface.config.width,
                     height: surface.config.height,
                 };
 
+                let acquire_start = Instant::now();
                 let surface_texture = surface
                     .surface
                     .get_current_texture()
                     .expect("failed to get surface texture");
-
                 let texture_view = surface_texture
                     .texture
                     .create_view(&wgpu::TextureViewDescriptor::default());
+                self.accumulated_acquire_time += acquire_start.elapsed().as_secs_f64() * 1000.0;
 
+                let render_start = Instant::now();
+
+                let renderer = self.renderers[surface.dev_id].as_mut().unwrap();
+
+                let scene_start = Instant::now();
+                self.scene.reset();
+                self.scene.set_transform(self.transform);
+                self.scenes[self.current_scene].render(
+                    &mut self.scene,
+                    self.transform,
+                    &mut self.glyph_caches,
+                    &mut renderer.image_cache,
+                    &self.text_config,
+                );
+                self.accumulated_scene_time += scene_start.elapsed().as_secs_f64() * 1000.0;
+
+                let device_handle = &self.context.devices[surface.dev_id];
+
+                let gpu_start = Instant::now();
+                // Replay outline/COLR draw commands into each atlas page.
+                let pending_cmds = self.glyph_caches.glyph_atlas.take_pending_atlas_commands();
+                for mut recorder in pending_cmds {
+                    self.glyph_renderer.reset();
+                    replay_atlas_commands(&mut recorder.commands, &mut self.glyph_renderer);
+                    renderer
+                        .render_to_atlas(
+                            &self.glyph_renderer,
+                            &device_handle.device,
+                            &device_handle.queue,
+                            AtlasId::new(recorder.page_index),
+                        )
+                        .expect("Failed to render glyphs to atlas");
+                }
+
+                // Upload bitmap glyphs to the GPU atlas.
+                let padding = u32::from(GLYPH_PADDING);
                 let mut encoder =
                     device_handle
                         .device
                         .create_command_encoder(&wgpu::CommandEncoderDescriptor {
-                            label: Some("Vello Render to Surface pass"),
+                            label: Some("Glyph bitmap upload"),
                         });
-                self.renderers[surface.dev_id]
-                    .as_mut()
-                    .unwrap()
+                for upload in self.glyph_caches.glyph_atlas.take_pending_uploads() {
+                    let resource = renderer
+                        .image_cache
+                        .get(upload.image_id)
+                        .expect("Bitmap image not found in cache");
+                    let dst_x = resource.offset[0] as u32 + padding;
+                    let dst_y = resource.offset[1] as u32 + padding;
+                    renderer.write_to_atlas(
+                        &device_handle.device,
+                        &device_handle.queue,
+                        &mut encoder,
+                        upload.image_id,
+                        &upload.pixmap,
+                        Some([dst_x, dst_y]),
+                    );
+                }
+
+                renderer
                     .render(
                         &self.scene,
                         &device_handle.device,
@@ -357,7 +444,17 @@
                 device_handle.queue.submit([encoder.finish()]);
                 surface_texture.present();
 
+                // Maintain caches (eviction, etc.)
+                self.glyph_caches.maintain(&mut renderer.image_cache);
+
+                clear_atlas_regions(
+                    &device_handle.queue,
+                    renderer,
+                    &self.glyph_caches.glyph_atlas.take_pending_clear_rects(),
+                );
+
                 device_handle.device.poll(wgpu::PollType::Poll).unwrap();
+                self.accumulated_gpu_time += gpu_start.elapsed().as_secs_f64() * 1000.0;
 
                 self.accumulated_render_time += render_start.elapsed().as_secs_f64() * 1000.0;
 
@@ -369,6 +466,40 @@
     }
 }
 
+/// Zero out atlas regions on the GPU after eviction.
+fn clear_atlas_regions(queue: &wgpu::Queue, renderer: &Renderer, rects: &[PendingClearRect]) {
+    if rects.is_empty() {
+        return;
+    }
+    let atlas_texture = renderer.atlas_texture();
+    for rect in rects {
+        let zeroed = vec![0_u8; rect.width as usize * rect.height as usize * 4];
+        queue.write_texture(
+            wgpu::TexelCopyTextureInfo {
+                texture: atlas_texture,
+                mip_level: 0,
+                origin: wgpu::Origin3d {
+                    x: rect.x as u32,
+                    y: rect.y as u32,
+                    z: rect.page_index,
+                },
+                aspect: wgpu::TextureAspect::All,
+            },
+            &zeroed,
+            wgpu::TexelCopyBufferLayout {
+                offset: 0,
+                bytes_per_row: Some(rect.width as u32 * 4),
+                rows_per_image: None,
+            },
+            wgpu::Extent3d {
+                width: rect.width as u32,
+                height: rect.height as u32,
+                depth_or_array_layers: 1,
+            },
+        );
+    }
+}
+
 impl App<'_> {
     fn upload_images_to_atlas(&mut self, device_id: usize) {
         let device_handle = &self.context.devices[device_id];
diff --git a/sparse_strips/vello_hybrid/src/render/wgpu.rs b/sparse_strips/vello_hybrid/src/render/wgpu.rs
index 9d1ec2f..4865261 100644
--- a/sparse_strips/vello_hybrid/src/render/wgpu.rs
+++ b/sparse_strips/vello_hybrid/src/render/wgpu.rs
@@ -145,7 +145,16 @@
         render_size: &RenderSize,
         view: &TextureView,
     ) -> Result<(), RenderError> {
-        self.render_scene(scene, device, queue, encoder, render_size, view, true)
+        self.render_scene(
+            scene,
+            device,
+            queue,
+            encoder,
+            render_size,
+            view,
+            true,
+            false,
+        )
     }
 
     /// Render a `scene` directly into an atlas layer.
@@ -215,10 +224,6 @@
             &mut self.programs.resources.stub_atlas_bind_group,
         );
 
-        // TODO: The atlas is always RGBA8; when the surface uses a different format (e.g. BGRA on
-        // macOS), we may need a dedicated RGBA8 render pipeline for atlas rendering. Adopt the
-        // fix from the filters/native-format pipeline work when available.
-
         let result = self.render_scene(
             scene,
             device,
@@ -227,6 +232,7 @@
             &atlas_render_size,
             &layer_view,
             false,
+            true,
         );
 
         // Restore the real atlas bind group.
@@ -256,6 +262,7 @@
         render_size: &RenderSize,
         view: &TextureView,
         clear: bool,
+        use_atlas_pipeline: bool,
     ) -> Result<(), RenderError> {
         self.prepare_gpu_encoded_paints(&scene.encoded_paints);
         // TODO: For the time being, we upload the entire alpha buffer as one big chunk. As a future
@@ -286,6 +293,7 @@
                 queue,
                 encoder,
                 view,
+                use_atlas_pipeline,
             };
             let load_op = if clear { LoadOp::Clear } else { LoadOp::Load };
             ctx.render_strips(&self.fast_path_gpu_strips, 2, load_op);
@@ -298,6 +306,7 @@
                 queue,
                 encoder,
                 view,
+                use_atlas_pipeline,
             };
             self.scheduler
                 .do_scene(&mut self.scheduler_state, &mut ctx, scene, &self.paint_idxs)
@@ -613,6 +622,8 @@
 struct Programs {
     /// Pipeline for rendering wide tile commands.
     strip_pipeline: RenderPipeline,
+    /// Pipeline for rendering strips into the atlas (always Rgba8Unorm).
+    atlas_strip_pipeline: RenderPipeline,
     /// Bind group layout for strip draws
     strip_bind_group_layout: BindGroupLayout,
     /// Bind group layout for encoded paints
@@ -871,6 +882,39 @@
             cache: None,
         });
 
+        let atlas_strip_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+            label: Some("Atlas Strip Pipeline"),
+            layout: Some(&strip_pipeline_layout),
+            vertex: wgpu::VertexState {
+                module: &strip_shader,
+                entry_point: Some("vs_main"),
+                buffers: &[wgpu::VertexBufferLayout {
+                    array_stride: size_of::<GpuStrip>() as u64,
+                    step_mode: wgpu::VertexStepMode::Instance,
+                    attributes: &GpuStrip::vertex_attributes(),
+                }],
+                compilation_options: PipelineCompilationOptions::default(),
+            },
+            fragment: Some(wgpu::FragmentState {
+                module: &strip_shader,
+                entry_point: Some("fs_main"),
+                targets: &[Some(ColorTargetState {
+                    format: wgpu::TextureFormat::Rgba8Unorm,
+                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
+                    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: None,
+            cache: None,
+        });
+
         let clear_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
             label: Some("Clear Slots Pipeline"),
             layout: Some(&clear_pipeline_layout),
@@ -1099,6 +1143,7 @@
 
         Self {
             strip_pipeline,
+            atlas_strip_pipeline,
             strip_bind_group_layout,
             encoded_paints_bind_group_layout,
             gradient_bind_group_layout,
@@ -1737,6 +1782,9 @@
     queue: &'a Queue,
     encoder: &'a mut CommandEncoder,
     view: &'a TextureView,
+    /// When true, use the Rgba8Unorm atlas strip pipeline instead of the
+    /// surface-format strip pipeline.
+    use_atlas_pipeline: bool,
 }
 
 impl RendererContext<'_> {
@@ -1774,7 +1822,12 @@
             occlusion_query_set: None,
             timestamp_writes: None,
         });
-        render_pass.set_pipeline(&self.programs.strip_pipeline);
+        let pipeline = if self.use_atlas_pipeline {
+            &self.programs.atlas_strip_pipeline
+        } else {
+            &self.programs.strip_pipeline
+        };
+        render_pass.set_pipeline(pipeline);
         render_pass.set_bind_group(0, &self.programs.resources.slot_bind_groups[ix], &[]);
         render_pass.set_bind_group(1, &self.programs.resources.atlas_bind_group, &[]);
         render_pass.set_bind_group(2, &self.programs.resources.encoded_paints_bind_group, &[]);
diff --git a/sparse_strips/vello_hybrid/src/scene.rs b/sparse_strips/vello_hybrid/src/scene.rs
index f103cbc..24e3e27 100644
--- a/sparse_strips/vello_hybrid/src/scene.rs
+++ b/sparse_strips/vello_hybrid/src/scene.rs
@@ -454,6 +454,20 @@
         }
     }
 
+    /// Fill a rectangle, bypassing the pixel-alignment fast-path check.
+    ///
+    /// Use this when the caller already knows the transform/rect combination is
+    /// *not* pixel-aligned (e.g. glyph atlas rendering with fractional bearing
+    /// offsets or non-identity paint transforms). This avoids the per-call cost
+    /// of `is_integer_translation` and `is_integer_rect`.
+    #[inline]
+    pub fn fill_rect_pixel_aligned(&mut self, rect: &Rect) {
+        if !self.paint_visible {
+            return;
+        }
+        self.fill_rect_fast(rect);
+    }
+
     /// Fast path for filling a pixel-aligned rectangle.
     ///
     /// Bypasses path processing by generating strips directly for the rectangle.