git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit4f81934c193c6a0d75af6f9714a67d24b1da6c76
parentf7799aaaa5
authorClaude <noreply@anthropic.com>
date2026-10-04 20:11
refactor(draw): the glyph atlas, the window-info block and the 2D shaders are shared

The web renderer (W2) will draw the same frames as the Vulkan one, so
what both need moves into draw/ instead of being copied:

- draw::glyphs: the CPU half of the Vulkan text stage, moved verbatim:
  the shelf packer, the swash rasterization into an RGBA8 atlas, and the
  glyph quads (GlyphAtlas::prepare, pixels, generation, vertices), plus
  image_quad_vertices for the image pass, which feeds the same shader
  with the same 52-byte vertex. TextStage keeps the upload and the draw
  around a GlyphAtlas; ImageStage builds its quads through the shared
  function.
- draw::window_info_data / relief_px_at / WINDOW_INFO_BYTES /
  PLATE_FEATURE_BYTES: shader2d's WindowInfo block and the feature
  stride, by width and height rather than a vk::Extent2D (vk keeps a
  thin wrapper for its own callers and the plate probe).
- draw::shaders: shader2d.wgsl and glyph.wgsl move from vk/ to draw/,
  one source for both renderers. shader2d_for_webgpu() swaps the one
  push-constant line for a @group(1) uniform, since WebGPU has none;
  WEBGPU_BLOCK_STRIDE is the 256-byte offset step every device allows.
- shader2d.wgsl samples the backdrop with textureSampleLevel(.., 0.0).
  WebGPU rejects implicit-LOD sampling in non-uniform control flow (the
  blur branch is), and the backdrop has one level, so the texel is the
  one an implicit LOD picks.

A new test validates the WebGPU variants with naga and no capabilities
at all, checks the uniform block's size against PUSH_CONSTANT_FLOATS and
the stride, and catches a substitution that stopped applying.

Verified natively: test results unchanged (524 pass, the same 2
pre-existing failures), the plate golden byte-identical, the demo
identical to the pixel over the 24 scripted steps (text and image
views through the moved atlas and quad code), and examples/frost_pair
-- three frosted plates, the one path the level-0 sampling touches --
identical to the pixel between the previous commit and this one under
headless sway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ

 CLAUDE.md                      |  11 +-
 src/{vk => draw}/glyph.wgsl    |   0
 src/draw/glyphs.rs             | 356 +++++++++++++++++++++++++++++++++++++++++
 src/draw/mod.rs                |  50 ++++++
 src/{vk => draw}/shader2d.wgsl |  11 +-
 src/draw/shaders.rs            |  29 ++++
 src/scene/material.rs          |   2 +-
 src/scene/relief_shade.rs      |   2 +-
 src/vk/image.rs                |  48 +-----
 src/vk/renderer.rs             |  81 +++++-----
 src/vk/text.rs                 | 291 ++-------------------------------
 11 files changed, 509 insertions(+), 372 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 35fbd44..76e6aa6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1130,7 +1130,16 @@ cce-system-interface) to confirm behavior, not just the test suite.
   (`upload_rgba`, `update_pixels`, `free_image`, `renderer_epoch`, …) that a renderer
   drains with `take_pending`. They lived in `vk/` while Vulkan was the only renderer;
   `vk` re-exports every one at its old path, so `cce_ui::vk::upload_rgba` and the rest
-  are unchanged for clients.
+  are unchanged for clients. Also here, shared by every renderer: `draw::glyphs`
+  (`GlyphAtlas` — rasterizing, packing and the glyph quads; a renderer uploads
+  `pixels()` when `generation()` moves — and `image_quad_vertices`), `window_info_data`
+  (shader2d's `WindowInfo` block), and `draw::shaders`: `shader2d.wgsl` and `glyph.wgsl`
+  live in `src/draw/` now, one source for both renderers. WebGPU has no push constants,
+  so `shader2d_for_webgpu()` swaps the one push-block line for a `@group(1)` uniform read
+  at a per-batch dynamic offset (`WEBGPU_BLOCK_STRIDE`); the backdrop is sampled with
+  `textureSampleLevel(…, 0.0)` because WebGPU rejects implicit-LOD sampling in the
+  non-uniform blur branch (the backdrop has one level, so the texel is the same —
+  `frost_pair` is identical to the pixel either way).
 - `protocol.rs` — inline-generated Wayland protocol bindings.
 - `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
   the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
diff --git a/src/vk/glyph.wgsl b/src/draw/glyph.wgsl
similarity index 100%
rename from src/vk/glyph.wgsl
rename to src/draw/glyph.wgsl
diff --git a/src/draw/glyphs.rs b/src/draw/glyphs.rs
new file mode 100644
index 0000000..595332e
--- /dev/null
+++ b/src/draw/glyphs.rs
@@ -0,0 +1,356 @@
+//! The glyph atlas, CPU side: shaped runs ([`TextSpan`]s) rasterized through
+//! swash into an RGBA8 atlas (a shelf packer, a cache keyed by cosmic-text's
+//! glyph key) and turned into glyph quads. A renderer uploads [`pixels`]
+//! whenever [`generation`] moves and draws [`vertices`] with `glyph.wgsl`.
+//! Image quads use the same vertex and shader ([`image_quad_vertices`]).
+//!
+//! It was half of the Vulkan text stage; the other half — the upload and
+//! the draw — is what each renderer keeps.
+//!
+//! [`pixels`]: GlyphAtlas::pixels
+//! [`generation`]: GlyphAtlas::generation
+//! [`vertices`]: GlyphAtlas::vertices
+
+use std::collections::HashMap;
+
+use cosmic_text::{CacheKey, FontSystem, SwashCache, SwashContent};
+
+use super::{ImageQuad, TextSpan};
+
+/// The atlas is one ATLAS_SIZE² RGBA8 texture (unorm, sampled nearest).
+pub const ATLAS_SIZE: u32 = 1024;
+const ATLAS_PAD: u32 = 1;
+
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct GlyphVertex {
+    pub position: [f32; 2],
+    pub uv: [f32; 2],
+    pub color: [f32; 4],
+    pub clip_circle: [f32; 3],
+    pub clip_extents: [f32; 2],
+}
+
+// The glyph shader's vertex (locations 0..=4) for text AND image quads: both
+// pipelines feed `glyph.wgsl`, and every renderer describes this exact layout.
+const _: () = {
+    assert!(std::mem::size_of::<GlyphVertex>() == 52);
+    assert!(std::mem::offset_of!(GlyphVertex, position) == 0);
+    assert!(std::mem::offset_of!(GlyphVertex, uv) == 8);
+    assert!(std::mem::offset_of!(GlyphVertex, color) == 16);
+    assert!(std::mem::offset_of!(GlyphVertex, clip_circle) == 32);
+    assert!(std::mem::offset_of!(GlyphVertex, clip_extents) == 44);
+};
+
+#[derive(Clone, Copy)]
+struct GlyphEntry {
+    /// Atlas texel rect.
+    u: u32,
+    v: u32,
+    w: u32,
+    h: u32,
+    /// Raster placement offsets (from swash).
+    left: i32,
+    top: i32,
+    is_color: bool,
+    /// Zero-sized raster (spaces): nothing to draw, but cached to skip re-rastering.
+    empty: bool,
+}
+
+struct Shelf {
+    cursor_x: u32,
+    cursor_y: u32,
+    row_height: u32,
+}
+
+impl Shelf {
+    fn new() -> Self {
+        Shelf { cursor_x: ATLAS_PAD, cursor_y: ATLAS_PAD, row_height: 0 }
+    }
+
+    fn insert(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
+        if w > ATLAS_SIZE - 2 * ATLAS_PAD || h > ATLAS_SIZE - 2 * ATLAS_PAD {
+            return None;
+        }
+        if self.cursor_x + w + ATLAS_PAD > ATLAS_SIZE {
+            self.cursor_x = ATLAS_PAD;
+            self.cursor_y += self.row_height + ATLAS_PAD;
+            self.row_height = 0;
+        }
+        if self.cursor_y + h + ATLAS_PAD > ATLAS_SIZE {
+            return None;
+        }
+        let pos = (self.cursor_x, self.cursor_y);
+        self.cursor_x += w + ATLAS_PAD;
+        self.row_height = self.row_height.max(h);
+        Some(pos)
+    }
+}
+
+/// The atlas and this frame's glyph quads.
+pub struct GlyphAtlas {
+    /// RGBA8, ATLAS_SIZE², the texture's contents.
+    pixels: Vec<u8>,
+    /// Bumped whenever `pixels` changes; a renderer re-uploads on a change.
+    generation: u64,
+    glyphs: HashMap<CacheKey, GlyphEntry>,
+    shelf: Shelf,
+    /// The staged text: replaced only by the next [`prepare`](Self::prepare),
+    /// so a frame drawn without a re-prepare keeps its text.
+    vertices: Vec<GlyphVertex>,
+}
+
+impl Default for GlyphAtlas {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl GlyphAtlas {
+    pub fn new() -> Self {
+        Self {
+            pixels: vec![0u8; (ATLAS_SIZE * ATLAS_SIZE * 4) as usize],
+            generation: 1,
+            glyphs: HashMap::new(),
+            shelf: Shelf::new(),
+            vertices: Vec::new(),
+        }
+    }
+
+    /// The atlas texture's contents (RGBA8, `ATLAS_SIZE`²).
+    pub fn pixels(&self) -> &[u8] {
+        &self.pixels
+    }
+
+    /// Moves whenever [`pixels`](Self::pixels) changed.
+    pub fn generation(&self) -> u64 {
+        self.generation
+    }
+
+    /// The glyph quads the last [`prepare`](Self::prepare) built, six vertices each.
+    pub fn vertices(&self) -> &[GlyphVertex] {
+        &self.vertices
+    }
+
+    /// Rasterize (on miss) and cache one glyph. Returns None when the atlas is full.
+    fn ensure_glyph(
+        &mut self,
+        font_system: &mut FontSystem,
+        swash_cache: &mut SwashCache,
+        key: CacheKey,
+    ) -> Option<GlyphEntry> {
+        if let Some(entry) = self.glyphs.get(&key) {
+            return Some(*entry);
+        }
+        let image = swash_cache.get_image_uncached(font_system, key)?;
+        let w = image.placement.width;
+        let h = image.placement.height;
+        if w == 0 || h == 0 || image.data.is_empty() {
+            let entry = GlyphEntry {
+                u: 0, v: 0, w: 0, h: 0, left: 0, top: 0, is_color: false, empty: true,
+            };
+            self.glyphs.insert(key, entry);
+            return Some(entry);
+        }
+        let (u, v) = self.shelf.insert(w, h)?;
+
+        let is_color = !matches!(image.content, SwashContent::Mask);
+        for row in 0..h {
+            for col in 0..w {
+                let dst = (((v + row) * ATLAS_SIZE + (u + col)) * 4) as usize;
+                let texel = match image.content {
+                    SwashContent::Mask => {
+                        let a = image.data[(row * w + col) as usize];
+                        [255, 255, 255, a]
+                    }
+                    // Color and SubpixelMask rasters are RGBA.
+                    _ => {
+                        let src = ((row * w + col) * 4) as usize;
+                        [
+                            image.data[src],
+                            image.data[src + 1],
+                            image.data[src + 2],
+                            image.data[src + 3],
+                        ]
+                    }
+                };
+                self.pixels[dst..dst + 4].copy_from_slice(&texel);
+            }
+        }
+        self.generation += 1;
+
+        let entry = GlyphEntry {
+            u,
+            v,
+            w,
+            h,
+            left: image.placement.left,
+            top: image.placement.top,
+            is_color,
+            empty: false,
+        };
+        self.glyphs.insert(key, entry);
+        Some(entry)
+    }
+
+    /// Build this frame's glyph vertices. Positions/bounds in physical pixels,
+    /// NDC computed against the target's
+    /// `width` x `height` (Y up; the Vulkan renderer flips with its viewport).
+    pub fn prepare(
+        &mut self,
+        font_system: &mut FontSystem,
+        swash_cache: &mut SwashCache,
+        spans: &[TextSpan<'_>],
+        width: u32,
+        height: u32,
+    ) {
+        self.vertices.clear();
+        if !self.try_prepare(font_system, swash_cache, spans, width, height) {
+            // Atlas full: clear and repack with only the glyphs this frame needs.
+            log::info!("glyph atlas full — clearing and repacking");
+            self.glyphs.clear();
+            self.shelf = Shelf::new();
+            self.pixels.fill(0);
+            self.generation += 1;
+            self.vertices.clear();
+            if !self.try_prepare(font_system, swash_cache, spans, width, height) {
+                log::error!("glyph atlas full even after repack; text truncated this frame");
+            }
+        }
+    }
+
+    fn try_prepare(
+        &mut self,
+        font_system: &mut FontSystem,
+        swash_cache: &mut SwashCache,
+        spans: &[TextSpan<'_>],
+        width: u32,
+        height: u32,
+    ) -> bool {
+        let sw = width as f32;
+        let sh = height as f32;
+        for span in spans {
+            for run in span.buffer.layout_runs() {
+                let line_y = (run.line_y * span.scale).round() as i32;
+                for glyph in run.glyphs.iter() {
+                    let physical = glyph.physical((span.left, span.top), span.scale);
+                    let Some(entry) =
+                        self.ensure_glyph(font_system, swash_cache, physical.cache_key)
+                    else {
+                        // Distinguish "atlas full" (retryable) from "unrasterizable"
+                        // (skip): a missing swash image caches as empty above, so a
+                        // None here means the shelf rejected it.
+                        if swash_cache
+                            .get_image_uncached(font_system, physical.cache_key)
+                            .is_some()
+                        {
+                            return false;
+                        }
+                        continue;
+                    };
+                    if entry.empty {
+                        continue;
+                    }
+
+                    // glyphon's placement formula (kept verbatim), physical pixels.
+                    let mut x0 = (physical.x + entry.left) as f32;
+                    let mut y0 = (line_y + physical.y - entry.top) as f32;
+                    let mut x1 = x0 + entry.w as f32;
+                    let mut y1 = y0 + entry.h as f32;
+                    let mut u0 = entry.u as f32;
+                    let mut v0 = entry.v as f32;
+                    let mut u1 = u0 + entry.w as f32;
+                    let mut v1 = v0 + entry.h as f32;
+
+                    // CPU clip to span bounds, shrinking UVs proportionally.
+                    if let Some([bl, bt, br, bb]) = span.bounds {
+                        let (bl, bt, br, bb) = (bl as f32, bt as f32, br as f32, bb as f32);
+                        if x0 >= br || x1 <= bl || y0 >= bb || y1 <= bt {
+                            continue;
+                        }
+                        if x0 < bl {
+                            u0 += bl - x0;
+                            x0 = bl;
+                        }
+                        if x1 > br {
+                            u1 -= x1 - br;
+                            x1 = br;
+                        }
+                        if y0 < bt {
+                            v0 += bt - y0;
+                            y0 = bt;
+                        }
+                        if y1 > bb {
+                            v1 -= y1 - bb;
+                            y1 = bb;
+                        }
+                    }
+
+                    let color = if entry.is_color {
+                        [1.0, 1.0, 1.0, 1.0]
+                    } else if let Some(c) = glyph.color_opt {
+                        [
+                            c.r() as f32 / 255.0,
+                            c.g() as f32 / 255.0,
+                            c.b() as f32 / 255.0,
+                            c.a() as f32 / 255.0,
+                        ]
+                    } else {
+                        span.default_color
+                    };
+
+                    // Corner positions, optionally rotated about the span's center
+                    // (physical px) before the NDC mapping.
+                    let corners = match span.rotation {
+                        None => [[x0, y0], [x1, y0], [x0, y1], [x1, y1]],
+                        Some((angle, cx, cy)) => {
+                            let (sin_a, cos_a) = angle.sin_cos();
+                            let rot = |px: f32, py: f32| {
+                                let (dx, dy) = (px - cx, py - cy);
+                                [cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a]
+                            };
+                            [rot(x0, y0), rot(x1, y0), rot(x0, y1), rot(x1, y1)]
+                        }
+                    };
+                    let ndc = |p: [f32; 2]| {
+                        [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0]
+                    };
+                    let uv = |u: f32, v: f32| [u / ATLAS_SIZE as f32, v / ATLAS_SIZE as f32];
+                    let clip_circle = span.clip_circle;
+                    let clip_extents = span.clip_extents;
+                    let tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle, clip_extents };
+                    let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle, clip_extents };
+                    let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle, clip_extents };
+                    let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle, clip_extents };
+                    self.vertices.extend([tl, tr, bl, tr, br, bl]);
+                }
+            }
+        }
+        true
+    }
+}
+
+/// Image quads as the glyph shader's vertices, six per image in `images`
+/// order, NDC against a `width` x `height` target. A renderer draws image `i`
+/// as vertices `6i..6i+6` with that image's texture bound.
+pub fn image_quad_vertices(images: &[ImageQuad], width: u32, height: u32) -> Vec<GlyphVertex> {
+    let sw = width as f32;
+    let sh = height as f32;
+    let mut verts: Vec<GlyphVertex> = Vec::with_capacity(images.len() * 6);
+    for q in images {
+        let (x, y, w, h) = q.rect;
+        let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
+        let color = [1.0, 1.0, 1.0, q.alpha];
+        let clip_circle = [0.0; 3];
+        // Zero extents = the shader's plain-circle clip degenerate case. Inert
+        // while clip_circle.z is 0 (the clip branch never runs), but it must be
+        // a defined value, not whatever a missing attribute would read.
+        let clip_extents = [0.0; 2];
+        let tl = GlyphVertex { position: ndc(x, y), uv: [0.0, 0.0], color, clip_circle, clip_extents };
+        let tr = GlyphVertex { position: ndc(x + w, y), uv: [1.0, 0.0], color, clip_circle, clip_extents };
+        let bl = GlyphVertex { position: ndc(x, y + h), uv: [0.0, 1.0], color, clip_circle, clip_extents };
+        let br = GlyphVertex { position: ndc(x + w, y + h), uv: [1.0, 1.0], color, clip_circle, clip_extents };
+        verts.extend([tl, tr, bl, tr, br, bl]);
+    }
+    verts
+}
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
index 8bf7990..b788995 100644
--- a/src/draw/mod.rs
+++ b/src/draw/mod.rs
@@ -12,7 +12,9 @@
 use crate::backend::tessellate::Vertex;
 use cosmic_text::Buffer as TextBuffer;
 
+pub mod glyphs;
 pub mod images;
+pub mod shaders;
 
 pub use images::{
     free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
@@ -165,3 +167,51 @@ pub struct TextSpan<'a> {
     /// `clip_extents` — so plate children (labels included) cut off at rounded corners.
     pub clip_extents: [f32; 2],
 }
+
+/// The bytes of one plate feature (a carve CSG'd into a plate): three vec4s,
+/// `[f32; 12]` in `Frame2D::plate_features`.
+pub const PLATE_FEATURE_BYTES: usize = 48;
+
+/// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
+/// [8 vec4 of profile slope samples].
+// [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
+// [size/clip vec4][carve profile meta][8 carve slopes][roll profile meta]
+// [8 roll slopes][relief heights][backdrop meta] = 21 vec4. Grows only at the
+// END — every offset above is addressed by index from both sides.
+pub const WINDOW_INFO_BYTES: usize = 320;
+
+/// The pinned relief heights (carve, roll) in physical px at `scale`, 0 =
+/// follow the width.
+pub fn relief_px_at(scale: f32) -> (f32, f32) {
+    let s = scale.max(0.001);
+    (
+        crate::layout::bevel_height().map_or(0.0, |h| h * s),
+        crate::layout::roll_height().map_or(0.0, |h| h * s),
+    )
+}
+
+/// shader2d's `WindowInfo` block for a `width` x `height` target whose corners clip
+/// at `clip_corner_radius` (physical px), with the pinned relief heights
+/// `relief` (carve, roll; physical px, 0 = unpinned) — the profiles and the
+/// corner shape from the live style. Shared by every renderer and the offscreen
+/// test harness.
+pub fn window_info_data(width: u32, height: u32, clip_corner_radius: f32, relief: (f32, f32)) -> [f32; WINDOW_INFO_BYTES / 4] {
+    let mut data = [0.0f32; WINDOW_INFO_BYTES / 4];
+    data[0] = width as f32;
+    data[1] = height as f32;
+    data[2] = clip_corner_radius;
+    data[3] = crate::layout::corner_shape();
+    if let Some(slopes) = crate::layout::bevel_profile_slopes() {
+        data[4] = 1.0;
+        data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+        data[8..8 + slopes.len()].copy_from_slice(&slopes);
+    }
+    if let Some(slopes) = crate::layout::roll_profile_slopes() {
+        data[40] = 1.0;
+        data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+        data[44..44 + slopes.len()].copy_from_slice(&slopes);
+    }
+    data[76] = relief.0;
+    data[77] = relief.1;
+    data
+}
diff --git a/src/vk/shader2d.wgsl b/src/draw/shader2d.wgsl
similarity index 99%
rename from src/vk/shader2d.wgsl
rename to src/draw/shader2d.wgsl
index 77a9423..925de88 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/draw/shader2d.wgsl
@@ -1202,12 +1202,17 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
 // spacing in physical px (sigma = 2 taps); both come from the plate's own
 // push block (MODE_PLATE), or are the no-recipe defaults (droplet, raw
 // vertices). A stride of 0 is a CLEAR plate: one clean sample, tinted.
+//
+// The backdrop is sampled at level 0 explicitly: it has one level, so that is
+// the texel an implicit LOD would pick, and this runs under non-uniform control
+// flow (the blur branch), where WebGPU forbids the derivatives an implicit LOD
+// takes.
 fn resolve_blur(pos: vec2f, color: vec4f, refract: vec2f, clarity: f32, k_in: f32, stride: f32) -> vec4f {
     let tex_size = vec2f(textureDimensions(t_backdrop));
 
     var backdrop_color = vec4f(0.0);
     if (stride <= 0.0) {
-        backdrop_color = textureSample(t_backdrop, s_backdrop, (pos + refract) / tex_size);
+        backdrop_color = textureSampleLevel(t_backdrop, s_backdrop, (pos + refract) / tex_size, 0.0);
     } else {
         var blurred = vec4f(0.0);
         var total_weight = 0.0;
@@ -1221,7 +1226,7 @@ fn resolve_blur(pos: vec2f, color: vec4f, refract: vec2f, clarity: f32, k_in: f3
                 let offset = vec2f(x, y) * stride;
                 let sample_uv = (pos + offset) / tex_size;
                 let weight = exp(-(x*x + y*y) / (2.0 * 2.0 * 2.0));
-                blurred += textureSample(t_backdrop, s_backdrop, sample_uv) * weight;
+                blurred += textureSampleLevel(t_backdrop, s_backdrop, sample_uv, 0.0) * weight;
                 total_weight += weight;
             }
         }
@@ -1242,7 +1247,7 @@ fn resolve_blur(pos: vec2f, color: vec4f, refract: vec2f, clarity: f32, k_in: f3
     // narrow is invisible once the body blur is 49 taps, and paying for it
     // would triple the most expensive path in this shader to be erased.
     if (clarity > 0.001) {
-        let clean = textureSample(t_backdrop, s_backdrop, (pos + refract) / tex_size);
+        let clean = textureSampleLevel(t_backdrop, s_backdrop, (pos + refract) / tex_size, 0.0);
         backdrop_color = mix(backdrop_color, clean, clamp(clarity, 0.0, 1.0));
     }
 
diff --git a/src/draw/shaders.rs b/src/draw/shaders.rs
new file mode 100644
index 0000000..3fc8d33
--- /dev/null
+++ b/src/draw/shaders.rs
@@ -0,0 +1,29 @@
+//! The 2D and glyph shaders, shared by every renderer: one WGSL source each.
+//!
+//! The Vulkan renderer compiles them to SPIR-V through naga. WebGPU takes WGSL
+//! as it is, with one difference: it has no push constants, so the per-batch
+//! parameter block (`RRectClip`, [`super::PUSH_CONSTANT_FLOATS`] floats laid
+//! out by [`super::batch_push_constants`]) is a uniform there, bound at
+//! `@group(1) @binding(0)` with a dynamic offset per batch
+//! ([`shader2d_for_webgpu`]).
+
+/// The 2D pipeline's shader: geometry, the SDF-lit plates, blur-behind.
+pub const SHADER2D: &str = include_str!("shader2d.wgsl");
+
+/// Text and image quads.
+pub const GLYPH: &str = include_str!("glyph.wgsl");
+
+/// The one line that differs between the Vulkan and the WebGPU 2D shader.
+const PUSH_BLOCK: &str = "var<push_constant> rrect_clip: RRectClip;";
+const UNIFORM_BLOCK: &str = "@group(1) @binding(0) var<uniform> rrect_clip: RRectClip;";
+
+/// Where a batch's block sits in WebGPU's uniform buffer: each batch's
+/// [`super::PUSH_CONSTANT_FLOATS`] floats at a multiple of this, the
+/// `minUniformBufferOffsetAlignment` every WebGPU device supports.
+pub const WEBGPU_BLOCK_STRIDE: usize = 256;
+
+/// [`SHADER2D`] for WebGPU: the push-constant block declared as a uniform.
+pub fn shader2d_for_webgpu() -> String {
+    assert_eq!(SHADER2D.matches(PUSH_BLOCK).count(), 1, "shader2d.wgsl must declare its push block exactly once");
+    SHADER2D.replace(PUSH_BLOCK, UNIFORM_BLOCK)
+}
diff --git a/src/scene/material.rs b/src/scene/material.rs
index 9398af9..a24718c 100644
--- a/src/scene/material.rs
+++ b/src/scene/material.rs
@@ -620,7 +620,7 @@ mod tests {
         assert_eq!(Frost::Unfrosted.pack(2.0), [0.0, 0.0]);
         assert_eq!(Frost::Frosted { compression: 0.0, refraction: 0.0, radius: 0.0 }.pack(2.0), [0.0, 0.0]);
 
-        let wgsl = include_str!("../vk/shader2d.wgsl");
+        let wgsl = include_str!("../draw/shader2d.wgsl");
         let lit = |name: &str| -> f32 {
             let rest = wgsl.split(&format!("const {name}: f32 = ")).nth(1).unwrap_or_else(|| panic!("{name} missing"));
             rest.split(';').next().unwrap().trim().parse().unwrap()
diff --git a/src/scene/relief_shade.rs b/src/scene/relief_shade.rs
index f0e25dd..4d53b10 100644
--- a/src/scene/relief_shade.rs
+++ b/src/scene/relief_shade.rs
@@ -253,7 +253,7 @@ mod tests {
 
     /// The shader's own source, so the constants below are checked against the
     /// thing they mirror rather than against a comment.
-    const WGSL: &str = include_str!("../vk/shader2d.wgsl");
+    const WGSL: &str = include_str!("../draw/shader2d.wgsl");
 
     fn wgsl_const(name: &str) -> f32 {
         let needle = format!("const {name}: f32 = ");
diff --git a/src/vk/image.rs b/src/vk/image.rs
index bc34f22..10178be 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -60,33 +60,8 @@ pub(crate) fn mip_level_count(width: u32, height: u32) -> u32 {
     32 - width.max(height).max(1).leading_zeros()
 }
 
-#[repr(C)]
-#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-/// Must match `GlyphVertex` field-for-field: both pipelines are fed by the same
-/// glyph shader, whose vertex entry point declares locations 0..=4. Omitting
-/// `clip_extents` here left location 4 with no `VkVertexInputAttributeDescription`,
-/// which the validation layer flags (VUID-VkGraphicsPipelineCreateInfo-Input-07904)
-/// and which reads undefined data without `vertexAttributeRobustness`.
-struct ImageVertex {
-    position: [f32; 2],
-    uv: [f32; 2],
-    color: [f32; 4],
-    clip_circle: [f32; 3],
-    clip_extents: [f32; 2],
-}
-
-// The pipeline below hardcodes one offset per shader location. Pin the struct to
-// them so adding, reordering, or resizing a field fails the build instead of
-// silently feeding the shader mis-aligned attributes — the drift that left
-// location 4 undescribed. `text.rs` pins `GlyphVertex` to the same layout.
-const _: () = {
-    assert!(std::mem::size_of::<ImageVertex>() == 52);
-    assert!(std::mem::offset_of!(ImageVertex, position) == 0);
-    assert!(std::mem::offset_of!(ImageVertex, uv) == 8);
-    assert!(std::mem::offset_of!(ImageVertex, color) == 16);
-    assert!(std::mem::offset_of!(ImageVertex, clip_circle) == 32);
-    assert!(std::mem::offset_of!(ImageVertex, clip_extents) == 44);
-};
+/// An image quad's vertex: the glyph shader's, shared with the text stage.
+type ImageVertex = crate::draw::glyphs::GlyphVertex;
 
 struct GpuImage {
     image: vk::Image,
@@ -769,24 +744,7 @@ impl ImageStage {
         images: &[ImageQuad],
         extent: vk::Extent2D,
     ) {
-        let sw = extent.width as f32;
-        let sh = extent.height as f32;
-        let mut verts: Vec<ImageVertex> = Vec::with_capacity(images.len() * 6);
-        for q in images {
-            let (x, y, w, h) = q.rect;
-            let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
-            let color = [1.0, 1.0, 1.0, q.alpha];
-            let clip_circle = [0.0; 3];
-            // Zero extents = the shader's plain-circle clip degenerate case. Inert
-            // while clip_circle.z is 0 (the clip branch never runs), but it must be
-            // a defined value, not whatever the missing attribute used to read.
-            let clip_extents = [0.0; 2];
-            let tl = ImageVertex { position: ndc(x, y), uv: [0.0, 0.0], color, clip_circle, clip_extents };
-            let tr = ImageVertex { position: ndc(x + w, y), uv: [1.0, 0.0], color, clip_circle, clip_extents };
-            let bl = ImageVertex { position: ndc(x, y + h), uv: [0.0, 1.0], color, clip_circle, clip_extents };
-            let br = ImageVertex { position: ndc(x + w, y + h), uv: [1.0, 1.0], color, clip_circle, clip_extents };
-            verts.extend([tl, tr, bl, tr, br, bl]);
-        }
+        let verts = crate::draw::glyphs::image_quad_vertices(images, extent.width, extent.height);
         let bytes: &[u8] = bytemuck::cast_slice(&verts);
         let buf = &mut self.frame_buffers[frame_index];
         if bytes.len() as vk::DeviceSize > buf.size {
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index cb96ba8..9a1c917 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -96,48 +96,14 @@ fn rect_intersect(a: vk::Rect2D, b: vk::Rect2D) -> vk::Rect2D {
 }
 
 pub(crate) const FRAMES_IN_FLIGHT: usize = 2;
-pub(crate) const PLATE_FEATURE_BYTES: usize = 48;
-/// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
-/// [8 vec4 of profile slope samples].
-// [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
-// [size/clip vec4][carve profile meta][8 carve slopes][roll profile meta]
-// [8 roll slopes][relief heights][backdrop meta] = 21 vec4. Grows only at the
-// END — every offset above is addressed by index from both sides.
-pub(crate) const WINDOW_INFO_BYTES: vk::DeviceSize = 320;
-
-/// The pinned relief heights (carve, roll) in physical px at `scale`, 0 =
-/// follow the width.
-pub(crate) fn relief_px_at(scale: f32) -> (f32, f32) {
-    let s = scale.max(0.001);
-    (
-        crate::layout::bevel_height().map_or(0.0, |h| h * s),
-        crate::layout::roll_height().map_or(0.0, |h| h * s),
-    )
-}
-
-/// shader2d's `WindowInfo` block for a target of `extent` whose corners clip
-/// at `clip_corner_radius` (physical px), with the pinned relief heights
-/// `relief` (carve, roll; physical px, 0 = unpinned) — the profiles and the
-/// corner shape from the live style. Shared with the offscreen test harness.
-pub(crate) fn window_info_data(extent: vk::Extent2D, clip_corner_radius: f32, relief: (f32, f32)) -> [f32; WINDOW_INFO_BYTES as usize / 4] {
-    let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
-    data[0] = extent.width as f32;
-    data[1] = extent.height as f32;
-    data[2] = clip_corner_radius;
-    data[3] = crate::layout::corner_shape();
-    if let Some(slopes) = crate::layout::bevel_profile_slopes() {
-        data[4] = 1.0;
-        data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
-        data[8..8 + slopes.len()].copy_from_slice(&slopes);
-    }
-    if let Some(slopes) = crate::layout::roll_profile_slopes() {
-        data[40] = 1.0;
-        data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
-        data[44..44 + slopes.len()].copy_from_slice(&slopes);
-    }
-    data[76] = relief.0;
-    data[77] = relief.1;
-    data
+pub(crate) use crate::draw::PLATE_FEATURE_BYTES;
+/// shader2d's WindowInfo uniform, in bytes (layout in `draw::window_info_data`).
+pub(crate) const WINDOW_INFO_BYTES: vk::DeviceSize = crate::draw::WINDOW_INFO_BYTES as vk::DeviceSize;
+pub(crate) use crate::draw::relief_px_at;
+
+/// [`crate::draw::window_info_data`] for a Vulkan extent.
+pub(crate) fn window_info_data(extent: vk::Extent2D, clip_corner_radius: f32, relief: (f32, f32)) -> [f32; crate::draw::WINDOW_INFO_BYTES / 4] {
+    crate::draw::window_info_data(extent.width, extent.height, clip_corner_radius, relief)
 }
 
 pub(crate) struct AllocatedBuffer {
@@ -474,12 +440,12 @@ pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
 /// so compile each shader once per process.
 pub(crate) fn shader2d_spirv() -> &'static [u32] {
     static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
-    SPIRV.get_or_init(|| compile_wgsl(include_str!("shader2d.wgsl")))
+    SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::SHADER2D))
 }
 
 pub(crate) fn glyph_spirv() -> &'static [u32] {
     static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
-    SPIRV.get_or_init(|| compile_wgsl(include_str!("glyph.wgsl")))
+    SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::GLYPH))
 }
 
 pub(crate) fn scene3d_spirv() -> &'static [u32] {
@@ -2436,6 +2402,31 @@ mod tests {
         assert!(!super::shader2d_spirv().is_empty());
     }
 
+    /// The WebGPU variants validate with no capabilities at all — WebGPU has
+    /// no push constants — and the 2D one carries its block as the uniform
+    /// the web renderer binds. naga does not see everything a browser's
+    /// compiler rejects (its derivative-uniformity analysis does not follow
+    /// calls), so the browser probe is the last word; this catches a
+    /// substitution that silently stopped applying.
+    #[test]
+    fn the_webgpu_shaders_validate_without_push_constants() {
+        let web2d = crate::draw::shaders::shader2d_for_webgpu();
+        assert!(!web2d.contains("var<push_constant>"));
+        assert!(web2d.contains("@group(1) @binding(0) var<uniform> rrect_clip: RRectClip;"));
+        for (name, src) in [("shader2d (web)", web2d.as_str()), ("glyph", crate::draw::shaders::GLYPH)] {
+            let module = naga::front::wgsl::parse_str(src).unwrap_or_else(|e| panic!("{name}: {}", e.emit_to_string(src)));
+            naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty())
+                .validate(&module)
+                .unwrap_or_else(|e| panic!("{name} does not validate for WebGPU: {e:?}"));
+        }
+        // And the block's size is what the renderers lay out.
+        let module = naga::front::wgsl::parse_str(&web2d).unwrap();
+        let block = module.types.iter().find(|(_, t)| t.name.as_deref() == Some("RRectClip")).expect("RRectClip").1;
+        let naga::TypeInner::Struct { span, .. } = block.inner else { panic!("RRectClip is a struct") };
+        assert_eq!(span as usize, crate::draw::PUSH_CONSTANT_FLOATS * 4);
+        assert!(span as usize <= crate::draw::shaders::WEBGPU_BLOCK_STRIDE);
+    }
+
     #[test]
     fn scene3d_compiles() {
         assert!(!super::scene3d_spirv().is_empty());
@@ -2457,7 +2448,7 @@ mod tests {
     /// shape here, so it measures the thing it is guarding.
     #[test]
     fn window_info_layout_matches_the_uniform_size() {
-        let src = include_str!("shader2d.wgsl");
+        let src = crate::draw::shaders::SHADER2D;
         let body = src
             .split_once("struct WindowInfo {")
             .expect("WindowInfo moved; this test scans for it")
diff --git a/src/vk/text.rs b/src/vk/text.rs
index eddbf99..79eded2 100644
--- a/src/vk/text.rs
+++ b/src/vk/text.rs
@@ -14,7 +14,6 @@
 //! stored white-with-alpha, color (emoji) glyphs as-is drawn with a white vertex
 //! color — glyph.wgsl multiplies either by the vertex color.
 
-use std::collections::HashMap;
 
 use ash::vk;
 use gpu_allocator::vulkan::{
@@ -22,80 +21,12 @@ use gpu_allocator::vulkan::{
 };
 use gpu_allocator::MemoryLocation;
 
-use cosmic_text::{CacheKey, SwashContent};
+
 use cosmic_text::{FontSystem, SwashCache};
 
 use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
 pub use crate::draw::TextSpan;
-
-const ATLAS_SIZE: u32 = 1024;
-const ATLAS_PAD: u32 = 1;
-
-#[repr(C)]
-#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-struct GlyphVertex {
-    position: [f32; 2],
-    uv: [f32; 2],
-    color: [f32; 4],
-    clip_circle: [f32; 3],
-    clip_extents: [f32; 2],
-}
-
-// See the matching block in `image.rs`: both pipelines feed the same glyph
-// shader (locations 0..=4), so both vertex structs must hold this exact layout.
-const _: () = {
-    assert!(std::mem::size_of::<GlyphVertex>() == 52);
-    assert!(std::mem::offset_of!(GlyphVertex, position) == 0);
-    assert!(std::mem::offset_of!(GlyphVertex, uv) == 8);
-    assert!(std::mem::offset_of!(GlyphVertex, color) == 16);
-    assert!(std::mem::offset_of!(GlyphVertex, clip_circle) == 32);
-    assert!(std::mem::offset_of!(GlyphVertex, clip_extents) == 44);
-};
-
-#[derive(Clone, Copy)]
-struct GlyphEntry {
-    /// Atlas texel rect.
-    u: u32,
-    v: u32,
-    w: u32,
-    h: u32,
-    /// Raster placement offsets (from swash).
-    left: i32,
-    top: i32,
-    is_color: bool,
-    /// Zero-sized raster (spaces): nothing to draw, but cached to skip re-rastering.
-    empty: bool,
-}
-
-struct Shelf {
-    cursor_x: u32,
-    cursor_y: u32,
-    row_height: u32,
-}
-
-impl Shelf {
-    fn new() -> Self {
-        Shelf { cursor_x: ATLAS_PAD, cursor_y: ATLAS_PAD, row_height: 0 }
-    }
-
-    fn insert(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
-        if w > ATLAS_SIZE - 2 * ATLAS_PAD || h > ATLAS_SIZE - 2 * ATLAS_PAD {
-            return None;
-        }
-        if self.cursor_x + w + ATLAS_PAD > ATLAS_SIZE {
-            self.cursor_x = ATLAS_PAD;
-            self.cursor_y += self.row_height + ATLAS_PAD;
-            self.row_height = 0;
-        }
-        if self.cursor_y + h + ATLAS_PAD > ATLAS_SIZE {
-            return None;
-        }
-        let pos = (self.cursor_x, self.cursor_y);
-        self.cursor_x += w + ATLAS_PAD;
-        self.row_height = self.row_height.max(h);
-        Some(pos)
-    }
-}
+use crate::draw::glyphs::{GlyphAtlas, GlyphVertex, ATLAS_SIZE};
 
 struct TextFrame {
     vertex: AllocatedBuffer,
@@ -117,15 +48,9 @@ pub(crate) struct TextStage {
     atlas_image: vk::Image,
     atlas_view: vk::ImageView,
     atlas_allocation: Option<Allocation>,
-    /// CPU mirror of the atlas (RGBA8, ATLAS_SIZE²).
-    atlas_cpu: Vec<u8>,
+    /// The atlas and glyph quads (CPU side, shared with every renderer).
+    atlas: GlyphAtlas,
     atlas_initialized: bool,
-    generation: u64,
-
-    glyphs: HashMap<CacheKey, GlyphEntry>,
-    shelf: Shelf,
-
-    pending_vertices: Vec<GlyphVertex>,
     frames: Vec<TextFrame>,
 }
 
@@ -391,80 +316,14 @@ impl TextStage {
                 atlas_image,
                 atlas_view,
                 atlas_allocation: Some(atlas_allocation),
-                atlas_cpu: vec![0u8; (ATLAS_SIZE * ATLAS_SIZE * 4) as usize],
+                atlas: GlyphAtlas::new(),
                 atlas_initialized: false,
-                generation: 1,
-                glyphs: HashMap::new(),
-                shelf: Shelf::new(),
-                pending_vertices: Vec::new(),
                 frames,
             }
         }
     }
 
-    /// Rasterize (on miss) and cache one glyph. Returns None when the atlas is full.
-    fn ensure_glyph(
-        &mut self,
-        font_system: &mut FontSystem,
-        swash_cache: &mut SwashCache,
-        key: CacheKey,
-    ) -> Option<GlyphEntry> {
-        if let Some(entry) = self.glyphs.get(&key) {
-            return Some(*entry);
-        }
-        let image = swash_cache.get_image_uncached(font_system, key)?;
-        let w = image.placement.width;
-        let h = image.placement.height;
-        if w == 0 || h == 0 || image.data.is_empty() {
-            let entry = GlyphEntry {
-                u: 0, v: 0, w: 0, h: 0, left: 0, top: 0, is_color: false, empty: true,
-            };
-            self.glyphs.insert(key, entry);
-            return Some(entry);
-        }
-        let (u, v) = self.shelf.insert(w, h)?;
-
-        let is_color = !matches!(image.content, SwashContent::Mask);
-        for row in 0..h {
-            for col in 0..w {
-                let dst = (((v + row) * ATLAS_SIZE + (u + col)) * 4) as usize;
-                let texel = match image.content {
-                    SwashContent::Mask => {
-                        let a = image.data[(row * w + col) as usize];
-                        [255, 255, 255, a]
-                    }
-                    // Color and SubpixelMask rasters are RGBA.
-                    _ => {
-                        let src = ((row * w + col) * 4) as usize;
-                        [
-                            image.data[src],
-                            image.data[src + 1],
-                            image.data[src + 2],
-                            image.data[src + 3],
-                        ]
-                    }
-                };
-                self.atlas_cpu[dst..dst + 4].copy_from_slice(&texel);
-            }
-        }
-        self.generation += 1;
-
-        let entry = GlyphEntry {
-            u,
-            v,
-            w,
-            h,
-            left: image.placement.left,
-            top: image.placement.top,
-            is_color,
-            empty: false,
-        };
-        self.glyphs.insert(key, entry);
-        Some(entry)
-    }
-
-    /// Build this frame's glyph vertices. Positions/bounds in physical pixels,
-    /// NDC computed against `extent` (wgpu convention; the shader flips for Vulkan).
+    /// Build this frame's glyph quads against `extent` (see [`GlyphAtlas::prepare`]).
     pub(crate) fn prepare(
         &mut self,
         font_system: &mut FontSystem,
@@ -472,128 +331,7 @@ impl TextStage {
         spans: &[TextSpan<'_>],
         extent: vk::Extent2D,
     ) {
-        self.pending_vertices.clear();
-        if !self.try_prepare(font_system, swash_cache, spans, extent) {
-            // Atlas full: clear and repack with only the glyphs this frame needs.
-            log::info!("glyph atlas full — clearing and repacking");
-            self.glyphs.clear();
-            self.shelf = Shelf::new();
-            self.atlas_cpu.fill(0);
-            self.generation += 1;
-            self.pending_vertices.clear();
-            if !self.try_prepare(font_system, swash_cache, spans, extent) {
-                log::error!("glyph atlas full even after repack; text truncated this frame");
-            }
-        }
-    }
-
-    fn try_prepare(
-        &mut self,
-        font_system: &mut FontSystem,
-        swash_cache: &mut SwashCache,
-        spans: &[TextSpan<'_>],
-        extent: vk::Extent2D,
-    ) -> bool {
-        let sw = extent.width as f32;
-        let sh = extent.height as f32;
-        for span in spans {
-            for run in span.buffer.layout_runs() {
-                let line_y = (run.line_y * span.scale).round() as i32;
-                for glyph in run.glyphs.iter() {
-                    let physical = glyph.physical((span.left, span.top), span.scale);
-                    let Some(entry) =
-                        self.ensure_glyph(font_system, swash_cache, physical.cache_key)
-                    else {
-                        // Distinguish "atlas full" (retryable) from "unrasterizable"
-                        // (skip): a missing swash image caches as empty above, so a
-                        // None here means the shelf rejected it.
-                        if swash_cache
-                            .get_image_uncached(font_system, physical.cache_key)
-                            .is_some()
-                        {
-                            return false;
-                        }
-                        continue;
-                    };
-                    if entry.empty {
-                        continue;
-                    }
-
-                    // glyphon's placement formula (kept verbatim), physical pixels.
-                    let mut x0 = (physical.x + entry.left) as f32;
-                    let mut y0 = (line_y + physical.y - entry.top) as f32;
-                    let mut x1 = x0 + entry.w as f32;
-                    let mut y1 = y0 + entry.h as f32;
-                    let mut u0 = entry.u as f32;
-                    let mut v0 = entry.v as f32;
-                    let mut u1 = u0 + entry.w as f32;
-                    let mut v1 = v0 + entry.h as f32;
-
-                    // CPU clip to span bounds, shrinking UVs proportionally.
-                    if let Some([bl, bt, br, bb]) = span.bounds {
-                        let (bl, bt, br, bb) = (bl as f32, bt as f32, br as f32, bb as f32);
-                        if x0 >= br || x1 <= bl || y0 >= bb || y1 <= bt {
-                            continue;
-                        }
-                        if x0 < bl {
-                            u0 += bl - x0;
-                            x0 = bl;
-                        }
-                        if x1 > br {
-                            u1 -= x1 - br;
-                            x1 = br;
-                        }
-                        if y0 < bt {
-                            v0 += bt - y0;
-                            y0 = bt;
-                        }
-                        if y1 > bb {
-                            v1 -= y1 - bb;
-                            y1 = bb;
-                        }
-                    }
-
-                    let color = if entry.is_color {
-                        [1.0, 1.0, 1.0, 1.0]
-                    } else if let Some(c) = glyph.color_opt {
-                        [
-                            c.r() as f32 / 255.0,
-                            c.g() as f32 / 255.0,
-                            c.b() as f32 / 255.0,
-                            c.a() as f32 / 255.0,
-                        ]
-                    } else {
-                        span.default_color
-                    };
-
-                    // Corner positions, optionally rotated about the span's center
-                    // (physical px) before the NDC mapping.
-                    let corners = match span.rotation {
-                        None => [[x0, y0], [x1, y0], [x0, y1], [x1, y1]],
-                        Some((angle, cx, cy)) => {
-                            let (sin_a, cos_a) = angle.sin_cos();
-                            let rot = |px: f32, py: f32| {
-                                let (dx, dy) = (px - cx, py - cy);
-                                [cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a]
-                            };
-                            [rot(x0, y0), rot(x1, y0), rot(x0, y1), rot(x1, y1)]
-                        }
-                    };
-                    let ndc = |p: [f32; 2]| {
-                        [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0]
-                    };
-                    let uv = |u: f32, v: f32| [u / ATLAS_SIZE as f32, v / ATLAS_SIZE as f32];
-                    let clip_circle = span.clip_circle;
-                    let clip_extents = span.clip_extents;
-                    let tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle, clip_extents };
-                    let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle, clip_extents };
-                    let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle, clip_extents };
-                    let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle, clip_extents };
-                    self.pending_vertices.extend([tl, tr, bl, tr, br, bl]);
-                }
-            }
-        }
-        true
+        self.atlas.prepare(font_system, swash_cache, spans, extent.width, extent.height);
     }
 
     /// Called after this frame's fence has been waited: copy the current text
@@ -610,7 +348,7 @@ impl TextStage {
     ) {
         let frame = &mut self.frames[frame_index];
 
-        let bytes: &[u8] = bytemuck::cast_slice(&self.pending_vertices);
+        let bytes: &[u8] = bytemuck::cast_slice(self.atlas.vertices());
         let needed = bytes.len() as vk::DeviceSize;
         if needed > frame.vertex.size {
             let mut old = std::mem::replace(&mut frame.vertex, AllocatedBuffer::null());
@@ -628,13 +366,14 @@ impl TextStage {
                 [..bytes.len()]
                 .copy_from_slice(bytes);
         }
-        frame.vertex_count = self.pending_vertices.len() as u32;
+        frame.vertex_count = self.atlas.vertices().len() as u32;
 
         let frame = &mut self.frames[frame_index];
-        if frame.uploaded_generation != self.generation {
+        if frame.uploaded_generation != self.atlas.generation() {
+            let pixels = self.atlas.pixels();
             frame.staging.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()
-                [..self.atlas_cpu.len()]
-                .copy_from_slice(&self.atlas_cpu);
+                [..pixels.len()]
+                .copy_from_slice(pixels);
         }
     }
 
@@ -642,10 +381,10 @@ impl TextStage {
     /// Must be called outside a render pass.
     pub(crate) fn record_upload(&mut self, device: &ash::Device, cmd: vk::CommandBuffer, frame_index: usize) {
         let frame = &mut self.frames[frame_index];
-        if frame.uploaded_generation == self.generation {
+        if frame.uploaded_generation == self.atlas.generation() {
             return;
         }
-        frame.uploaded_generation = self.generation;
+        frame.uploaded_generation = self.atlas.generation();
 
         let range = vk::ImageSubresourceRange::default()
             .aspect_mask(vk::ImageAspectFlags::COLOR)