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

commit46afc03acbffa9a54f735b54bd5f4feb4e73fd6e
parent0c58d47f3c
authorClaude <noreply@anthropic.com>
date2026-10-04 19:51
refactor(draw): what a renderer draws moves out of vk/

Batch2D, PlatePush, Frame2D, MAX_PLATE_FEATURES, the 32-float parameter
block layout (PUSH_CONSTANT_FLOATS, batch_push_constants), TextSpan,
ImageQuad, PixelFormat and the image-id queue (upload_rgba and its kin,
update_pixels, recycle_buffer, free_image, renderer_epoch) are plain
data and bookkeeping with nothing Vulkan in them. They lived in vk/
because it was the only renderer; backend::frame builds them for any
renderer, and the WebGPU one the browser port needs will draw from the
same types. src/draw/ holds them now.

The Vulkan side keeps what is Vulkan's: the 128-byte push-constant
assertion, PixelFormat::vk, and ImageStage, which drains the queue
through draw::images::take_pending and registers its table with
image_table_built (the renderer epoch). vk re-exports every moved item
at its old path, so cce_ui::vk::upload_rgba, vk::Frame2D and the rest
are unchanged for clients.

Two smaller paths stop going through the Wayland runner: widgets call
the shaping helpers at backend::text, heightfield takes DlBatch from
backend::tessellate, and the cursor an app returns is cursor-icon's
CursorIcon, depended on directly (smithay-client-toolkit re-exports the
same type, so nothing converts).

Verified: test results unchanged (523 pass, the same 2 pre-existing
failures, vk::image's tests and the plate probe on lavapipe among the
passes), the plate golden byte-identical, the demo identical to the
pixel over the 24 scripted steps (its two image views exercise the
moved queue).

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

 CLAUDE.md                             |   8 ++
 Cargo.lock                            |   1 +
 Cargo.toml                            |   4 +
 src/backend/app.rs                    |   3 +-
 src/backend/driver.rs                 |   2 +-
 src/backend/frame.rs                  |   2 +-
 src/backend/tessellate.rs             |  38 ++++----
 src/backend/text.rs                   |   2 +-
 src/draw/images.rs                    | 169 ++++++++++++++++++++++++++++++++++
 src/draw/mod.rs                       | 167 +++++++++++++++++++++++++++++++++
 src/icon.rs                           |   4 +-
 src/lib.rs                            |   5 +-
 src/scene/heightfield.rs              |   4 +-
 src/vk/image.rs                       | 160 ++------------------------------
 src/vk/renderer.rs                    | 124 +------------------------
 src/vk/text.rs                        |  28 +-----
 src/widget/container/parameters_bg.rs |   2 +-
 src/widget/core.rs                    |   4 +-
 src/widget/display/image_view.rs      |   4 +-
 src/widget/display/label.rs           |   2 +-
 src/widget/display/list_item.rs       |   2 +-
 src/widget/display/text_label.rs      |   2 +-
 src/widget/input/button.rs            |   6 +-
 src/widget/input/color_selector.rs    |   2 +-
 src/widget/input/dropdown.rs          |   2 +-
 src/widget/input/spinbox.rs           |   2 +-
 src/widget/input/text_box.rs          |  10 +-
 src/widget/shaping.rs                 |   8 +-
 28 files changed, 415 insertions(+), 352 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 07e0c1b..70231ad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1110,6 +1110,14 @@ cce-system-interface) to confirm behavior, not just the test suite.
   what a frame contains in `frame.rs` and a pacing change in `shell.rs`, never in the
   Wayland code. A second shell implements `Shell` and calls `Pacer::turn` from its own
   loop (an animation frame, a run-loop observer), sleeping or scheduling for the `Step`. `menu_popup.rs` and `dnd.rs` are Wayland-only.
+- `draw/` — what a renderer draws, with no renderer in it (since 2026-10-04): `Frame2D`,
+  `Batch2D`, `PlatePush` and `batch_push_constants` (the one layout of a batch's 32-float
+  parameter block — Vulkan pushes it, a renderer without push constants puts it in a
+  uniform), `TextSpan`, `ImageQuad`, and `draw::images`, the image-id queue
+  (`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.
 - `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/Cargo.lock b/Cargo.lock
index 8be2f74..27631e3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -359,6 +359,7 @@ dependencies = [
  "calloop-wayland-source",
  "cce-vault",
  "cosmic-text",
+ "cursor-icon",
  "glam",
  "gpu-allocator",
  "kdl",
diff --git a/Cargo.toml b/Cargo.toml
index ec760ab..99eff0b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,6 +41,10 @@ wayland-protocols = { version = "0.31", features = ["client", "unstable"] }
 wayland-backend = "0.3"
 wayland-scanner = "0.31"
 bitflags = "2"
+# The cursor names an app returns. smithay-client-toolkit re-exports this same
+# crate's type, so the Wayland shell hands it over unconverted; depending on it
+# directly keeps the client contract free of the toolkit.
+cursor-icon = "1.2"
 xkeysym = "0.2"
 resvg = "0.41.0"
 # App/tray icon themes are overwhelmingly PNG; resvg covers only the SVG half.
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 2720ad7..2257387 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -8,7 +8,8 @@ use wayland_client::QueueHandle;
 use cosmic_text::FontSystem;
 use crate::widget::{MouseButton, ElementState, MouseScrollDelta, KeyEvent};
 use crate::vk::VkRenderer;
-use super::window_runner::{EngineState, PointerCursorIcon as CursorIcon};
+use super::window_runner::EngineState;
+use cursor_icon::CursorIcon;
 use super::tessellate::Vertex;
 
 #[derive(Debug, Clone)]
diff --git a/src/backend/driver.rs b/src/backend/driver.rs
index f213d3a..cf9074b 100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@ -16,7 +16,7 @@
 use std::time::Instant;
 
 use super::app::{Application, LogicalPosition, LogicalSize};
-use super::window_runner::PointerCursorIcon as CursorIcon;
+use cursor_icon::CursorIcon;
 use crate::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, Position};
 
 /// A key held down, for the runner's own key repeat.
diff --git a/src/backend/frame.rs b/src/backend/frame.rs
index 2dd9359..d0dd673 100644
--- a/src/backend/frame.rs
+++ b/src/backend/frame.rs
@@ -17,7 +17,7 @@ use cosmic_text::FontSystem;
 use super::app::{Application, LogicalSize};
 use super::tessellate::{quad_vertices, tessellate_display_list, DlBatch, Vertex};
 use super::text::{collect_dl_text, dl_text_spans, TextBounds};
-use crate::vk::{Batch2D, Frame2D, ImageQuad, TextSpan};
+use crate::draw::{Batch2D, Frame2D, ImageQuad, TextSpan};
 use crate::widget::TextItem;
 
 /// One frame, built and owned: the renderer's [`Frame2D`] is a borrow of it
diff --git a/src/backend/tessellate.rs b/src/backend/tessellate.rs
index e887492..f3ddac5 100644
--- a/src/backend/tessellate.rs
+++ b/src/backend/tessellate.rs
@@ -5,7 +5,7 @@
 //! `window_runner` so another shell can share it.
 
 use crate::widget::WidgetHost;
-use crate::vk::Batch2D;
+use crate::draw::Batch2D;
 
 /// A droplet spec resolved against a concrete rect: the push-constant fields
 /// that define its SILHOUETTE, in logical px.
@@ -1276,8 +1276,8 @@ pub struct DlBatch {
     pub start: u32,
     pub end: u32,
     /// When set, this batch is one SDF-lit plate cover quad (see
-    /// [`crate::vk::PlatePush`]; already in physical px). Never merged.
-    pub plate: Option<crate::vk::PlatePush>,
+    /// [`crate::draw::PlatePush`]; already in physical px). Never merged.
+    pub plate: Option<crate::draw::PlatePush>,
     /// A blur-behind plate (negative-alpha color): before drawing this batch
     /// the renderer snapshots the swapchain-so-far into its snapshot image, so
     /// the blur samples everything painted beneath the plate — not just the 3D
@@ -1462,7 +1462,7 @@ pub fn tessellate_display_list(
 
     for item in &dl.items {
         let mut start = verts.len() as u32;
-        let mut plate: Option<crate::vk::PlatePush> = None;
+        let mut plate: Option<crate::draw::PlatePush> = None;
         // A frosted flat fill promoted to a zero-depth plate batch (below):
         // it carries a recipe like any plate, but it is ordinary geometry to
         // the carve grouping — it opens no host and closes the open ones.
@@ -1701,7 +1701,7 @@ pub fn tessellate_display_list(
                 // A tinted carve also never groups: a CSG feature is geometry only,
                 // so the tint could only land on the whole plate's specular.
                 let full_ring = *edges == (true, true, true, true);
-                let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::vk::MAX_PLATE_FEATURES {
+                let host_plate = if mode < 3.5 && full_ring && tint.is_none() && features.len() < crate::draw::MAX_PLATE_FEATURES {
                     // The carve's shaded region, for the occlusion test below
                     // (the overlay path's cover-quad inflation).
                     let infl = *depth * 0.5 + 2.0;
@@ -1755,7 +1755,7 @@ pub fn tessellate_display_list(
                         let roll = batches[bi].plate.as_ref().map_or(0.0, |p| p.light[3]) / scale;
                         let later: Vec<crate::scene::layout::Rect> =
                             plate_stack[si + 1..].iter().map(|&(_, r)| r).collect();
-                        let budget_full = features.len() >= crate::vk::MAX_PLATE_FEATURES;
+                        let budget_full = features.len() >= crate::draw::MAX_PLATE_FEATURES;
                         if let Some(why) =
                             near_roll_fallback_reason(rect, *depth, &prect, roll, &later, budget_full)
                         {
@@ -1807,7 +1807,7 @@ pub fn tessellate_display_list(
                                 format!("edge-suppressed {edges:?} — the extended wall would smear across the host")
                             } else if tint.is_some() {
                                 "tinted — a CSG feature is geometry only, it carries no color".into()
-                            } else if features.len() >= crate::vk::MAX_PLATE_FEATURES {
+                            } else if features.len() >= crate::draw::MAX_PLATE_FEATURES {
                                 format!("feature budget full ({} used)", features.len())
                             } else if enclosing.is_empty() {
                                 format!("no enclosing plate ({} open)", plate_stack.len())
@@ -2093,7 +2093,7 @@ pub fn tessellate_display_list(
                 // the disc by 1px for the shader's silhouette anti-aliasing.
                 let d = *radius + 1.0;
                 verts.extend(quad_vertices(cx - d, cy - d, 2.0 * d, 2.0 * d, sw, sh, color));
-                plate = Some(crate::vk::PlatePush {
+                plate = Some(crate::draw::PlatePush {
                     // Center + radius in physical px; the SDF box machinery is
                     // unused in this mode, so .w is free.
                     rect: [cx * scale, cy * scale, radius * scale, 0.0],
@@ -2120,7 +2120,7 @@ pub fn tessellate_display_list(
                 // outside the silhouette.
                 let g = droplet_geom(rect, spec);
                 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, color));
-                plate = Some(crate::vk::PlatePush {
+                plate = Some(crate::draw::PlatePush {
                     rect: [
                         (rect.x + rect.width * 0.5) * scale,
                         (rect.y + rect.height * 0.5) * scale,
@@ -2160,7 +2160,7 @@ pub fn tessellate_display_list(
                     rect.height + sh_reach,
                     sw, sh, color,
                 ));
-                plate = Some(crate::vk::PlatePush {
+                plate = Some(crate::draw::PlatePush {
                     rect: [
                         (rect.x + rect.width * 0.5) * scale,
                         (rect.y + rect.height * 0.5) * scale,
@@ -2221,7 +2221,7 @@ pub fn tessellate_display_list(
                 let m = *depth * 0.5 + 2.0;
                 let r = *radius + m;
                 verts.extend(quad_vertices(cx - r, cy - r, 2.0 * r, 2.0 * r, sw, sh, [0.0; 4]));
-                plate = Some(crate::vk::PlatePush {
+                plate = Some(crate::draw::PlatePush {
                     rect: [cx * scale, cy * scale, *radius * scale, 0.0],
                     radii: [*a0, 0.0, 0.0, 0.0],
                     light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
@@ -2251,7 +2251,7 @@ pub fn tessellate_display_list(
                 let (dx, dy) = (b.0 - a.0, b.1 - a.1);
                 let len = (dx * dx + dy * dy).sqrt();
                 let n = if len > 1e-4 { (-dy / len, dx / len) } else { (1.0, 0.0) };
-                plate = Some(crate::vk::PlatePush {
+                plate = Some(crate::draw::PlatePush {
                     // Centre + slab half-width in physical px; .w unused.
                     rect: [
                         (a.0 + b.0) * 0.5 * scale,
@@ -2333,7 +2333,7 @@ pub fn tessellate_display_list(
                 // fades against a host — its own rect bounds it).
                 let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
                 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
-                plate = Some(crate::vk::PlatePush {
+                plate = Some(crate::draw::PlatePush {
                     rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
                     radii: [*radius * scale; 4],
                     light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
@@ -2351,7 +2351,7 @@ pub fn tessellate_display_list(
                 // carried but unread.
                 let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
                 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
-                plate = Some(crate::vk::PlatePush {
+                plate = Some(crate::draw::PlatePush {
                     rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
                     radii: [*radius * scale; 4],
                     light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
@@ -2371,7 +2371,7 @@ pub fn tessellate_display_list(
                 // and the shader takes the nearest one per pixel. The cover
                 // quad is the union's bounding box grown by the wall's reach;
                 // off-shape corners of it sit at the plateau and shade nothing.
-                let budget = crate::vk::MAX_PLATE_FEATURES.saturating_sub(features.len());
+                let budget = crate::draw::MAX_PLATE_FEATURES.saturating_sub(features.len());
                 let take = boxes.len().min(budget);
                 if take < boxes.len() && plate_debug() {
                     eprintln!(
@@ -2417,7 +2417,7 @@ pub fn tessellate_display_list(
                 // not append past it (its features would no longer be
                 // contiguous), so it is closed here like any other appender.
                 last_feature_plate = None;
-                plate = Some(crate::vk::PlatePush {
+                plate = Some(crate::draw::PlatePush {
                     rect: [
                         (x0 + x1) * 0.5 * scale,
                         (y0 + y1) * 0.5 * scale,
@@ -2537,7 +2537,7 @@ fn flat_frost_push(
     scale: f32,
     light: [f32; 3],
     material: [f32; 4],
-) -> crate::vk::PlatePush {
+) -> crate::draw::PlatePush {
     let mut p = plate_push_raised(rect, radii, 0.0, scale, light, material, false, None);
     let [fz, fw] = crate::scene::material::Material::from_fill(fill).frost.pack(scale);
     p.host[2] = fz;
@@ -2554,7 +2554,7 @@ fn plate_push_raised(
     material: [f32; 4],
     scale_corners: bool,
     shape: Option<f32>,
-) -> crate::vk::PlatePush {
+) -> crate::draw::PlatePush {
     // Floored: a rect already shrunk past its padding (a window dragged
     // below what its layout can hold) has a NEGATIVE extent here, and
     // `clamp(0.0, cap)` with a negative cap is a panic, not a zero radius.
@@ -2570,7 +2570,7 @@ fn plate_push_raised(
     // the nominal-radius squircles of the widget silhouettes around them, and
     // at their few-px roll widths the offset crease is subpixel.
     let rscale = if scale_corners { crate::layout::corner_span_factor_for(shape) } else { 1.0 };
-    crate::vk::PlatePush {
+    crate::draw::PlatePush {
         rect: [
             (rect.x + rect.width * 0.5) * scale,
             (rect.y + rect.height * 0.5) * scale,
diff --git a/src/backend/text.rs b/src/backend/text.rs
index 9d98bda..e57aad0 100644
--- a/src/backend/text.rs
+++ b/src/backend/text.rs
@@ -5,7 +5,7 @@
 
 use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
 use crate::widget::TextItem;
-use crate::vk::TextSpan;
+use crate::draw::TextSpan;
 
 #[derive(Hash, PartialEq, Eq, Clone)]
 struct BufferCacheKey {
diff --git a/src/draw/images.rs b/src/draw/images.rs
new file mode 100644
index 0000000..96f4ed3
--- /dev/null
+++ b/src/draw/images.rs
@@ -0,0 +1,169 @@
+//! The image-id queue. [`upload_rgba`] and its kin queue pixels from any code
+//! and hand back an id that is usable in [`ImageQuad`]s at once; whichever
+//! renderer the process has drains the queue at its next frame
+//! ([`take_pending`]) and frees what [`free_image`] queued. See `vk::image`
+//! for the Vulkan side and the reasoning behind the streaming path.
+
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::sync::Mutex;
+
+/// One image draw in a 2D frame.
+pub struct ImageQuad {
+    /// Id from [`upload_rgba`].
+    pub image: u32,
+    /// Destination rect (x, y, w, h) in physical pixels.
+    pub rect: (f32, f32, f32, f32),
+    pub alpha: f32,
+    /// Draw order: this quad renders before the vertex at this index of
+    /// `Frame2D::verts` (so vertices below it stay below, later ones cover it).
+    /// Use `u32::MAX` to draw on top of all display-list geometry.
+    pub z_before: u32,
+    /// Optional scissor (x, y, w, h) in physical pixels.
+    pub clip: Option<(u32, u32, u32, u32)>,
+}
+
+/// Byte order of the pixels handed over.
+///
+/// Both are sRGB-encoded 8-bit-per-channel; the difference is only which
+/// channel comes first in memory, and the hardware handles it on sample. A
+/// caller whose source is already BGRA (Wayland's and WebKit's usual order)
+/// should say so rather than swizzle on the CPU: at a fullscreen 3840x2400
+/// that swizzle measured 7.4 ms per frame, which is most of a frame budget
+/// spent rearranging bytes the sampler can read either way.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum PixelFormat {
+    Rgba,
+    Bgra,
+}
+
+/// One queued change to the image table, drained by the renderer.
+pub enum Pending {
+    Upload { id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat, mips: bool },
+    /// Replace the contents of an image that already exists, keeping its
+    /// id, its `VkImage` and its descriptor set.
+    Update { id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat },
+    Free { id: u32 },
+}
+
+static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
+static NEXT_ID: AtomicU32 = AtomicU32::new(1);
+/// How many image tables have been built in this process. See
+/// [`renderer_epoch`].
+static STAGES_BUILT: AtomicU32 = AtomicU32::new(0);
+/// Pixel buffers the renderer has finished with, waiting to be refilled.
+/// Bounded: a streaming caller needs one or two in flight, and holding more
+/// frame-sized buffers than that is just memory.
+static RECYCLED: Mutex<Vec<Vec<u8>>> = Mutex::new(Vec::new());
+const MAX_RECYCLED: usize = 3;
+
+/// Queue an RGBA8 image for upload; the id is usable in [`ImageQuad`]s right
+/// away (draws before the upload lands are skipped, not errors).
+pub fn upload_rgba(pixels: Vec<u8>, width: u32, height: u32) -> u32 {
+    upload_pixels(pixels, width, height, PixelFormat::Rgba)
+}
+
+/// Queue an image whose bytes are in `format`. [`upload_rgba`] is this with
+/// [`PixelFormat::Rgba`].
+pub fn upload_pixels(pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat) -> u32 {
+    queue_upload(pixels, width, height, format, false)
+}
+
+/// [`upload_rgba`] for an image that will be drawn much smaller than it is,
+/// or at a slant: the image gets a full mip chain, built on the GPU, and is
+/// sampled from the level that matches the size it is drawn at. An update
+/// ([`update_pixels`]) rebuilds the chain.
+///
+/// On a device that cannot build one by blitting the image is uploaded
+/// without, as [`upload_rgba`] would have.
+pub fn upload_rgba_mipmapped(pixels: Vec<u8>, width: u32, height: u32) -> u32 {
+    queue_upload(pixels, width, height, PixelFormat::Rgba, true)
+}
+
+fn queue_upload(pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat, mips: bool) -> u32 {
+    assert_eq!(pixels.len(), (width * height * 4) as usize, "8888 size mismatch");
+    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
+    PENDING.lock().unwrap().push(Pending::Upload { id, pixels, width, height, format, mips });
+    id
+}
+
+/// Replace what `id` holds, keeping the image itself.
+///
+/// For a caller that redraws the same picture over and over — a page, a video
+/// frame, a live preview. Nothing is allocated, no descriptor is rewritten and
+/// no image is destroyed, so none of the per-frame `device_wait_idle` that
+/// freeing one costs. The size or format changing is allowed and simply falls
+/// back to a fresh image under the same id, which is what a window resize
+/// does.
+///
+/// An `id` that does not exist yet is treated as an upload, so a caller can
+/// take an id from [`upload_pixels`] and update it from the next frame on
+/// without sequencing the two.
+pub fn update_pixels(id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat) {
+    assert_eq!(pixels.len(), (width * height * 4) as usize, "8888 size mismatch");
+    PENDING.lock().unwrap().push(Pending::Update { id, pixels, width, height, format });
+}
+
+/// A pixel buffer to fill, reusing one the renderer has finished with when
+/// there is one of at least `len` bytes.
+///
+/// The returned buffer is exactly `len` long and its contents are unspecified
+/// — a caller is expected to overwrite every byte, which a full-frame readback
+/// does by definition. Allocating a fresh frame-sized `Vec` instead measured
+/// 7.4 ms against 2.9 ms at 3840x2400: the cost is not the copy, it is the
+/// zeroing and the page faults on newly mapped memory.
+pub fn recycle_buffer(len: usize) -> Vec<u8> {
+    let mut pool = RECYCLED.lock().unwrap();
+    if let Some(index) = pool.iter().position(|b| b.capacity() >= len) {
+        let mut buf = pool.swap_remove(index);
+        buf.clear();
+        buf.resize(len, 0);
+        return buf;
+    }
+    vec![0u8; len]
+}
+
+/// Hand a finished buffer back to the pool (the renderer, once it has
+/// uploaded what a [`Pending`] carried).
+pub fn retire_buffer(mut buf: Vec<u8>) {
+    let mut pool = RECYCLED.lock().unwrap();
+    if pool.len() < MAX_RECYCLED {
+        buf.clear();
+        pool.push(buf);
+    }
+}
+
+/// Which renderer's image table the ids handed out right now belong to.
+///
+/// `0` until the first renderer exists, and again for the whole life of that
+/// first renderer: uploads queued before it was built (from `Application::new`
+/// and from anything the app did on the way to its first frame) are drained
+/// into it, so they are that epoch's images, not a previous one's.
+/// Every later renderer — `window_runner` builds one per session, and a lost
+/// Wayland transport starts a new session around the same `Application` —
+/// counts as the next epoch.
+///
+/// This is what lets a long-lived cache of image ids notice that its ids have
+/// stopped naming anything. It is the cheap half of the contract; the other
+/// half is the app's, because only the app knows how to produce the pixels
+/// again (see [`Application::renderer_init`], and `upload_icon` for the
+/// toolkit's own use of this).
+///
+/// [`Application::renderer_init`]: crate::engine::Application::renderer_init
+pub fn renderer_epoch() -> u32 {
+    STAGES_BUILT.load(Ordering::Relaxed).saturating_sub(1)
+}
+
+/// Queue an image's GPU resources for destruction.
+pub fn free_image(id: u32) {
+    PENDING.lock().unwrap().push(Pending::Free { id });
+}
+
+/// Everything queued since the last call, in order. The renderer's to drain.
+pub fn take_pending() -> Vec<Pending> {
+    std::mem::take(&mut *PENDING.lock().unwrap())
+}
+
+/// A renderer built its image table: from here on [`renderer_epoch`] names it.
+pub fn image_table_built() {
+    STAGES_BUILT.fetch_add(1, Ordering::Relaxed);
+}
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
new file mode 100644
index 0000000..8bf7990
--- /dev/null
+++ b/src/draw/mod.rs
@@ -0,0 +1,167 @@
+//! What a renderer draws, with no renderer in it: the frame
+//! ([`Frame2D`] and its [`Batch2D`]s, [`PlatePush`] parameter blocks and the
+//! one way to lay a batch's block out, [`batch_push_constants`]), text runs
+//! ([`TextSpan`]), image draws ([`ImageQuad`]) and the image-id queue apps
+//! upload through ([`images`]).
+//!
+//! These lived in `vk/` because the Vulkan renderer was the only one. They
+//! are plain data, and `backend::frame` builds them for any renderer — the
+//! Vulkan one on Linux, a WebGPU one in the browser — so they live here, and
+//! `vk` re-exports every one at its old path.
+
+use crate::backend::tessellate::Vertex;
+use cosmic_text::Buffer as TextBuffer;
+
+pub mod images;
+
+pub use images::{
+    free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
+    upload_rgba_mipmapped, ImageQuad, PixelFormat,
+};
+
+/// One scissored draw range of a 2D frame. `scissor` is (x, y, w, h) in
+/// physical pixels; None draws with the full-surface scissor. `clip_rrect` is an
+/// optional rounded-rect clip `[cx, cy, bx, by, r]` (center, SDF half-extents, corner
+/// radius; physical px) applied via push constants — fragments outside it discard, so a
+/// plate's children cut off at its rounded corners.
+pub struct Batch2D {
+    pub scissor: Option<(u32, u32, u32, u32)>,
+    pub clip_rrect: Option<[f32; 5]>,
+    pub start: u32,
+    pub end: u32,
+    /// When set, this batch is a single SDF-lit plate cover quad: the params go
+    /// out as push constants and shader2d's plate branch lights it per pixel.
+    pub plate: Option<PlatePush>,
+    /// A blur-behind plate (negative-alpha color): the renderer suspends the UI
+    /// pass, copies the swapchain-so-far into its snapshot image, and resumes —
+    /// so the plate's blur samples everything painted beneath it (background,
+    /// widgets, wires), not just the 3D scene backdrop.
+    pub blur_behind: bool,
+}
+
+/// Floats in the fragment push-constant block: the rounded-rect clip (`rect0`,
+/// `rect1` — 6 clip/flag floats plus the plate mode and corner shape) followed
+/// by [`PlatePush`]'s six vec4s. Field for field, this is shader2d's `RRectClip`.
+pub const PUSH_CONSTANT_FLOATS: usize = 32;
+
+/// The fragment push-constant block for one batch: its rounded-rect clip,
+/// and its plate block when it is a plate cover quad. `feature_base` is the
+/// frame slot's first entry in the feature UBO, added to a plate's (or a
+/// union carve's) feature offset. Shared by every renderer and the offscreen
+/// test harness (`vk::plate_probe`), so none can push a different block. (Vulkan
+/// carries it as push constants; a renderer without them puts it in a uniform.)
+pub fn batch_push_constants(batch: &Batch2D, clip_shape: f32, feature_base: usize) -> [f32; PUSH_CONSTANT_FLOATS] {
+    let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
+    let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
+    let mut pc = [0.0f32; PUSH_CONSTANT_FLOATS];
+    pc[..5].copy_from_slice(&rr);
+    pc[5] = enabled;
+    pc[7] = clip_shape;
+    if let Some(p) = &batch.plate {
+        pc[6] = p.mode;
+        pc[7] = p.shape;
+        pc[8..12].copy_from_slice(&p.rect);
+        pc[12..16].copy_from_slice(&p.radii);
+        pc[16..20].copy_from_slice(&p.light);
+        pc[20..24].copy_from_slice(&p.material);
+        pc[24..28].copy_from_slice(&p.host);
+        pc[28..32].copy_from_slice(&p.specular_tint);
+        if p.mode == 1.0 || p.mode == 14.0 {
+            // Rebase the feature offset onto this frame's UBO slot (a plate's
+            // CSG carves, or a union carve's boxes).
+            pc[24] += feature_base as f32;
+        }
+    }
+    pc
+}
+
+/// Push-constant block for one SDF-lit plate batch (physical px throughout).
+/// Mirrors the `p_*` fields of shader2d's `RRectClip`.
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub struct PlatePush {
+    /// SDF box: center + half-extents. May extend past the cover quad — that is
+    /// how a recess suppresses a wall.
+    pub rect: [f32; 4],
+    /// Per-corner radii [tl, tr, br, bl].
+    pub radii: [f32; 4],
+    /// xyz = unit vector toward the light (+z out of the screen), w = roll width px.
+    pub light: [f32; 4],
+    /// [shading strength, specular strength, shininess, curvature/AO strength].
+    pub material: [f32; 4],
+    /// Mode 1: `[feature offset, feature count, frost z, frost w]` — xy into
+    /// the frame's `plate_features`, the carves CSG'd out of this plate (the
+    /// renderer adds the frame slot's base offset at record time); zw the
+    /// plate's frost recipe, `scene::material::Frost::pack` (compression and
+    /// refraction packed in z, the blur sigma in physical px in w). Mode 14 uses the same
+    /// `[offset, count]` for the union's boxes. Mode 2: the host-plate box
+    /// (center + half-extents) a free recess fades out against; far-away sides
+    /// (±1e5) disable the fade.
+    pub host: [f32; 4],
+    /// RGB multiplies the roll's specular color (w unused). Neutral white
+    /// normally; the focused-pane bevel carries the highlight color here.
+    pub specular_tint: [f32; 4],
+    /// 1.0 = raised lit plate, 2.0 = recess overlay, 3.0 = boss, 4.0 = ridge,
+    /// 5.0 = sphere, 6.0/7.0 = concave fillet (recessed/raised), 8.0 = groove
+    /// (slab carve about a line: `rect` = [cx, cy, half-width, _], `radii.xy` =
+    /// the line's unit normal, `host` = the surface it is engraved into),
+    /// 9.0 = trough, 10.0 = droplet (`radii` = [sag, belly r, belly half-w,
+    /// blend k] px, `host` = [sheet corner r px, clarity, dome amplitude,
+    /// attach r px], `material.w` = fresnel rim, `specular_tint` = [core
+    /// density, _, _, bottom-bow rise px] — droplet glints are always white,
+    /// so the tint RGB is repurposed; see shader2d's MODE_DROPLET).
+    pub mode: f32,
+    /// Corner shape exponent: 2.0 = circular arcs, > 2 = superellipse
+    /// (continuous-curvature) corners — see shader2d's `plate_sdf_grad`.
+    pub shape: f32,
+}
+
+/// A full 2D frame: the display-list vertices (optionally split into scissored
+/// batches), overlay vertices drawn after text, and the clear color (linear;
+/// only used on frames without a backdrop copy).
+pub struct Frame2D<'a> {
+    pub verts: &'a [Vertex],
+    pub batches: &'a [Batch2D],
+    pub overlay_verts: &'a [Vertex],
+    /// User images drawn interleaved with `verts` by each quad's `z_before`.
+    pub images: &'a [ImageQuad],
+    /// Carves CSG'd into this frame's SDF-lit plates, 12 floats each (rect
+    /// center+half-extents, per-corner radii, [width px, depth px, 0, 0]).
+    /// Plate batches reference them by offset+count in `PlatePush::host`.
+    pub plate_features: &'a [[f32; 12]],
+    pub clear_color: [f32; 4],
+    /// The only part of the surface that differs from the previous frame,
+    /// (x, y, w, h) in physical pixels; None = all of it. With a rect the
+    /// renderer keeps the pixels outside it (Vulkan's `ImageAge`) and tells the
+    /// compositor that only the rect changed. The caller vouches for it: a
+    /// pixel that changed outside the rect stays as it was.
+    pub damage: Option<(u32, u32, u32, u32)>,
+}
+
+/// Max plate-carve features per frame; the shader's UBO holds one slot of this
+/// size per frame in flight.
+pub const MAX_PLATE_FEATURES: usize = 64;
+
+/// One shaped text run to draw. `left`/`top` are physical pixels and `scale`
+/// multiplies the shaped (logical) glyph positions — the same contract as
+/// the old glyphon::TextArea, where callers pass `label.x * scale`.
+pub struct TextSpan<'a> {
+    pub buffer: &'a TextBuffer,
+    pub left: f32,
+    pub top: f32,
+    pub scale: f32,
+    /// Physical-pixel clip rect (left, top, right, bottom); None = whole surface.
+    pub bounds: Option<[i32; 4]>,
+    /// 0..=1 sRGB + alpha, applied to glyphs without their own color.
+    pub default_color: [f32; 4],
+    /// Rotate the span's glyph quads by (radians, center_x, center_y) in
+    /// physical pixels — the circular network pane's curved rim labels.
+    pub rotation: Option<(f32, f32, f32)>,
+    /// Fragment circle clip (center_x, center_y, radius) in physical pixels;
+    /// zero radius disables (matches shader.wgsl's clip_circle).
+    pub clip_circle: [f32; 3],
+    /// Rounded-rect clip half-extents (physical px). Zero keeps `clip_circle` a plain
+    /// circle; non-zero reinterprets it as a rounded-rect SDF clip — center
+    /// `clip_circle.xy`, corner radius `clip_circle.z`, inner box half-size
+    /// `clip_extents` — so plate children (labels included) cut off at rounded corners.
+    pub clip_extents: [f32; 2],
+}
diff --git a/src/icon.rs b/src/icon.rs
index 6653175..6f9f51b 100644
--- a/src/icon.rs
+++ b/src/icon.rs
@@ -126,7 +126,7 @@ pub fn lookup_in(name: &str, contexts: &[&str]) -> Option<PathBuf> {
 /// still skips the disk read and the rasterizer, which is where the time goes.
 pub fn upload_themed(name: &str, px: u32) -> Option<(u32, u32, u32)> {
     let (pixels, w, h) = decode(name, px)?;
-    Some((crate::vk::upload_rgba(pixels, w, h), w, h))
+    Some((crate::draw::upload_rgba(pixels, w, h), w, h))
 }
 
 /// [`upload_themed`]'s cached half: name → straight RGBA8 pixels + dimensions.
@@ -159,7 +159,7 @@ fn decode(name: &str, px: u32) -> Option<(Vec<u8>, u32, u32)> {
 }
 
 /// Decode a PNG to straight (un-premultiplied) RGBA8, the layout
-/// [`crate::vk::upload_rgba`] takes. `EXPAND` folds palette, sub-byte grayscale
+/// [`crate::draw::upload_rgba`] takes. `EXPAND` folds palette, sub-byte grayscale
 /// and `tRNS` into plain channels, which leaves only the four color types below;
 /// 16-bit samples are truncated to their high byte.
 fn decode_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
diff --git a/src/lib.rs b/src/lib.rs
index 6c60d1a..43d8a5a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -12,6 +12,7 @@ pub mod scale;
 pub mod units;
 pub mod backend;
 pub mod context;
+pub mod draw;
 pub mod scene;
 pub mod file_dialog;
 pub mod icon;
@@ -99,7 +100,7 @@ pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
     /// The cached ids, and the renderer epoch they were uploaded to.
     static CACHE: Mutex<Option<(u32, HashMap<(String, u32), Option<(u32, u32, u32)>>)>> =
         Mutex::new(None);
-    let epoch = crate::vk::renderer_epoch();
+    let epoch = crate::draw::renderer_epoch();
     let key = (name.to_string(), px);
     let mut guard = CACHE.lock().unwrap();
     let (cached_epoch, cache) = guard.get_or_insert_with(|| (epoch, HashMap::new()));
@@ -116,7 +117,7 @@ pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
         let path = format!("{}/{name}.svg", icons_dir());
         let data = std::fs::read(&path).ok()?;
         let (rgba, w, h) = rasterize_svg(&data, px)?;
-        Some((crate::vk::upload_rgba(rgba, w, h), w, h))
+        Some((crate::draw::upload_rgba(rgba, w, h), w, h))
     })();
     cache.insert(key, loaded);
     loaded
diff --git a/src/scene/heightfield.rs b/src/scene/heightfield.rs
index fba5a8a..e4216b1 100644
--- a/src/scene/heightfield.rs
+++ b/src/scene/heightfield.rs
@@ -31,7 +31,7 @@ use std::path::{Path, PathBuf};
 use std::sync::atomic::{AtomicU32, Ordering};
 use std::sync::Mutex;
 
-use crate::backend::window_runner::DlBatch;
+use crate::backend::tessellate::DlBatch;
 use crate::scene::relief_shade::{RECESS_DEPTH, ROLL_CUT};
 use crate::units::MetricSource;
 
@@ -502,7 +502,7 @@ pub fn export_png(hf: &HeightField, path: &Path, mm_per_sample: Option<f32>) ->
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::vk::PlatePush;
+    use crate::draw::PlatePush;
 
     fn plate(rect: [f32; 4], t: f32, host: [f32; 4]) -> DlBatch {
         DlBatch {
diff --git a/src/vk/image.rs b/src/vk/image.rs
index d228362..bc34f22 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -32,43 +32,17 @@
 //! are drawn at their own size.
 
 use std::collections::HashMap;
-use std::sync::atomic::{AtomicU32, Ordering};
-use std::sync::Mutex;
 
 use ash::vk;
 use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
 use gpu_allocator::MemoryLocation;
 
 use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
-
-/// One image draw in a 2D frame.
-pub struct ImageQuad {
-    /// Id from [`upload_rgba`].
-    pub image: u32,
-    /// Destination rect (x, y, w, h) in physical pixels.
-    pub rect: (f32, f32, f32, f32),
-    pub alpha: f32,
-    /// Draw order: this quad renders before the vertex at this index of
-    /// `Frame2D::verts` (so vertices below it stay below, later ones cover it).
-    /// Use `u32::MAX` to draw on top of all display-list geometry.
-    pub z_before: u32,
-    /// Optional scissor (x, y, w, h) in physical pixels.
-    pub clip: Option<(u32, u32, u32, u32)>,
-}
-
-/// Byte order of the pixels handed over.
-///
-/// Both are sRGB-encoded 8-bit-per-channel; the difference is only which
-/// channel comes first in memory, and the hardware handles it on sample. A
-/// caller whose source is already BGRA (Wayland's and WebKit's usual order)
-/// should say so rather than swizzle on the CPU: at a fullscreen 3840x2400
-/// that swizzle measured 7.4 ms per frame, which is most of a frame budget
-/// spent rearranging bytes the sampler can read either way.
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
-pub enum PixelFormat {
-    Rgba,
-    Bgra,
-}
+use crate::draw::images::{image_table_built, retire_buffer, take_pending, Pending};
+pub use crate::draw::images::{
+    free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba,
+    upload_rgba_mipmapped, ImageQuad, PixelFormat,
+};
 
 impl PixelFormat {
     fn vk(self) -> vk::Format {
@@ -80,132 +54,12 @@ impl PixelFormat {
     }
 }
 
-enum Pending {
-    Upload { id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat, mips: bool },
-    /// Replace the contents of an image that already exists, keeping its
-    /// id, its `VkImage` and its descriptor set.
-    Update { id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat },
-    Free { id: u32 },
-}
-
-static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
-static NEXT_ID: AtomicU32 = AtomicU32::new(1);
-/// How many image tables have been built in this process. See
-/// [`renderer_epoch`].
-static STAGES_BUILT: AtomicU32 = AtomicU32::new(0);
-/// Pixel buffers the renderer has finished with, waiting to be refilled.
-/// Bounded: a streaming caller needs one or two in flight, and holding more
-/// frame-sized buffers than that is just memory.
-static RECYCLED: Mutex<Vec<Vec<u8>>> = Mutex::new(Vec::new());
-const MAX_RECYCLED: usize = 3;
-
-/// Queue an RGBA8 image for upload; the id is usable in [`ImageQuad`]s right
-/// away (draws before the upload lands are skipped, not errors).
-pub fn upload_rgba(pixels: Vec<u8>, width: u32, height: u32) -> u32 {
-    upload_pixels(pixels, width, height, PixelFormat::Rgba)
-}
-
-/// Queue an image whose bytes are in `format`. [`upload_rgba`] is this with
-/// [`PixelFormat::Rgba`].
-pub fn upload_pixels(pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat) -> u32 {
-    queue_upload(pixels, width, height, format, false)
-}
-
-/// [`upload_rgba`] for an image that will be drawn much smaller than it is,
-/// or at a slant: the image gets a full mip chain, built on the GPU, and is
-/// sampled from the level that matches the size it is drawn at. An update
-/// ([`update_pixels`]) rebuilds the chain.
-///
-/// On a device that cannot build one by blitting the image is uploaded
-/// without, as [`upload_rgba`] would have.
-pub fn upload_rgba_mipmapped(pixels: Vec<u8>, width: u32, height: u32) -> u32 {
-    queue_upload(pixels, width, height, PixelFormat::Rgba, true)
-}
-
-fn queue_upload(pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat, mips: bool) -> u32 {
-    assert_eq!(pixels.len(), (width * height * 4) as usize, "8888 size mismatch");
-    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
-    PENDING.lock().unwrap().push(Pending::Upload { id, pixels, width, height, format, mips });
-    id
-}
-
 /// How many levels a full mip chain of an image has: halved until the
 /// longer side is one texel.
 pub(crate) fn mip_level_count(width: u32, height: u32) -> u32 {
     32 - width.max(height).max(1).leading_zeros()
 }
 
-/// Replace what `id` holds, keeping the image itself.
-///
-/// For a caller that redraws the same picture over and over — a page, a video
-/// frame, a live preview. Nothing is allocated, no descriptor is rewritten and
-/// no image is destroyed, so none of the per-frame `device_wait_idle` that
-/// freeing one costs. The size or format changing is allowed and simply falls
-/// back to a fresh image under the same id, which is what a window resize
-/// does.
-///
-/// An `id` that does not exist yet is treated as an upload, so a caller can
-/// take an id from [`upload_pixels`] and update it from the next frame on
-/// without sequencing the two.
-pub fn update_pixels(id: u32, pixels: Vec<u8>, width: u32, height: u32, format: PixelFormat) {
-    assert_eq!(pixels.len(), (width * height * 4) as usize, "8888 size mismatch");
-    PENDING.lock().unwrap().push(Pending::Update { id, pixels, width, height, format });
-}
-
-/// A pixel buffer to fill, reusing one the renderer has finished with when
-/// there is one of at least `len` bytes.
-///
-/// The returned buffer is exactly `len` long and its contents are unspecified
-/// — a caller is expected to overwrite every byte, which a full-frame readback
-/// does by definition. Allocating a fresh frame-sized `Vec` instead measured
-/// 7.4 ms against 2.9 ms at 3840x2400: the cost is not the copy, it is the
-/// zeroing and the page faults on newly mapped memory.
-pub fn recycle_buffer(len: usize) -> Vec<u8> {
-    let mut pool = RECYCLED.lock().unwrap();
-    if let Some(index) = pool.iter().position(|b| b.capacity() >= len) {
-        let mut buf = pool.swap_remove(index);
-        buf.clear();
-        buf.resize(len, 0);
-        return buf;
-    }
-    vec![0u8; len]
-}
-
-/// Hand a finished buffer back to the pool.
-fn retire_buffer(mut buf: Vec<u8>) {
-    let mut pool = RECYCLED.lock().unwrap();
-    if pool.len() < MAX_RECYCLED {
-        buf.clear();
-        pool.push(buf);
-    }
-}
-
-/// Which renderer's image table the ids handed out right now belong to.
-///
-/// `0` until the first renderer exists, and again for the whole life of that
-/// first renderer: uploads queued before it was built (from `Application::new`
-/// and from anything the app did on the way to its first frame) are drained
-/// into it, so they are that epoch's images, not a previous one's.
-/// Every later renderer — `window_runner` builds one per session, and a lost
-/// Wayland transport starts a new session around the same `Application` —
-/// counts as the next epoch.
-///
-/// This is what lets a long-lived cache of image ids notice that its ids have
-/// stopped naming anything. It is the cheap half of the contract; the other
-/// half is the app's, because only the app knows how to produce the pixels
-/// again (see [`Application::renderer_init`], and `upload_icon` for the
-/// toolkit's own use of this).
-///
-/// [`Application::renderer_init`]: crate::engine::Application::renderer_init
-pub fn renderer_epoch() -> u32 {
-    STAGES_BUILT.load(Ordering::Relaxed).saturating_sub(1)
-}
-
-/// Queue an image's GPU resources for destruction.
-pub fn free_image(id: u32) {
-    PENDING.lock().unwrap().push(Pending::Free { id });
-}
-
 #[repr(C)]
 #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
 /// Must match `GlyphVertex` field-for-field: both pipelines are fed by the same
@@ -316,7 +170,7 @@ impl ImageStage {
         // One image table per renderer, so this is the renderer count — see
         // `renderer_epoch`, which is what tells a cache of ids that its
         // renderer is gone.
-        STAGES_BUILT.fetch_add(1, Ordering::Relaxed);
+        image_table_built();
         unsafe {
             let bindings = image_set_bindings();
             let descriptor_set_layout = device
@@ -509,7 +363,7 @@ impl ImageStage {
         queue: vk::Queue,
         command_pool: vk::CommandPool,
     ) {
-        let pending: Vec<Pending> = std::mem::take(&mut *PENDING.lock().unwrap());
+        let pending: Vec<Pending> = take_pending();
         if pending.is_empty() {
             self.staging_idle = self.staging_idle.saturating_add(1);
             if self.staging_idle > STAGING_IDLE_FRAMES {
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index d646c34..cb96ba8 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -18,36 +18,13 @@ use gpu_allocator::MemoryLocation;
 use crate::engine::Vertex;
 
 use super::core::SurfaceLost;
-use super::image::{ImageQuad, ImageStage};
+use super::image::ImageStage;
+pub use crate::draw::{Batch2D, Frame2D, PlatePush, MAX_PLATE_FEATURES};
+pub(crate) use crate::draw::{batch_push_constants, PUSH_CONSTANT_FLOATS};
 use super::rt::{RtCamera, RtEnvironment, RtImage, RtImageSource, RtMaterial, RtStage, RtTriangle};
 use super::scene::{MeshId, SceneDraw, SceneImage, SceneStage, Vertex3D};
 use super::text::{TextSpan, TextStage};
 
-/// One scissored draw range of a 2D frame. `scissor` is (x, y, w, h) in
-/// physical pixels; None draws with the full-surface scissor. `clip_rrect` is an
-/// optional rounded-rect clip `[cx, cy, bx, by, r]` (center, SDF half-extents, corner
-/// radius; physical px) applied via push constants — fragments outside it discard, so a
-/// plate's children cut off at its rounded corners.
-pub struct Batch2D {
-    pub scissor: Option<(u32, u32, u32, u32)>,
-    pub clip_rrect: Option<[f32; 5]>,
-    pub start: u32,
-    pub end: u32,
-    /// When set, this batch is a single SDF-lit plate cover quad: the params go
-    /// out as push constants and shader2d's plate branch lights it per pixel.
-    pub plate: Option<PlatePush>,
-    /// A blur-behind plate (negative-alpha color): the renderer suspends the UI
-    /// pass, copies the swapchain-so-far into its snapshot image, and resumes —
-    /// so the plate's blur samples everything painted beneath it (background,
-    /// widgets, wires), not just the 3D scene backdrop.
-    pub blur_behind: bool,
-}
-
-/// Floats in the fragment push-constant block: the rounded-rect clip (`rect0`,
-/// `rect1` — 6 clip/flag floats plus the plate mode and corner shape) followed
-/// by [`PlatePush`]'s six vec4s. Field for field, this is shader2d's `RRectClip`.
-pub(crate) const PUSH_CONSTANT_FLOATS: usize = 32;
-
 /// The block in bytes. **This is exactly `maxPushConstantsSize`'s
 /// Vulkan-guaranteed minimum, so the budget is full** — every one of the 32
 /// slots is written. That is why a new SDF mode reinterprets existing fields per
@@ -71,98 +48,6 @@ const _: () = assert!(
      before raising PUSH_CONSTANT_FLOATS"
 );
 
-/// The fragment push-constant block for one batch: its rounded-rect clip,
-/// and its plate block when it is a plate cover quad. `feature_base` is the
-/// frame slot's first entry in the feature UBO, added to a plate's (or a
-/// union carve's) feature offset. Shared with the offscreen test harness
-/// (`vk::plate_probe`), so the two cannot push different blocks.
-pub(crate) fn batch_push_constants(batch: &Batch2D, clip_shape: f32, feature_base: usize) -> [f32; PUSH_CONSTANT_FLOATS] {
-    let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
-    let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
-    let mut pc = [0.0f32; PUSH_CONSTANT_FLOATS];
-    pc[..5].copy_from_slice(&rr);
-    pc[5] = enabled;
-    pc[7] = clip_shape;
-    if let Some(p) = &batch.plate {
-        pc[6] = p.mode;
-        pc[7] = p.shape;
-        pc[8..12].copy_from_slice(&p.rect);
-        pc[12..16].copy_from_slice(&p.radii);
-        pc[16..20].copy_from_slice(&p.light);
-        pc[20..24].copy_from_slice(&p.material);
-        pc[24..28].copy_from_slice(&p.host);
-        pc[28..32].copy_from_slice(&p.specular_tint);
-        if p.mode == 1.0 || p.mode == 14.0 {
-            // Rebase the feature offset onto this frame's UBO slot (a plate's
-            // CSG carves, or a union carve's boxes).
-            pc[24] += feature_base as f32;
-        }
-    }
-    pc
-}
-
-/// Push-constant block for one SDF-lit plate batch (physical px throughout).
-/// Mirrors the `p_*` fields of shader2d's `RRectClip`.
-#[derive(Clone, Copy, PartialEq, Debug)]
-pub struct PlatePush {
-    /// SDF box: center + half-extents. May extend past the cover quad — that is
-    /// how a recess suppresses a wall.
-    pub rect: [f32; 4],
-    /// Per-corner radii [tl, tr, br, bl].
-    pub radii: [f32; 4],
-    /// xyz = unit vector toward the light (+z out of the screen), w = roll width px.
-    pub light: [f32; 4],
-    /// [shading strength, specular strength, shininess, curvature/AO strength].
-    pub material: [f32; 4],
-    /// Mode 1: `[feature offset, feature count, frost z, frost w]` — xy into
-    /// the frame's `plate_features`, the carves CSG'd out of this plate (the
-    /// renderer adds the frame slot's base offset at record time); zw the
-    /// plate's frost recipe, `scene::material::Frost::pack` (compression and
-    /// refraction packed in z, the blur sigma in physical px in w). Mode 14 uses the same
-    /// `[offset, count]` for the union's boxes. Mode 2: the host-plate box
-    /// (center + half-extents) a free recess fades out against; far-away sides
-    /// (±1e5) disable the fade.
-    pub host: [f32; 4],
-    /// RGB multiplies the roll's specular color (w unused). Neutral white
-    /// normally; the focused-pane bevel carries the highlight color here.
-    pub specular_tint: [f32; 4],
-    /// 1.0 = raised lit plate, 2.0 = recess overlay, 3.0 = boss, 4.0 = ridge,
-    /// 5.0 = sphere, 6.0/7.0 = concave fillet (recessed/raised), 8.0 = groove
-    /// (slab carve about a line: `rect` = [cx, cy, half-width, _], `radii.xy` =
-    /// the line's unit normal, `host` = the surface it is engraved into),
-    /// 9.0 = trough, 10.0 = droplet (`radii` = [sag, belly r, belly half-w,
-    /// blend k] px, `host` = [sheet corner r px, clarity, dome amplitude,
-    /// attach r px], `material.w` = fresnel rim, `specular_tint` = [core
-    /// density, _, _, bottom-bow rise px] — droplet glints are always white,
-    /// so the tint RGB is repurposed; see shader2d's MODE_DROPLET).
-    pub mode: f32,
-    /// Corner shape exponent: 2.0 = circular arcs, > 2 = superellipse
-    /// (continuous-curvature) corners — see shader2d's `plate_sdf_grad`.
-    pub shape: f32,
-}
-
-/// A full 2D frame: the display-list vertices (optionally split into scissored
-/// batches), overlay vertices drawn after text, and the clear color (linear;
-/// only used on frames without a backdrop copy).
-pub struct Frame2D<'a> {
-    pub verts: &'a [Vertex],
-    pub batches: &'a [Batch2D],
-    pub overlay_verts: &'a [Vertex],
-    /// User images drawn interleaved with `verts` by each quad's `z_before`.
-    pub images: &'a [ImageQuad],
-    /// Carves CSG'd into this frame's SDF-lit plates, 12 floats each (rect
-    /// center+half-extents, per-corner radii, [width px, depth px, 0, 0]).
-    /// Plate batches reference them by offset+count in `PlatePush::host`.
-    pub plate_features: &'a [[f32; 12]],
-    pub clear_color: [f32; 4],
-    /// The only part of the surface that differs from the previous frame,
-    /// (x, y, w, h) in physical pixels; None = all of it. With a rect the
-    /// renderer keeps the pixels outside it (see [`ImageAge`]) and tells the
-    /// compositor that only the rect changed. The caller vouches for it: a
-    /// pixel that changed outside the rect stays as it was.
-    pub damage: Option<(u32, u32, u32, u32)>,
-}
-
 /// How far a swapchain image's pixels are behind the latest frame. A frame
 /// with [`Frame2D::damage`] repaints only what the image it acquired is
 /// missing — the frame's own damage plus whatever frames that went to the
@@ -211,9 +96,6 @@ fn rect_intersect(a: vk::Rect2D, b: vk::Rect2D) -> vk::Rect2D {
 }
 
 pub(crate) const FRAMES_IN_FLIGHT: usize = 2;
-/// Max plate-carve features per frame; the shader's UBO holds one slot of this
-/// size per frame in flight.
-pub const MAX_PLATE_FEATURES: usize = 64;
 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].
diff --git a/src/vk/text.rs b/src/vk/text.rs
index 2210cb9..eddbf99 100644
--- a/src/vk/text.rs
+++ b/src/vk/text.rs
@@ -22,39 +22,15 @@ use gpu_allocator::vulkan::{
 };
 use gpu_allocator::MemoryLocation;
 
-use cosmic_text::{Buffer as TextBuffer, CacheKey, SwashContent};
+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;
 
-/// One shaped text run to draw. `left`/`top` are physical pixels and `scale`
-/// multiplies the shaped (logical) glyph positions — the same contract as
-/// the old glyphon::TextArea, where callers pass `label.x * scale`.
-pub struct TextSpan<'a> {
-    pub buffer: &'a TextBuffer,
-    pub left: f32,
-    pub top: f32,
-    pub scale: f32,
-    /// Physical-pixel clip rect (left, top, right, bottom); None = whole surface.
-    pub bounds: Option<[i32; 4]>,
-    /// 0..=1 sRGB + alpha, applied to glyphs without their own color.
-    pub default_color: [f32; 4],
-    /// Rotate the span's glyph quads by (radians, center_x, center_y) in
-    /// physical pixels — the circular network pane's curved rim labels.
-    pub rotation: Option<(f32, f32, f32)>,
-    /// Fragment circle clip (center_x, center_y, radius) in physical pixels;
-    /// zero radius disables (matches shader.wgsl's clip_circle).
-    pub clip_circle: [f32; 3],
-    /// Rounded-rect clip half-extents (physical px). Zero keeps `clip_circle` a plain
-    /// circle; non-zero reinterprets it as a rounded-rect SDF clip — center
-    /// `clip_circle.xy`, corner radius `clip_circle.z`, inner box half-size
-    /// `clip_extents` — so plate children (labels included) cut off at rounded corners.
-    pub clip_extents: [f32; 2],
-}
-
 #[repr(C)]
 #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
 struct GlyphVertex {
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index dc3c0b3..481d664 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -2031,7 +2031,7 @@ impl Paint for ParametersBg {
         for c in self.colors.iter_mut().flatten() {
             c.prepare_text(fs);
         }
-        let clusters = crate::backend::window_runner::shaped_cluster_offsets(
+        let clusters = crate::backend::text::shaped_cluster_offsets(
             fs,
             "MMMMMMMM",
             12.0,
diff --git a/src/widget/core.rs b/src/widget/core.rs
index a357049..4a8cbe6 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -618,7 +618,7 @@ pub mod context_menu {
                         .lock()
                         .ok()
                         .and_then(|mut fs| {
-                            crate::backend::window_runner::shaped_cluster_offsets(&mut fs, s, size, Some(&family))
+                            crate::backend::text::shaped_cluster_offsets(&mut fs, s, size, Some(&family))
                                 .last()
                                 .map(|&(_, total)| total)
                         })
@@ -2131,7 +2131,7 @@ mod context_menu_padding_tests {
         let (family, size) = super::context_menu::label_font();
         let drawn = {
             let mut fs = crate::geometry_font_system().lock().unwrap();
-            crate::backend::window_runner::shaped_cluster_offsets(&mut fs, "● Follow Active Editor", size, Some(&family))
+            crate::backend::text::shaped_cluster_offsets(&mut fs, "● Follow Active Editor", size, Some(&family))
                 .last()
                 .map(|&(_, t)| t)
                 .unwrap()
diff --git a/src/widget/display/image_view.rs b/src/widget/display/image_view.rs
index 986ff0a..db98f05 100644
--- a/src/widget/display/image_view.rs
+++ b/src/widget/display/image_view.rs
@@ -1,7 +1,7 @@
 //! `ImageView` — a GPU-textured image fitted into the widget's rect via
 //! [`fit_rect`]. The view BORROWS its image id: ids come from
-//! [`crate::vk::upload_rgba`] and stay owned by the app, which frees them with
-//! [`crate::vk::free_image`] when done — the widget never uploads or frees GPU
+//! [`crate::draw::upload_rgba`] and stay owned by the app, which frees them with
+//! [`crate::draw::free_image`] when done — the widget never uploads or frees GPU
 //! resources, so one id can back several views and a dropped view leaks
 //! nothing. `image: None` paints only the optional letterbox floor.
 //!
diff --git a/src/widget/display/label.rs b/src/widget/display/label.rs
index d41ec1c..fca7442 100644
--- a/src/widget/display/label.rs
+++ b/src/widget/display/label.rs
@@ -168,7 +168,7 @@ impl StyledLabel {
         if is_vert {
             final_text = text.chars().map(|c| c.to_string()).collect::<Vec<_>>().join("\n");
         }
-        let mut buffer = crate::backend::window_runner::get_text_buffer(fs, &final_text, size, Some(family));
+        let mut buffer = crate::backend::text::get_text_buffer(fs, &final_text, size, Some(family));
         if is_vert {
             let bar_thickness = crate::BAR_THICKNESS.load(std::sync::atomic::Ordering::Relaxed) as f32;
             buffer.set_size(fs, Some(bar_thickness * scale as f32), None);
diff --git a/src/widget/display/list_item.rs b/src/widget/display/list_item.rs
index 6bbb820..d3bf581 100644
--- a/src/widget/display/list_item.rs
+++ b/src/widget/display/list_item.rs
@@ -33,7 +33,7 @@ impl TextItem {
         font: Option<&str>,
         bounds: Option<[f32; 4]>,
     ) -> Self {
-        let buffer = crate::backend::window_runner::get_text_buffer(fs, text, size, font);
+        let buffer = crate::backend::text::get_text_buffer(fs, text, size, font);
         Self { buffer, x, y, color, bounds, clip_circle: None, clip_rrect: None }
     }
 }
diff --git a/src/widget/display/text_label.rs b/src/widget/display/text_label.rs
index 245c18d..50579e4 100644
--- a/src/widget/display/text_label.rs
+++ b/src/widget/display/text_label.rs
@@ -75,5 +75,5 @@ impl TextLabel {
 }
 
 pub(crate) fn make_widget_text_buffer(fs: &mut cosmic_text::FontSystem, text: &str, size: f32, font_family: &str) -> cosmic_text::Buffer {
-    crate::backend::window_runner::get_text_buffer(fs, text, size, Some(font_family))
+    crate::backend::text::get_text_buffer(fs, text, size, Some(font_family))
 }
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 3d76745..bf5b17e 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -267,7 +267,7 @@ impl Button {
             .lock()
             .ok()
             .and_then(|mut fs| {
-                crate::backend::window_runner::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
+                crate::backend::text::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
                     .last()
                     .map(|&(_, total)| total)
             })
@@ -732,7 +732,7 @@ mod tests {
         for label in ["Attach...", "Load Images", "Cancel"] {
             let drawn = {
                 let mut fs = crate::geometry_font_system().lock().unwrap();
-                crate::backend::window_runner::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
+                crate::backend::text::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
                     .last()
                     .map(|&(_, t)| t)
                     .unwrap()
@@ -775,7 +775,7 @@ mod tests {
         // 8px in from the left.
         let drawn = {
             let mut fs = crate::geometry_font_system().lock().unwrap();
-            crate::backend::window_runner::shaped_cluster_offsets(&mut fs, &text.0, text.2, text.3.as_deref())
+            crate::backend::text::shaped_cluster_offsets(&mut fs, &text.0, text.2, text.3.as_deref())
                 .last()
                 .map(|&(_, t)| t)
                 .unwrap()
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index bc063f3..56b8fff 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -190,7 +190,7 @@ impl Paint for ColorSelector {
         // default family) and record char-index → x for the caret.
         let text = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
         let clusters =
-            crate::backend::window_runner::shaped_cluster_offsets(fs, &text, 12.0, None);
+            crate::backend::text::shaped_cluster_offsets(fs, &text, 12.0, None);
         let mut offsets = vec![0.0f32; text.chars().count() + 1];
         for (byte, x) in clusters {
             let ci = text[..byte.min(text.len())].chars().count();
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index eb0372d..0dbb62a 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -103,7 +103,7 @@ fn shaped_clusters(text: &str, font_size: f32) -> Option<Vec<(usize, f32)>> {
     let font = crate::layout::control_label_font_detached();
     let mut fs = crate::geometry_font_system().lock().ok()?;
     let clusters =
-        crate::backend::window_runner::shaped_cluster_offsets(&mut fs, text, font_size, Some(&font));
+        crate::backend::text::shaped_cluster_offsets(&mut fs, text, font_size, Some(&font));
     (clusters.len() > 1 && clusters.last().is_some_and(|&(_, total)| total > 0.0)).then_some(clusters)
 }
 
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 77ced27..7c1a1a1 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -315,7 +315,7 @@ impl Paint for Spinbox {
         // keyed by byte; the editor state is char-indexed.
         let text = self.value_text();
         let clusters =
-            crate::backend::window_runner::shaped_cluster_offsets(fs, &text, 14.0, None);
+            crate::backend::text::shaped_cluster_offsets(fs, &text, 14.0, None);
         let mut offsets = vec![0.0f32; text.chars().count() + 1];
         for (byte, x) in clusters {
             let ci = text[..byte.min(text.len())].chars().count();
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 485f71a..0fb4647 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1422,7 +1422,7 @@ impl Paint for TextBox {
             display_text.to_string()
         };
 
-        let buffer = crate::backend::window_runner::get_text_buffer(fs, &render_text, self.font_size, font_fam);
+        let buffer = crate::backend::text::get_text_buffer(fs, &render_text, self.font_size, font_fam);
 
         let char_count = render_text.chars().count();
         let mut x_offsets = vec![0.0; char_count + 1];
@@ -1431,14 +1431,14 @@ impl Paint for TextBox {
 
         // One column's advance, from the same shaping path as the labels (buffer-cached,
         // so this is a lookup after the first frame per family/size).
-        let probe = crate::backend::window_runner::get_text_buffer(fs, "MMMMMMMM", self.font_size, font_fam);
+        let probe = crate::backend::text::get_text_buffer(fs, "MMMMMMMM", self.font_size, font_fam);
         self.shaped_char_advance = probe
             .layout_runs()
             .next()
             .and_then(|run| run.glyphs.last().map(|g| (g.x + g.w) / scale / 8.0))
             .unwrap_or(0.0);
 
-        for (start, gx, gw) in crate::backend::window_runner::normalized_glyph_starts(&buffer, &render_text) {
+        for (start, gx, gw) in crate::backend::text::normalized_glyph_starts(&buffer, &render_text) {
             let c_idx = render_text[..start.min(render_text.len())].chars().count();
             if c_idx < x_offsets.len() {
                 x_offsets[c_idx] = gx / scale;
@@ -1480,11 +1480,11 @@ impl Paint for TextBox {
             };
             let (lines, _) = self.wrap_text(max_chars);
             for line in &lines {
-                let line_buffer = crate::backend::window_runner::get_text_buffer(fs, line, self.font_size, font_fam);
+                let line_buffer = crate::backend::text::get_text_buffer(fs, line, self.font_size, font_fam);
                 let n = line.chars().count();
                 let mut offs = vec![0.0f32; n + 1];
                 let mut line_total: f32 = 0.0;
-                for (start, gx, gw) in crate::backend::window_runner::normalized_glyph_starts(&line_buffer, line) {
+                for (start, gx, gw) in crate::backend::text::normalized_glyph_starts(&line_buffer, line) {
                     let c_idx = line[..start.min(line.len())].chars().count();
                     if c_idx < offs.len() {
                         offs[c_idx] = gx / scale;
diff --git a/src/widget/shaping.rs b/src/widget/shaping.rs
index 8b8860d..c4062c4 100644
--- a/src/widget/shaping.rs
+++ b/src/widget/shaping.rs
@@ -36,12 +36,12 @@ impl Measure for ShapingMeasure {
         if let Some(w) = self.cache.get(&key) {
             return *w;
         }
-        let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
+        let buf = crate::backend::text::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
         // The glyphs' extent, trailing spaces included — the same measure
         // `offsets` ends on. (A layout run's `line_w` leaves trailing
         // whitespace out, so a width taken from it disagreed with where
         // the next run was placed by a space.)
-        let w = crate::backend::window_runner::normalized_glyph_starts(&buf, text)
+        let w = crate::backend::text::normalized_glyph_starts(&buf, text)
             .into_iter()
             .map(|(_, x, w)| x + w)
             .fold(0.0, f32::max)
@@ -58,8 +58,8 @@ impl ShapingMeasure {
     /// combining mark) takes the cluster's start.
     pub fn offsets(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> Vec<(usize, f32)> {
         let scale = crate::scale::scale_factor().max(0.01);
-        let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
-        let glyphs = crate::backend::window_runner::normalized_glyph_starts(&buf, text);
+        let buf = crate::backend::text::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
+        let glyphs = crate::backend::text::normalized_glyph_starts(&buf, text);
         let mut starts: Vec<(usize, f32)> = Vec::with_capacity(glyphs.len() + 1);
         let mut width = 0.0f32;
         for (start, x, w) in glyphs {