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

commitd84e138af84cc18f53fc261cf29633114c1a6feb
parent52724466c1
authorLucas Galante <[email protected]>
date2026-07-14 13:09
feat: raw-Vulkan (ash) backend — VkRenderer moves in, the engine runner renders on it

The vk module grown through cce-designer's migration (renderer, glyph-atlas
text stage, 3D scene stage) moves into the toolkit as cce_ui::vk, generalized
for every client:

- shader2d.wgsl is the union of the two wgpu-era 2D dialects: the engine
  shader's wavy-blob sentinel plus the designer shader's window-corner
  rounding (radius 0 disables), circle clip, and blur-behind branch over the
  renderer-managed backdrop.
- Frame2D/Batch2D: draw_frame_2d renders the display list as scissored clip
  batches, then text, then overlay vertices, with a caller-provided clear
  color — everything EngineState::render needs.

window_runner's EngineState drops WgpuAdapter, the pipeline, and the vertex
buffers for Option<VkRenderer> (+ FontSystem/SwashCache moving onto the
state); init_gpu is sync, and the glyphon TextArea pass becomes TextSpans
against the same shaped-buffer cache (physical-size shaping, scale 1.0).
Every engine::run client renders on Vulkan from this commit.

Public API is preserved: Vertex::desc, SHADER, and WgpuAdapter remain for
hand-rolled clients (cce-cloud) until they port. Full workspace builds; 169
cce-ui tests pass; verified live on cce-system-settings (buttons, system
fonts, dropdown overlay) with zero validation messages.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RFkXq68hDwVDMKnu9fckz3

 Cargo.toml                   |    4 +
 src/backend/window_runner.rs |  304 +++------
 src/lib.rs                   |    1 +
 src/vk/glyph.wgsl            |   42 ++
 src/vk/mod.rs                |   32 +
 src/vk/renderer.rs           | 1495 ++++++++++++++++++++++++++++++++++++++++++
 src/vk/scene.rs              |  726 ++++++++++++++++++++
 src/vk/scene3d.wgsl          |   72 ++
 src/vk/shader2d.wgsl         |  151 +++++
 src/vk/text.rs               |  771 ++++++++++++++++++++++
 10 files changed, 3370 insertions(+), 228 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 854f1c6..e6460ff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,10 @@ glam = "0.29"
 bytemuck = { version = "1", features = ["derive"] }
 pollster = "0.4"
 glyphon = "0.8"
+# Raw-Vulkan backend (versions match what wgpu 24 already pulls in).
+ash = "0.38"
+gpu-allocator = { version = "0.27", default-features = false, features = ["vulkan"] }
+naga = { version = "24", features = ["wgsl-in", "spv-out"] }
 tokio = { version = "1", features = ["full"] }
 smithay-client-toolkit = { version = "0.19.2", features = ["calloop"] }
 calloop = "0.13.0"
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index f26037b..f2cb6e8 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -34,12 +34,12 @@ use wayland_protocols::wp::pointer_gestures::zv1::client::{
 use calloop::EventLoop;
 use calloop_wayland_source::WaylandSource;
 use glyphon::{
-    FontSystem, Resolution, TextArea,
+    FontSystem,
     TextBounds, Buffer, Attrs, Metrics,
 };
 use crate::widget::{WidgetHost, TextItem, MouseButton, ElementState, MouseScrollDelta, KeyEvent, Key, NamedKey, Position};
 use crate::wayland::detect_scale_factor;
-use crate::backend::WgpuAdapter;
+use crate::vk::{Batch2D, Frame2D, TextSpan, VkRenderer};
 
 #[derive(Hash, PartialEq, Eq, Clone)]
 struct BufferCacheKey {
@@ -1634,12 +1634,9 @@ pub struct EngineState<A: Application> {
     
     pub inner: Option<A>,
     
-    pub wgpu_adapter: Option<WgpuAdapter>,
-    pub render_pipeline: Option<wgpu::RenderPipeline>,
-    pub vertex_buffer: Option<wgpu::Buffer>,
-    pub vertex_count: u32,
-    pub overlay_vertex_buffer: Option<wgpu::Buffer>,
-    pub overlay_vertex_count: u32,
+    pub renderer: Option<VkRenderer>,
+    pub font_system: Option<FontSystem>,
+    pub swash_cache: glyphon::SwashCache,
     
     pub scale_factor: f64,
     pub logical_width: f32,
@@ -1669,93 +1666,37 @@ pub struct EngineState<A: Application> {
 }
 
 impl<A: Application> EngineState<A> {
-    pub async fn init_gpu(&mut self, conn: &Connection, width_logical: f32, height_logical: f32) {
+    pub fn init_gpu(&mut self, conn: &Connection, width_logical: f32, height_logical: f32) {
         let s = self.scale_factor as f32;
         let pw = (width_logical * s) as u32;
         let ph = (height_logical * s) as u32;
-        
+
         let surface = self.surface.as_ref().expect("surface missing");
-        
+
         let display_ptr = conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
         let surface_ptr = surface.id().as_ptr() as *mut std::ffi::c_void;
-        
-        let load_system_fonts = self.inner.as_ref().map_or(false, |a| a.load_system_fonts());
-        let adapter = WgpuAdapter::new(display_ptr, surface_ptr, pw, ph, load_system_fonts).await;
-        
-        let shader = adapter.device.create_shader_module(wgpu::ShaderModuleDescriptor {
-            label: Some("Shader"),
-            source: wgpu::ShaderSource::Wgsl(crate::SHADER.into()),
-        });
-        
-        let pipeline_layout = adapter.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
-            label: Some("Pipeline Layout"),
-            bind_group_layouts: &[],
-            push_constant_ranges: &[],
-        });
-        
-        let render_pipeline = adapter.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
-            label: Some("Render Pipeline"),
-            layout: Some(&pipeline_layout),
-            vertex: wgpu::VertexState {
-                module: &shader,
-                entry_point: Some("vs_main"),
-                buffers: &[Vertex::desc()],
-                compilation_options: Default::default(),
-            },
-            fragment: Some(wgpu::FragmentState {
-                module: &shader,
-                entry_point: Some("fs_main"),
-                targets: &[Some(wgpu::ColorTargetState {
-                    format: adapter.config.format,
-                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
-                    write_mask: wgpu::ColorWrites::ALL,
-                })],
-                compilation_options: Default::default(),
-            }),
-            primitive: wgpu::PrimitiveState {
-                topology: wgpu::PrimitiveTopology::TriangleList,
-                front_face: wgpu::FrontFace::Ccw,
-                cull_mode: None,
-                polygon_mode: wgpu::PolygonMode::Fill,
-                unclipped_depth: false,
-                conservative: false,
-                strip_index_format: None,
-            },
-            depth_stencil: None,
-            multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false },
-            multiview: None,
-            cache: None,
-        });
-        
-        let vertex_buffer = adapter.device.create_buffer(&wgpu::BufferDescriptor {
-            label: Some("Vertex Buffer"),
-            size: 1,
-            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-            mapped_at_creation: false,
-        });
 
-        let overlay_vertex_buffer = adapter.device.create_buffer(&wgpu::BufferDescriptor {
-            label: Some("Overlay Vertex Buffer"),
-            size: 1,
-            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-            mapped_at_creation: false,
+        let load_system_fonts = self.inner.as_ref().map_or(false, |a| a.load_system_fonts());
+        // Corner radius 0: runner apps tessellate their own rounded corners.
+        let renderer =
+            unsafe { VkRenderer::new(display_ptr, surface_ptr, pw, ph, 0.0) };
+        self.font_system = Some(if load_system_fonts {
+            crate::create_font_system_with_system_fonts()
+        } else {
+            crate::create_font_system()
         });
-        
-        self.wgpu_adapter = Some(adapter);
-        self.render_pipeline = Some(render_pipeline);
-        self.vertex_buffer = Some(vertex_buffer);
-        self.overlay_vertex_buffer = Some(overlay_vertex_buffer);
+        self.renderer = Some(renderer);
         self.logical_width = width_logical;
         self.logical_height = height_logical;
     }
-    
+
     pub fn resize(&mut self, w: f32, h: f32) {
         let (w, h) = self.inner.as_ref().unwrap().adjust_size(w, h);
         if w > 0.0 && h > 0.0 {
             self.logical_width = w;
             self.logical_height = h;
-            if let Some(ref mut adapter) = self.wgpu_adapter {
-                adapter.resize((w as f64 * self.scale_factor) as u32, (h as f64 * self.scale_factor) as u32);
+            if let Some(ref mut renderer) = self.renderer {
+                renderer.resize((w as f64 * self.scale_factor) as u32, (h as f64 * self.scale_factor) as u32);
             }
         }
     }
@@ -1789,7 +1730,7 @@ impl<A: Application> EngineState<A> {
         // Clip = the paint walk's item clip ∩ the prim's own bounds, in logical space.
         self.dl_text_items.clear();
         if self.inner.as_ref().unwrap().display_list_text() {
-            let fs = &mut self.wgpu_adapter.as_mut().unwrap().font_system;
+            let fs = self.font_system.as_mut().unwrap();
             for item in &dl.items {
                 if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, font, bounds, attrs, layout } = &item.prim {
                     let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
@@ -1815,8 +1756,6 @@ impl<A: Application> EngineState<A> {
             }
         }
 
-        let adapter = self.wgpu_adapter.as_mut().unwrap();
-        let render_pipeline = self.render_pipeline.as_ref().unwrap();
         let (mut verts, mut dl_batches) = tessellate_display_list(&dl, logical_w, logical_h);
         // custom_vertices (e.g. graph geometry) is appended as a final unclipped batch drawn on top.
         let pre_custom = verts.len() as u32;
@@ -1824,60 +1763,24 @@ impl<A: Application> EngineState<A> {
         if (verts.len() as u32) > pre_custom {
             dl_batches.push(DlBatch { scissor: None, start: pre_custom, end: verts.len() as u32 });
         }
-        self.vertex_count = verts.len() as u32;
-        if self.vertex_count > 0 {
-            let data = bytemuck::cast_slice(&verts);
-            let needed = data.len() as wgpu::BufferAddress;
-            let mut vbuf = self.vertex_buffer.as_ref().unwrap();
-            if needed > vbuf.size() {
-                let new_vbuf = adapter.device.create_buffer(&wgpu::BufferDescriptor {
-                    label: Some("Vertex Buffer"),
-                    size: needed,
-                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                    mapped_at_creation: false,
-                });
-                self.vertex_buffer = Some(new_vbuf);
-                vbuf = self.vertex_buffer.as_ref().unwrap();
-            }
-            adapter.queue.write_buffer(vbuf, 0, data);
-        }
 
-        // 1b. Build and upload overlay vertex buffer
+        // 1b. Overlay quads (drawn after the text pass).
         let mut overlay_quads = Vec::new();
         self.inner.as_mut().unwrap().overlay_quads(&mut overlay_quads, LogicalSize::new(logical_w, logical_h), scale_factor);
         let mut overlay_verts = Vec::new();
         for &(qx, qy, qw, qh, qc) in &overlay_quads {
             overlay_verts.extend(quad_vertices(qx, qy, qw, qh, logical_w, logical_h, qc));
         }
-        self.overlay_vertex_count = overlay_verts.len() as u32;
-        if self.overlay_vertex_count > 0 {
-            let data = bytemuck::cast_slice(&overlay_verts);
-            let needed = data.len() as wgpu::BufferAddress;
-            let mut ovbuf = self.overlay_vertex_buffer.as_ref().unwrap();
-            if needed > ovbuf.size() {
-                let new_ovbuf = adapter.device.create_buffer(&wgpu::BufferDescriptor {
-                    label: Some("Overlay Vertex Buffer"),
-                    size: needed,
-                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                    mapped_at_creation: false,
-                });
-                self.overlay_vertex_buffer = Some(new_ovbuf);
-                ovbuf = self.overlay_vertex_buffer.as_ref().unwrap();
-            }
-            adapter.queue.write_buffer(ovbuf, 0, data);
-        }
-        
+
         // 2. Prepare text
         let scale_f32 = scale_factor as f32;
         let pw = (logical_w * scale_f32) as u32;
         let ph = (logical_h * scale_f32) as u32;
-        adapter.text_viewport.update(&adapter.queue, Resolution { width: pw, height: ph });
-        
+
         let bounds = TextBounds { left: 0, top: 0, right: pw as i32, bottom: ph as i32 };
         // All text is display-list text now (the legacy text_items/text_areas path is gone):
         // map each dl Text prim with the default mapping (scale + surface clamp) plus the
         // popover-occlusion clamp against the app's registered popovers.
-        let mut areas: Vec<TextArea<'_>> = Vec::new();
         let mut dl_overlay_rects: Vec<(f32, f32, f32, f32)> = Vec::new();
         if let Some(ctx) = self.inner.as_ref().unwrap().ui_context() {
             for &pop_id in &ctx.active_popovers {
@@ -1902,6 +1805,7 @@ impl<A: Application> EngineState<A> {
                 crate::widget::context_menu::h(),
             ));
         }
+        let mut spans: Vec<TextSpan> = Vec::new();
         for ti in &self.dl_text_items {
             let mut item_bounds = if let Some([l, t, r, b]) = ti.bounds {
                 TextBounds {
@@ -1914,123 +1818,70 @@ impl<A: Application> EngineState<A> {
                 bounds
             };
             popover_occlusion_clamp(&dl_overlay_rects, ti, scale_f32, &mut item_bounds);
-            areas.push(TextArea {
+            spans.push(TextSpan {
                 buffer: &ti.buffer,
                 left: (ti.x * scale_f32).round(),
                 top: (ti.y * scale_f32).round(),
+                // Buffers are shaped at physical size (get_text_buffer_attrs).
                 scale: 1.0,
-                bounds: item_bounds,
-                default_color: ti.color,
-                custom_glyphs: &[],
+                bounds: Some([
+                    item_bounds.left,
+                    item_bounds.top,
+                    item_bounds.right,
+                    item_bounds.bottom,
+                ]),
+                default_color: [
+                    ti.color.r() as f32 / 255.0,
+                    ti.color.g() as f32 / 255.0,
+                    ti.color.b() as f32 / 255.0,
+                    ti.color.a() as f32 / 255.0,
+                ],
+                rotation: None,
+                clip_circle: [0.0; 3],
             });
         }
-        adapter.text_renderer.prepare(&adapter.device, &adapter.queue, &mut adapter.font_system, &mut adapter.text_atlas, &adapter.text_viewport, areas, &mut adapter.swash_cache).unwrap();
-        
-        // 3. Render Pass
-        let output = match adapter.surface.get_current_texture() {
-            Ok(t) => t,
-            Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
-                adapter.surface.configure(&adapter.device, &adapter.config);
-                match adapter.surface.get_current_texture() {
-                    Ok(t) => t,
-                    Err(e) => {
-                        log::error!("Surface error after configure: {e:?}");
-                        return;
-                    }
-                }
-            }
-            Err(wgpu::SurfaceError::Timeout) => return,
-            Err(e) => {
-                log::error!("Surface error: {e:?}");
-                return;
-            }
-        };
-        let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
-        let mut encoder = adapter.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
-            label: Some("Encoder"),
-        });
-        
-        {
-            let cc = self.inner.as_ref().unwrap().clear_color();
-            let r_clear = (cc[0] as f64).powf(2.2);
-            let g_clear = (cc[1] as f64).powf(2.2);
-            let b_clear = (cc[2] as f64).powf(2.2);
-            let a_clear = cc[3] as f64;
-            
-            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                label: Some("Render Pass"),
-                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                    view: &view,
-                    resolve_target: None,
-                    ops: wgpu::Operations {
-                        load: wgpu::LoadOp::Clear(wgpu::Color {
-                            r: r_clear,
-                            g: g_clear,
-                            b: b_clear,
-                            a: a_clear,
-                        }),
-                        store: wgpu::StoreOp::Store,
-                    },
-                })],
-                depth_stencil_attachment: None,
-                timestamp_writes: None,
-                occlusion_query_set: None,
-            });
-            
-            if self.vertex_count > 0 {
-                pass.set_pipeline(render_pipeline);
-                pass.set_vertex_buffer(0, self.vertex_buffer.as_ref().unwrap().slice(..));
-                // Draw each clip batch under its own GPU scissor (logical clip -> physical px).
-                for batch in &dl_batches {
-                    match batch.scissor {
-                        Some(clip) => {
-                            let sx = (clip.x * scale_f32).max(0.0) as u32;
-                            let sy = (clip.y * scale_f32).max(0.0) as u32;
-                            if sx >= pw || sy >= ph {
-                                continue;
-                            }
-                            let sw_px = ((clip.width * scale_f32) as u32).min(pw - sx);
-                            let sh_px = ((clip.height * scale_f32) as u32).min(ph - sy);
-                            if sw_px == 0 || sh_px == 0 {
-                                continue;
-                            }
-                            pass.set_scissor_rect(sx, sy, sw_px, sh_px);
-                        }
-                        None => pass.set_scissor_rect(0, 0, pw, ph),
-                    }
-                    pass.draw(batch.start..batch.end, 0..1);
-                }
-                // Restore full scissor so text/overlay draws are not clipped.
-                pass.set_scissor_rect(0, 0, pw, ph);
-            }
 
-            adapter.text_renderer.render(&adapter.text_atlas, &adapter.text_viewport, &mut pass).unwrap();
- 
-            if self.overlay_vertex_count > 0 {
-                pass.set_pipeline(render_pipeline);
-                pass.set_vertex_buffer(0, self.overlay_vertex_buffer.as_ref().unwrap().slice(..));
-                pass.draw(0..self.overlay_vertex_count, 0..1);
-            }
-        }
-        
+        // 3. Frame: display-list batches under their physical scissors, then
+        // text, then overlays. The renderer owns swapchain rebuild/recovery.
+        let renderer = self.renderer.as_mut().unwrap();
+        renderer.prepare_text(self.font_system.as_mut().unwrap(), &mut self.swash_cache, &spans);
+
+        let batches: Vec<Batch2D> = dl_batches
+            .iter()
+            .map(|batch| Batch2D {
+                scissor: batch.scissor.map(|clip| {
+                    (
+                        (clip.x * scale_f32).max(0.0) as u32,
+                        (clip.y * scale_f32).max(0.0) as u32,
+                        (clip.width * scale_f32) as u32,
+                        (clip.height * scale_f32) as u32,
+                    )
+                }),
+                start: batch.start,
+                end: batch.end,
+            })
+            .collect();
+
+        let cc = self.inner.as_ref().unwrap().clear_color();
+        let clear_color = [cc[0].powf(2.2), cc[1].powf(2.2), cc[2].powf(2.2), cc[3]];
+
         if let Some(ref surface) = self.surface {
             let _callback = surface.frame(&self.qh, ());
             self.frame_callback_pending = true;
         }
 
-        adapter.queue.submit(std::iter::once(encoder.finish()));
-        output.present();
-
-        adapter.text_atlas.trim();
+        renderer.draw_frame_2d(Frame2D {
+            verts: &verts,
+            batches: &batches,
+            overlay_verts: &overlay_verts,
+            clear_color,
+        });
     }
 }
 
 impl<A: Application> Drop for EngineState<A> {
     fn drop(&mut self) {
-        self.wgpu_adapter = None;
-        self.render_pipeline = None;
-        self.vertex_buffer = None;
-        self.overlay_vertex_buffer = None;
+        self.renderer = None;
     }
 }
 
@@ -2837,12 +2688,9 @@ pub fn run<A: Application>() {
         layer_surface: None,
         surface: None,
         inner: None,
-        wgpu_adapter: None,
-        render_pipeline: None,
-        vertex_buffer: None,
-        vertex_count: 0,
-        overlay_vertex_buffer: None,
-        overlay_vertex_count: 0,
+        renderer: None,
+        font_system: None,
+        swash_cache: glyphon::SwashCache::new(),
         scale_factor: 1.0,
         logical_width: 0.0,
         logical_height: 0.0,
@@ -2926,7 +2774,7 @@ pub fn run<A: Application>() {
     }
     engine_state.surface = Some(surface);
 
-    pollster::block_on(engine_state.init_gpu(&conn, settings.width as f32, settings.height as f32));
+    engine_state.init_gpu(&conn, settings.width as f32, settings.height as f32);
 
     let mut event_loop = EventLoop::try_new().unwrap();
     let loop_handle = event_loop.handle();
diff --git a/src/lib.rs b/src/lib.rs
index d900814..03db058 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -12,6 +12,7 @@ pub mod scene;
 pub mod process;
 pub mod file_dialog;
 pub mod ipc;
+pub mod vk;
 
 pub mod colors {
     pub use crate::color::*;
diff --git a/src/vk/glyph.wgsl b/src/vk/glyph.wgsl
new file mode 100644
index 0000000..8cd26e0
--- /dev/null
+++ b/src/vk/glyph.wgsl
@@ -0,0 +1,42 @@
+// Glyph-atlas pipeline for the ash text stage. Mask glyphs are stored as
+// white-with-alpha texels, color (emoji) glyphs as-is with a white vertex
+// color — one multiply covers both.
+
+@group(0) @binding(0) var t_atlas: texture_2d<f32>;
+@group(0) @binding(1) var s_atlas: sampler;
+
+struct VertexOutput {
+    @builtin(position) clip_position: vec4f,
+    @location(0) uv: vec2f,
+    @location(1) color: vec4f,
+    @location(2) clip_circle: vec3f,
+}
+
+@vertex
+fn vs_main(
+    @location(0) position: vec2f,
+    @location(1) uv: vec2f,
+    @location(2) color: vec4f,
+    @location(3) clip_circle: vec3f,
+) -> VertexOutput {
+    var out: VertexOutput;
+    out.clip_position = vec4f(position, 0.0, 1.0);
+    out.uv = uv;
+    out.color = color;
+    out.clip_circle = clip_circle;
+    return out;
+}
+
+@fragment
+fn fs_main(in: VertexOutput) -> @location(0) vec4f {
+    // Same circle clip as shader.wgsl (framebuffer px): used by the circular
+    // network pane's curved rim labels.
+    if (in.clip_circle.z > 0.0) {
+        let dx = in.clip_position.x - in.clip_circle.x;
+        let dy = in.clip_position.y - in.clip_circle.y;
+        if (dx * dx + dy * dy > in.clip_circle.z * in.clip_circle.z) {
+            discard;
+        }
+    }
+    return in.color * textureSample(t_atlas, s_atlas, in.uv);
+}
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
new file mode 100644
index 0000000..d85f0d2
--- /dev/null
+++ b/src/vk/mod.rs
@@ -0,0 +1,32 @@
+//! The toolkit's raw-Vulkan (ash) renderer — the workspace-wide replacement for
+//! the wgpu backend, grown in cce-designer (milestones 1–3 + cutover) and moved
+//! here for general use.
+//!
+//! `VkRenderer` owns the whole stack: instance (+ validation layers in debug
+//! builds or with `CCE_VK_VALIDATION=1`), `VK_KHR_wayland_surface`, sRGB
+//! swapchain (premultiplied alpha, FIFO), gpu-allocator memory, and three
+//! pipelines compiled from WGSL through naga at startup:
+//!
+//! - **2D** (`shader2d.wgsl`): the union of the engine and designer dialects —
+//!   quads with circle clipping, the wavy-blob sentinel, window-corner
+//!   rounding (radius 0 disables), and blur-behind plates (negative alpha)
+//!   sampling the renderer-managed backdrop.
+//! - **Text** (`glyph.wgsl` + [`TextSpan`]): cosmic-text shaping (via glyphon's
+//!   re-export) + swash rasterization into a self-managed glyph atlas, with
+//!   per-span bounds clipping, rotation, and circle clipping.
+//! - **3D** (`scene3d.wgsl` + [`SceneDraw`]): handle-based [`Vertex3D`] meshes
+//!   with a depth buffer, rendered into the full-size backdrop image
+//!   (scissored to a pane) and copied beneath the UI pass — the same image is
+//!   the blur-behind source.
+//!
+//! The wgpu↔Vulkan Y-flip is a negative-height viewport (like wgpu-hal), NOT
+//! naga's ADJUST_COORDINATE_SPACE — a shader-side flip would reverse winding
+//! and break the 3D pipeline's back-face culling.
+
+mod renderer;
+mod scene;
+mod text;
+
+pub use renderer::{Batch2D, Frame2D, VkRenderer};
+pub use scene::{MeshId, SceneDraw, Vertex3D};
+pub use text::TextSpan;
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
new file mode 100644
index 0000000..3d0db38
--- /dev/null
+++ b/src/vk/renderer.rs
@@ -0,0 +1,1495 @@
+//! The ash renderer. One graphics queue, a classic render pass, two frames in
+//! flight, FIFO (vsync) presentation. Memory goes through gpu-allocator; the
+//! descriptor set mirrors `shader.wgsl`'s @group(0): sampled backdrop texture
+//! (binding 0), sampler (binding 1), WindowInfo uniform (binding 2). The backdrop
+//! is a 1x1 placeholder until the blur-behind path is wired to a real framebuffer
+//! copy at cutover.
+
+use std::ffi::{c_void, CStr, CString};
+
+use ash::vk;
+use gpu_allocator::vulkan::{
+    Allocation, AllocationCreateDesc, AllocationScheme, Allocator, AllocatorCreateDesc,
+};
+use gpu_allocator::MemoryLocation;
+
+use crate::engine::Vertex;
+
+use super::scene::{MeshId, SceneDraw, 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.
+pub struct Batch2D {
+    pub scissor: Option<(u32, u32, u32, u32)>,
+    pub start: u32,
+    pub end: u32,
+}
+
+/// 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],
+    pub clear_color: [f32; 4],
+}
+
+const FRAMES_IN_FLIGHT: usize = 2;
+const VALIDATION_LAYER: &CStr = c"VK_LAYER_KHRONOS_validation";
+
+pub(crate) struct AllocatedBuffer {
+    pub(crate) buffer: vk::Buffer,
+    pub(crate) allocation: Option<Allocation>,
+    pub(crate) size: vk::DeviceSize,
+}
+
+impl AllocatedBuffer {
+    pub(crate) fn null() -> Self {
+        AllocatedBuffer { buffer: vk::Buffer::null(), allocation: None, size: 0 }
+    }
+}
+
+/// Create a host-visible buffer bound to gpu-allocator memory.
+pub(crate) fn create_cpu_buffer(
+    device: &ash::Device,
+    allocator: &mut Allocator,
+    size: vk::DeviceSize,
+    usage: vk::BufferUsageFlags,
+    name: &str,
+) -> AllocatedBuffer {
+    unsafe {
+        let buffer = device
+            .create_buffer(
+                &vk::BufferCreateInfo::default()
+                    .size(size)
+                    .usage(usage)
+                    .sharing_mode(vk::SharingMode::EXCLUSIVE),
+                None,
+            )
+            .expect("Failed to create buffer");
+        let requirements = device.get_buffer_memory_requirements(buffer);
+        let allocation = allocator
+            .allocate(&AllocationCreateDesc {
+                name,
+                requirements,
+                location: MemoryLocation::CpuToGpu,
+                linear: true,
+                allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+            })
+            .expect("Failed to allocate buffer memory");
+        device
+            .bind_buffer_memory(buffer, allocation.memory(), allocation.offset())
+            .expect("Failed to bind buffer memory");
+        AllocatedBuffer { buffer, allocation: Some(allocation), size }
+    }
+}
+
+/// Destroy a buffer and return its memory to the allocator.
+pub(crate) fn destroy_cpu_buffer(
+    device: &ash::Device,
+    allocator: &mut Allocator,
+    buf: &mut AllocatedBuffer,
+) {
+    unsafe {
+        self::destroy_buffer_handle(device, buf.buffer);
+    }
+    if let Some(allocation) = buf.allocation.take() {
+        let _ = allocator.free(allocation);
+    }
+    buf.buffer = vk::Buffer::null();
+    buf.size = 0;
+}
+
+unsafe fn destroy_buffer_handle(device: &ash::Device, buffer: vk::Buffer) {
+    if buffer != vk::Buffer::null() {
+        device.destroy_buffer(buffer, None);
+    }
+}
+
+struct Frame {
+    cmd: vk::CommandBuffer,
+    image_available: vk::Semaphore,
+    in_flight: vk::Fence,
+    vertex: AllocatedBuffer,
+    vertex_count: u32,
+    overlay_start: u32,
+    overlay_count: u32,
+}
+
+pub struct VkRenderer {
+    _entry: ash::Entry,
+    instance: ash::Instance,
+    debug: Option<(ash::ext::debug_utils::Instance, vk::DebugUtilsMessengerEXT)>,
+    surface_loader: ash::khr::surface::Instance,
+    surface: vk::SurfaceKHR,
+    physical_device: vk::PhysicalDevice,
+    device: ash::Device,
+    queue: vk::Queue,
+    allocator: Option<Allocator>,
+
+    swapchain_loader: ash::khr::swapchain::Device,
+    swapchain: vk::SwapchainKHR,
+    surface_format: vk::SurfaceFormatKHR,
+    extent: vk::Extent2D,
+    swapchain_images: Vec<vk::Image>,
+    swapchain_views: Vec<vk::ImageView>,
+    framebuffers: Vec<vk::Framebuffer>,
+    // One per swapchain image (not per frame in flight): present waits on the
+    // semaphore tied to the image being presented.
+    render_finished: Vec<vk::Semaphore>,
+
+    render_pass: vk::RenderPass,
+    /// UI pass over a backdrop copy: loadOp LOAD, initial layout TRANSFER_DST.
+    /// Framebuffers are shared with `render_pass` (compatible attachments).
+    render_pass_load: vk::RenderPass,
+    descriptor_set_layout: vk::DescriptorSetLayout,
+    pipeline_layout: vk::PipelineLayout,
+    pipeline: vk::Pipeline,
+    shader_module: vk::ShaderModule,
+
+    descriptor_pool: vk::DescriptorPool,
+    descriptor_set: vk::DescriptorSet,
+    backdrop_sampler: vk::Sampler,
+    window_info: AllocatedBuffer,
+
+    command_pool: vk::CommandPool,
+    frames: Vec<Frame>,
+    frame_index: usize,
+    text: TextStage,
+    scene: SceneStage,
+
+    desired_extent: vk::Extent2D,
+    corner_radius_px: f32,
+    swapchain_dirty: bool,
+}
+
+/// Compile WGSL to SPIR-V. The Y-flip between wgpu NDC (Y-up) and Vulkan NDC
+/// (Y-down) is handled with a negative-height viewport (like wgpu-hal), NOT in
+/// the shader — flipping in the shader would reverse screen-space winding and
+/// break the 3D pipeline's back-face culling.
+pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
+    let module = naga::front::wgsl::parse_str(source).expect("WGSL parse failed");
+    let info = naga::valid::Validator::new(
+        naga::valid::ValidationFlags::all(),
+        naga::valid::Capabilities::empty(),
+    )
+    .validate(&module)
+    .expect("WGSL validation failed");
+    let options = naga::back::spv::Options {
+        lang_version: (1, 0),
+        flags: naga::back::spv::WriterFlags::LABEL_VARYINGS,
+        ..Default::default()
+    };
+    naga::back::spv::write_vec(&module, &info, &options, None).expect("SPIR-V write failed")
+}
+
+const COLOR_RANGE: vk::ImageSubresourceRange = vk::ImageSubresourceRange {
+    aspect_mask: vk::ImageAspectFlags::COLOR,
+    base_mip_level: 0,
+    level_count: 1,
+    base_array_layer: 0,
+    layer_count: 1,
+};
+
+/// One-time submit: clear a color image and leave it in SHADER_READ_ONLY, so a
+/// freshly created backdrop is always legal to sample.
+pub(crate) fn clear_image_to_shader_read(
+    device: &ash::Device,
+    queue: vk::Queue,
+    command_pool: vk::CommandPool,
+    image: vk::Image,
+) {
+    unsafe {
+        let cmd = device
+            .allocate_command_buffers(
+                &vk::CommandBufferAllocateInfo::default()
+                    .command_pool(command_pool)
+                    .level(vk::CommandBufferLevel::PRIMARY)
+                    .command_buffer_count(1),
+            )
+            .expect("Failed to allocate init command buffer")[0];
+        device
+            .begin_command_buffer(
+                cmd,
+                &vk::CommandBufferBeginInfo::default()
+                    .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
+            )
+            .unwrap();
+        device.cmd_pipeline_barrier(
+            cmd,
+            vk::PipelineStageFlags::TOP_OF_PIPE,
+            vk::PipelineStageFlags::TRANSFER,
+            vk::DependencyFlags::empty(),
+            &[],
+            &[],
+            &[vk::ImageMemoryBarrier::default()
+                .src_access_mask(vk::AccessFlags::empty())
+                .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+                .old_layout(vk::ImageLayout::UNDEFINED)
+                .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+                .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                .image(image)
+                .subresource_range(COLOR_RANGE)],
+        );
+        device.cmd_clear_color_image(
+            cmd,
+            image,
+            vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+            &vk::ClearColorValue { float32: [0.0, 0.0, 0.0, 0.0] },
+            &[COLOR_RANGE],
+        );
+        device.cmd_pipeline_barrier(
+            cmd,
+            vk::PipelineStageFlags::TRANSFER,
+            vk::PipelineStageFlags::FRAGMENT_SHADER,
+            vk::DependencyFlags::empty(),
+            &[],
+            &[],
+            &[vk::ImageMemoryBarrier::default()
+                .src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+                .dst_access_mask(vk::AccessFlags::SHADER_READ)
+                .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+                .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
+                .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                .image(image)
+                .subresource_range(COLOR_RANGE)],
+        );
+        device.end_command_buffer(cmd).unwrap();
+        let cmds = [cmd];
+        let submit = vk::SubmitInfo::default().command_buffers(&cmds);
+        device
+            .queue_submit(queue, &[submit], vk::Fence::null())
+            .expect("Init submit failed");
+        device.queue_wait_idle(queue).expect("Init wait failed");
+        device.free_command_buffers(command_pool, &cmds);
+    }
+}
+
+/// The wgpu-convention viewport: Y flipped via negative height (Vulkan >= 1.1).
+pub(crate) fn flipped_viewport(extent: vk::Extent2D) -> vk::Viewport {
+    vk::Viewport {
+        x: 0.0,
+        y: extent.height as f32,
+        width: extent.width as f32,
+        height: -(extent.height as f32),
+        min_depth: 0.0,
+        max_depth: 1.0,
+    }
+}
+
+unsafe extern "system" fn debug_callback(
+    severity: vk::DebugUtilsMessageSeverityFlagsEXT,
+    _types: vk::DebugUtilsMessageTypeFlagsEXT,
+    data: *const vk::DebugUtilsMessengerCallbackDataEXT<'_>,
+    _user_data: *mut c_void,
+) -> vk::Bool32 {
+    if data.is_null() {
+        return vk::FALSE;
+    }
+    let message = unsafe {
+        let p = (*data).p_message;
+        if p.is_null() {
+            return vk::FALSE;
+        }
+        CStr::from_ptr(p).to_string_lossy()
+    };
+    if severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR) {
+        log::error!("[vulkan] {message}");
+    } else if severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::WARNING) {
+        log::warn!("[vulkan] {message}");
+    } else {
+        log::debug!("[vulkan] {message}");
+    }
+    vk::FALSE
+}
+
+impl VkRenderer {
+    /// # Safety
+    /// `display_ptr` and `surface_ptr` must be live `wl_display` / `wl_surface`
+    /// pointers that outlive the renderer (same contract as `WgpuAdapter::new`).
+    pub unsafe fn new(
+        display_ptr: *mut c_void,
+        surface_ptr: *mut c_void,
+        width: u32,
+        height: u32,
+        corner_radius_px: f32,
+    ) -> Self {
+        let entry = ash::Entry::load().expect("Failed to load libvulkan");
+
+        // Instance, with validation when available (debug builds or CCE_VK_VALIDATION=1).
+        let want_validation =
+            cfg!(debug_assertions) || std::env::var_os("CCE_VK_VALIDATION").is_some();
+        let validation_available = want_validation
+            && entry
+                .enumerate_instance_layer_properties()
+                .map(|layers| {
+                    layers.iter().any(|l| {
+                        CStr::from_ptr(l.layer_name.as_ptr()) == VALIDATION_LAYER
+                    })
+                })
+                .unwrap_or(false);
+        if want_validation && !validation_available {
+            log::warn!("Vulkan validation requested but VK_LAYER_KHRONOS_validation is not installed");
+        }
+
+        let api_version = match entry.try_enumerate_instance_version().ok().flatten() {
+            Some(v) if v >= vk::API_VERSION_1_2 => vk::API_VERSION_1_2,
+            Some(v) => v,
+            None => vk::API_VERSION_1_0,
+        };
+        let app_name = c"cce-ui";
+        let app_info = vk::ApplicationInfo::default()
+            .application_name(app_name)
+            .engine_name(app_name)
+            .api_version(api_version);
+
+        let mut extension_names = vec![
+            ash::khr::surface::NAME.as_ptr(),
+            ash::khr::wayland_surface::NAME.as_ptr(),
+        ];
+        if validation_available {
+            extension_names.push(ash::ext::debug_utils::NAME.as_ptr());
+        }
+        let layer_names_owned: Vec<CString> = if validation_available {
+            vec![VALIDATION_LAYER.to_owned()]
+        } else {
+            Vec::new()
+        };
+        let layer_names: Vec<*const i8> =
+            layer_names_owned.iter().map(|l| l.as_ptr()).collect();
+
+        let instance = entry
+            .create_instance(
+                &vk::InstanceCreateInfo::default()
+                    .application_info(&app_info)
+                    .enabled_extension_names(&extension_names)
+                    .enabled_layer_names(&layer_names),
+                None,
+            )
+            .expect("Failed to create Vulkan instance");
+
+        let debug = if validation_available {
+            let loader = ash::ext::debug_utils::Instance::new(&entry, &instance);
+            let messenger = loader
+                .create_debug_utils_messenger(
+                    &vk::DebugUtilsMessengerCreateInfoEXT::default()
+                        .message_severity(
+                            vk::DebugUtilsMessageSeverityFlagsEXT::ERROR
+                                | vk::DebugUtilsMessageSeverityFlagsEXT::WARNING,
+                        )
+                        .message_type(
+                            vk::DebugUtilsMessageTypeFlagsEXT::GENERAL
+                                | vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION
+                                | vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE,
+                        )
+                        .pfn_user_callback(Some(debug_callback)),
+                    None,
+                )
+                .expect("Failed to create debug messenger");
+            log::info!("Vulkan validation layers enabled");
+            Some((loader, messenger))
+        } else {
+            None
+        };
+
+        // Wayland surface from the same raw pointers WgpuAdapter uses.
+        let wayland_loader = ash::khr::wayland_surface::Instance::new(&entry, &instance);
+        let surface = wayland_loader
+            .create_wayland_surface(
+                &vk::WaylandSurfaceCreateInfoKHR::default()
+                    .display(display_ptr)
+                    .surface(surface_ptr),
+                None,
+            )
+            .expect("Failed to create Wayland surface");
+        let surface_loader = ash::khr::surface::Instance::new(&entry, &instance);
+
+        // Physical device + queue family: graphics with present support on this
+        // surface. Prefer integrated (matches WgpuAdapter's LowPower preference).
+        let mut candidates: Vec<(vk::PhysicalDevice, u32, i32)> = Vec::new();
+        for pd in instance
+            .enumerate_physical_devices()
+            .expect("No Vulkan physical devices")
+        {
+            let families = instance.get_physical_device_queue_family_properties(pd);
+            let family = families.iter().enumerate().find_map(|(i, f)| {
+                let graphics = f.queue_flags.contains(vk::QueueFlags::GRAPHICS);
+                let present = surface_loader
+                    .get_physical_device_surface_support(pd, i as u32, surface)
+                    .unwrap_or(false);
+                (graphics && present).then_some(i as u32)
+            });
+            if let Some(family) = family {
+                let props = instance.get_physical_device_properties(pd);
+                let rank = match props.device_type {
+                    vk::PhysicalDeviceType::INTEGRATED_GPU => 0,
+                    vk::PhysicalDeviceType::DISCRETE_GPU => 1,
+                    vk::PhysicalDeviceType::VIRTUAL_GPU => 2,
+                    _ => 3,
+                };
+                candidates.push((pd, family, rank));
+            }
+        }
+        candidates.sort_by_key(|&(_, _, rank)| rank);
+        let (physical_device, queue_family, _) = *candidates
+            .first()
+            .expect("No Vulkan device supports this Wayland surface");
+        {
+            let props = instance.get_physical_device_properties(physical_device);
+            let name = CStr::from_ptr(props.device_name.as_ptr()).to_string_lossy();
+            log::info!("Vulkan device: {name}");
+        }
+
+        let queue_priorities = [1.0f32];
+        let queue_infos = [vk::DeviceQueueCreateInfo::default()
+            .queue_family_index(queue_family)
+            .queue_priorities(&queue_priorities)];
+        let device_extensions = [ash::khr::swapchain::NAME.as_ptr()];
+        let device = instance
+            .create_device(
+                physical_device,
+                &vk::DeviceCreateInfo::default()
+                    .queue_create_infos(&queue_infos)
+                    .enabled_extension_names(&device_extensions),
+                None,
+            )
+            .expect("Failed to create Vulkan device");
+        let queue = device.get_device_queue(queue_family, 0);
+
+        let mut allocator = Allocator::new(&AllocatorCreateDesc {
+            instance: instance.clone(),
+            device: device.clone(),
+            physical_device,
+            debug_settings: Default::default(),
+            buffer_device_address: false,
+            allocation_sizes: Default::default(),
+        })
+        .expect("Failed to create GPU allocator");
+
+        // Surface format: prefer sRGB (wgpu's get_default_config sorts sRGB first,
+        // so this matches the colors the app renders today).
+        let formats = surface_loader
+            .get_physical_device_surface_formats(physical_device, surface)
+            .expect("No surface formats");
+        let surface_format = formats
+            .iter()
+            .copied()
+            .find(|f| {
+                (f.format == vk::Format::B8G8R8A8_SRGB || f.format == vk::Format::R8G8B8A8_SRGB)
+                    && f.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR
+            })
+            .unwrap_or(formats[0]);
+
+        // Render pass: one color attachment, clear -> present.
+        let attachments = [vk::AttachmentDescription::default()
+            .format(surface_format.format)
+            .samples(vk::SampleCountFlags::TYPE_1)
+            .load_op(vk::AttachmentLoadOp::CLEAR)
+            .store_op(vk::AttachmentStoreOp::STORE)
+            .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
+            .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
+            .initial_layout(vk::ImageLayout::UNDEFINED)
+            .final_layout(vk::ImageLayout::PRESENT_SRC_KHR)];
+        let color_refs = [vk::AttachmentReference::default()
+            .attachment(0)
+            .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
+        let subpasses = [vk::SubpassDescription::default()
+            .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
+            .color_attachments(&color_refs)];
+        // One dependency shared VERBATIM by both UI pass variants: framebuffer
+        // compatibility requires identical dependencies (only load/store ops and
+        // image layouts may differ), so this unions the clear case (previous
+        // frame's color output) with the load case (the backdrop copy's write).
+        let dependencies = [vk::SubpassDependency::default()
+            .src_subpass(vk::SUBPASS_EXTERNAL)
+            .dst_subpass(0)
+            .src_stage_mask(
+                vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
+                    | vk::PipelineStageFlags::TRANSFER,
+            )
+            .src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+            .dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
+            .dst_access_mask(
+                vk::AccessFlags::COLOR_ATTACHMENT_READ | vk::AccessFlags::COLOR_ATTACHMENT_WRITE,
+            )];
+        let render_pass = device
+            .create_render_pass(
+                &vk::RenderPassCreateInfo::default()
+                    .attachments(&attachments)
+                    .subpasses(&subpasses)
+                    .dependencies(&dependencies),
+                None,
+            )
+            .expect("Failed to create render pass");
+
+        // Variant used when a backdrop copy precedes the UI pass: keep the copied
+        // pixels (LOAD) and take the image from the copy's TRANSFER_DST layout.
+        let attachments_load = [vk::AttachmentDescription::default()
+            .format(surface_format.format)
+            .samples(vk::SampleCountFlags::TYPE_1)
+            .load_op(vk::AttachmentLoadOp::LOAD)
+            .store_op(vk::AttachmentStoreOp::STORE)
+            .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
+            .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
+            .initial_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+            .final_layout(vk::ImageLayout::PRESENT_SRC_KHR)];
+        let render_pass_load = device
+            .create_render_pass(
+                &vk::RenderPassCreateInfo::default()
+                    .attachments(&attachments_load)
+                    .subpasses(&subpasses)
+                    .dependencies(&dependencies),
+                None,
+            )
+            .expect("Failed to create load render pass");
+
+        // Descriptor set layout mirroring shader.wgsl @group(0): naga maps WGSL
+        // texture/sampler/uniform bindings 1:1 onto set 0 descriptor bindings.
+        let bindings = [
+            vk::DescriptorSetLayoutBinding::default()
+                .binding(0)
+                .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+                .descriptor_count(1)
+                .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+            vk::DescriptorSetLayoutBinding::default()
+                .binding(1)
+                .descriptor_type(vk::DescriptorType::SAMPLER)
+                .descriptor_count(1)
+                .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+            vk::DescriptorSetLayoutBinding::default()
+                .binding(2)
+                .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+                .descriptor_count(1)
+                .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+        ];
+        let descriptor_set_layout = device
+            .create_descriptor_set_layout(
+                &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
+                None,
+            )
+            .expect("Failed to create descriptor set layout");
+
+        let set_layouts = [descriptor_set_layout];
+        let pipeline_layout = device
+            .create_pipeline_layout(
+                &vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts),
+                None,
+            )
+            .expect("Failed to create pipeline layout");
+
+        // Pipeline from shader.wgsl (both entry points live in one SPIR-V module).
+        let spirv = compile_wgsl(include_str!("shader2d.wgsl"));
+        let shader_module = device
+            .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spirv), None)
+            .expect("Failed to create shader module");
+
+        let stages = [
+            vk::PipelineShaderStageCreateInfo::default()
+                .stage(vk::ShaderStageFlags::VERTEX)
+                .module(shader_module)
+                .name(c"vs_main"),
+            vk::PipelineShaderStageCreateInfo::default()
+                .stage(vk::ShaderStageFlags::FRAGMENT)
+                .module(shader_module)
+                .name(c"fs_main"),
+        ];
+
+        // Vertex layout = cce_ui::engine::Vertex: pos vec2f, color vec4f, clip vec3f.
+        let vertex_bindings = [vk::VertexInputBindingDescription::default()
+            .binding(0)
+            .stride(std::mem::size_of::<Vertex>() as u32)
+            .input_rate(vk::VertexInputRate::VERTEX)];
+        let vertex_attributes = [
+            vk::VertexInputAttributeDescription::default()
+                .location(0)
+                .binding(0)
+                .format(vk::Format::R32G32_SFLOAT)
+                .offset(0),
+            vk::VertexInputAttributeDescription::default()
+                .location(1)
+                .binding(0)
+                .format(vk::Format::R32G32B32A32_SFLOAT)
+                .offset(8),
+            vk::VertexInputAttributeDescription::default()
+                .location(2)
+                .binding(0)
+                .format(vk::Format::R32G32B32_SFLOAT)
+                .offset(24),
+        ];
+        let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
+            .vertex_binding_descriptions(&vertex_bindings)
+            .vertex_attribute_descriptions(&vertex_attributes);
+
+        let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
+            .topology(vk::PrimitiveTopology::TRIANGLE_LIST);
+        let viewport_state = vk::PipelineViewportStateCreateInfo::default()
+            .viewport_count(1)
+            .scissor_count(1);
+        let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
+            .polygon_mode(vk::PolygonMode::FILL)
+            .cull_mode(vk::CullModeFlags::NONE)
+            .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
+            .line_width(1.0);
+        let multisample = vk::PipelineMultisampleStateCreateInfo::default()
+            .rasterization_samples(vk::SampleCountFlags::TYPE_1);
+        // wgpu::BlendState::ALPHA_BLENDING.
+        let blend_attachments = [vk::PipelineColorBlendAttachmentState::default()
+            .blend_enable(true)
+            .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
+            .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+            .color_blend_op(vk::BlendOp::ADD)
+            .src_alpha_blend_factor(vk::BlendFactor::ONE)
+            .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+            .alpha_blend_op(vk::BlendOp::ADD)
+            .color_write_mask(vk::ColorComponentFlags::RGBA)];
+        let color_blend =
+            vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments);
+        let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
+        let dynamic_state =
+            vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
+
+        let pipeline = device
+            .create_graphics_pipelines(
+                vk::PipelineCache::null(),
+                &[vk::GraphicsPipelineCreateInfo::default()
+                    .stages(&stages)
+                    .vertex_input_state(&vertex_input)
+                    .input_assembly_state(&input_assembly)
+                    .viewport_state(&viewport_state)
+                    .rasterization_state(&rasterization)
+                    .multisample_state(&multisample)
+                    .color_blend_state(&color_blend)
+                    .dynamic_state(&dynamic_state)
+                    .layout(pipeline_layout)
+                    .render_pass(render_pass)
+                    .subpass(0)],
+                None,
+            )
+            .expect("Failed to create graphics pipeline")[0];
+
+        let command_pool = device
+            .create_command_pool(
+                &vk::CommandPoolCreateInfo::default()
+                    .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER)
+                    .queue_family_index(queue_family),
+                None,
+            )
+            .expect("Failed to create command pool");
+
+        // Full-size backdrop + depth live in the scene stage: the 3D pass renders
+        // into the backdrop, and the UI pass samples it for blur-behind plates.
+        let min_uniform_align = instance
+            .get_physical_device_properties(physical_device)
+            .limits
+            .min_uniform_buffer_offset_alignment;
+        let initial_extent = vk::Extent2D { width: width.max(1), height: height.max(1) };
+        let scene = SceneStage::new(
+            &device,
+            &mut allocator,
+            surface_format.format,
+            initial_extent,
+            FRAMES_IN_FLIGHT,
+            min_uniform_align,
+        );
+        clear_image_to_shader_read(&device, queue, command_pool, scene.backdrop_image);
+
+        // Matches the wgpu backdrop sampler: linear, clamp-to-edge.
+        let backdrop_sampler = device
+            .create_sampler(
+                &vk::SamplerCreateInfo::default()
+                    .mag_filter(vk::Filter::LINEAR)
+                    .min_filter(vk::Filter::LINEAR)
+                    .mipmap_mode(vk::SamplerMipmapMode::NEAREST)
+                    .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
+                    .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
+                    .address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE),
+                None,
+            )
+            .expect("Failed to create sampler");
+
+        let window_info = create_cpu_buffer(
+            &device,
+            &mut allocator,
+            16,
+            vk::BufferUsageFlags::UNIFORM_BUFFER,
+            "window-info",
+        );
+
+        let pool_sizes = [
+            vk::DescriptorPoolSize::default()
+                .ty(vk::DescriptorType::SAMPLED_IMAGE)
+                .descriptor_count(1),
+            vk::DescriptorPoolSize::default()
+                .ty(vk::DescriptorType::SAMPLER)
+                .descriptor_count(1),
+            vk::DescriptorPoolSize::default()
+                .ty(vk::DescriptorType::UNIFORM_BUFFER)
+                .descriptor_count(1),
+        ];
+        let descriptor_pool = device
+            .create_descriptor_pool(
+                &vk::DescriptorPoolCreateInfo::default()
+                    .max_sets(1)
+                    .pool_sizes(&pool_sizes),
+                None,
+            )
+            .expect("Failed to create descriptor pool");
+        let descriptor_set = device
+            .allocate_descriptor_sets(
+                &vk::DescriptorSetAllocateInfo::default()
+                    .descriptor_pool(descriptor_pool)
+                    .set_layouts(&set_layouts),
+            )
+            .expect("Failed to allocate descriptor set")[0];
+
+        let image_infos = [vk::DescriptorImageInfo::default()
+            .image_view(scene.backdrop_view)
+            .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
+        let sampler_infos = [vk::DescriptorImageInfo::default().sampler(backdrop_sampler)];
+        let buffer_infos = [vk::DescriptorBufferInfo::default()
+            .buffer(window_info.buffer)
+            .offset(0)
+            .range(16)];
+        device.update_descriptor_sets(
+            &[
+                vk::WriteDescriptorSet::default()
+                    .dst_set(descriptor_set)
+                    .dst_binding(0)
+                    .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+                    .image_info(&image_infos),
+                vk::WriteDescriptorSet::default()
+                    .dst_set(descriptor_set)
+                    .dst_binding(1)
+                    .descriptor_type(vk::DescriptorType::SAMPLER)
+                    .image_info(&sampler_infos),
+                vk::WriteDescriptorSet::default()
+                    .dst_set(descriptor_set)
+                    .dst_binding(2)
+                    .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+                    .buffer_info(&buffer_infos),
+            ],
+            &[],
+        );
+
+        // Per-frame command buffers, sync, and vertex buffers.
+        let cmds = device
+            .allocate_command_buffers(
+                &vk::CommandBufferAllocateInfo::default()
+                    .command_pool(command_pool)
+                    .level(vk::CommandBufferLevel::PRIMARY)
+                    .command_buffer_count(FRAMES_IN_FLIGHT as u32),
+            )
+            .expect("Failed to allocate command buffers");
+        let frames = cmds
+            .into_iter()
+            .map(|cmd| Frame {
+                cmd,
+                image_available: device
+                    .create_semaphore(&vk::SemaphoreCreateInfo::default(), None)
+                    .unwrap(),
+                in_flight: device
+                    .create_fence(
+                        &vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED),
+                        None,
+                    )
+                    .unwrap(),
+                vertex: create_cpu_buffer(
+                    &device,
+                    &mut allocator,
+                    64 * 1024,
+                    vk::BufferUsageFlags::VERTEX_BUFFER,
+                    "vertices",
+                ),
+                vertex_count: 0,
+                overlay_start: 0,
+                overlay_count: 0,
+            })
+            .collect();
+
+        let text = TextStage::new(&device, &mut allocator, render_pass, FRAMES_IN_FLIGHT);
+
+        let swapchain_loader = ash::khr::swapchain::Device::new(&instance, &device);
+        let mut renderer = Self {
+            _entry: entry,
+            instance,
+            debug,
+            surface_loader,
+            surface,
+            physical_device,
+            device,
+            queue,
+            allocator: Some(allocator),
+            swapchain_loader,
+            swapchain: vk::SwapchainKHR::null(),
+            swapchain_images: Vec::new(),
+            surface_format,
+            extent: vk::Extent2D { width: width.max(1), height: height.max(1) },
+            swapchain_views: Vec::new(),
+            framebuffers: Vec::new(),
+            render_finished: Vec::new(),
+            render_pass,
+            render_pass_load,
+            descriptor_set_layout,
+            pipeline_layout,
+            pipeline,
+            shader_module,
+            descriptor_pool,
+            descriptor_set,
+            backdrop_sampler,
+            window_info,
+            command_pool,
+            frames,
+            frame_index: 0,
+            text,
+            scene,
+            desired_extent: vk::Extent2D { width: width.max(1), height: height.max(1) },
+            corner_radius_px,
+            swapchain_dirty: false,
+        };
+        renderer.create_swapchain();
+        renderer.write_window_info();
+        // The swapchain may have settled on a different extent than requested;
+        // keep the backdrop targets in lockstep.
+        renderer.sync_backdrop_targets();
+        renderer
+    }
+
+    fn write_window_info(&mut self) {
+        let data = [
+            self.extent.width as f32,
+            self.extent.height as f32,
+            self.corner_radius_px,
+            0.0f32,
+        ];
+        if let Some(allocation) = self.window_info.allocation.as_mut() {
+            allocation.mapped_slice_mut().unwrap()[..16]
+                .copy_from_slice(bytemuck::cast_slice(&data));
+        }
+    }
+
+    fn destroy_swapchain_resources(&mut self) {
+        unsafe {
+            for fb in self.framebuffers.drain(..) {
+                self.device.destroy_framebuffer(fb, None);
+            }
+            for view in self.swapchain_views.drain(..) {
+                self.device.destroy_image_view(view, None);
+            }
+            self.swapchain_images.clear();
+            for sem in self.render_finished.drain(..) {
+                self.device.destroy_semaphore(sem, None);
+            }
+        }
+    }
+
+    fn create_swapchain(&mut self) {
+        unsafe {
+            let caps = self
+                .surface_loader
+                .get_physical_device_surface_capabilities(self.physical_device, self.surface)
+                .expect("Failed to query surface capabilities");
+
+            // Wayland reports "extent defined by the swapchain" (u32::MAX); use the
+            // size the configure events gave us.
+            let extent = if caps.current_extent.width != u32::MAX {
+                caps.current_extent
+            } else {
+                vk::Extent2D {
+                    width: self
+                        .desired_extent
+                        .width
+                        .clamp(caps.min_image_extent.width, caps.max_image_extent.width.max(1)),
+                    height: self
+                        .desired_extent
+                        .height
+                        .clamp(caps.min_image_extent.height, caps.max_image_extent.height.max(1)),
+                }
+            };
+
+            let mut image_count = caps.min_image_count + 1;
+            if caps.max_image_count > 0 {
+                image_count = image_count.min(caps.max_image_count);
+            }
+
+            // Prefer premultiplied (what the DE's other clients pick), else opaque,
+            // else whatever the surface offers.
+            let composite_alpha = [
+                vk::CompositeAlphaFlagsKHR::PRE_MULTIPLIED,
+                vk::CompositeAlphaFlagsKHR::OPAQUE,
+                vk::CompositeAlphaFlagsKHR::POST_MULTIPLIED,
+                vk::CompositeAlphaFlagsKHR::INHERIT,
+            ]
+            .into_iter()
+            .find(|&mode| caps.supported_composite_alpha.contains(mode))
+            .unwrap_or(vk::CompositeAlphaFlagsKHR::OPAQUE);
+
+            let old_swapchain = self.swapchain;
+            self.swapchain = self
+                .swapchain_loader
+                .create_swapchain(
+                    &vk::SwapchainCreateInfoKHR::default()
+                        .surface(self.surface)
+                        .min_image_count(image_count)
+                        .image_format(self.surface_format.format)
+                        .image_color_space(self.surface_format.color_space)
+                        .image_extent(extent)
+                        .image_array_layers(1)
+                        .image_usage(
+                            vk::ImageUsageFlags::COLOR_ATTACHMENT
+                                | vk::ImageUsageFlags::TRANSFER_DST,
+                        )
+                        .image_sharing_mode(vk::SharingMode::EXCLUSIVE)
+                        .pre_transform(caps.current_transform)
+                        .composite_alpha(composite_alpha)
+                        .present_mode(vk::PresentModeKHR::FIFO)
+                        .clipped(true)
+                        .old_swapchain(old_swapchain),
+                    None,
+                )
+                .expect("Failed to create swapchain");
+            if old_swapchain != vk::SwapchainKHR::null() {
+                self.swapchain_loader.destroy_swapchain(old_swapchain, None);
+            }
+            self.extent = extent;
+
+            let images = self
+                .swapchain_loader
+                .get_swapchain_images(self.swapchain)
+                .expect("Failed to get swapchain images");
+            self.swapchain_images = images.clone();
+            let subresource_range = vk::ImageSubresourceRange::default()
+                .aspect_mask(vk::ImageAspectFlags::COLOR)
+                .base_mip_level(0)
+                .level_count(1)
+                .base_array_layer(0)
+                .layer_count(1);
+            for image in &images {
+                let view = self
+                    .device
+                    .create_image_view(
+                        &vk::ImageViewCreateInfo::default()
+                            .image(*image)
+                            .view_type(vk::ImageViewType::TYPE_2D)
+                            .format(self.surface_format.format)
+                            .subresource_range(subresource_range),
+                        None,
+                    )
+                    .expect("Failed to create swapchain view");
+                self.swapchain_views.push(view);
+                let attachments = [view];
+                let fb = self
+                    .device
+                    .create_framebuffer(
+                        &vk::FramebufferCreateInfo::default()
+                            .render_pass(self.render_pass)
+                            .attachments(&attachments)
+                            .width(extent.width)
+                            .height(extent.height)
+                            .layers(1),
+                        None,
+                    )
+                    .expect("Failed to create framebuffer");
+                self.framebuffers.push(fb);
+                self.render_finished.push(
+                    self.device
+                        .create_semaphore(&vk::SemaphoreCreateInfo::default(), None)
+                        .unwrap(),
+                );
+            }
+        }
+    }
+
+    fn recreate_swapchain(&mut self) {
+        unsafe {
+            let _ = self.device.device_wait_idle();
+        }
+        self.destroy_swapchain_resources();
+        self.create_swapchain();
+        self.write_window_info();
+        self.sync_backdrop_targets();
+    }
+
+    /// Recreate backdrop + depth at the surface size (device must be idle),
+    /// re-point the UI descriptor at the new view, and make the fresh image
+    /// legal to sample.
+    fn sync_backdrop_targets(&mut self) {
+        self.scene.resize(
+            &self.device,
+            self.allocator.as_mut().unwrap(),
+            self.extent,
+        );
+        clear_image_to_shader_read(
+            &self.device,
+            self.queue,
+            self.command_pool,
+            self.scene.backdrop_image,
+        );
+        let image_infos = [vk::DescriptorImageInfo::default()
+            .image_view(self.scene.backdrop_view)
+            .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
+        unsafe {
+            self.device.update_descriptor_sets(
+                &[vk::WriteDescriptorSet::default()
+                    .dst_set(self.descriptor_set)
+                    .dst_binding(0)
+                    .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+                    .image_info(&image_infos)],
+                &[],
+            );
+        }
+    }
+
+    /// Upload a 3D mesh (Vertex3D: position + color); the id is stable for the
+    /// renderer's lifetime.
+    pub fn create_mesh(&mut self, verts: &[Vertex3D]) -> MeshId {
+        self.scene
+            .create_mesh(&self.device, self.allocator.as_mut().unwrap(), verts)
+    }
+
+    /// Replace a mesh's vertices. Waits for the GPU to go idle first — geometry
+    /// updates are rare (settings changes, graph rebuilds), matching the app.
+    #[allow(dead_code)] // cutover API: the app's rebuild_scene_geometry path
+    pub fn update_mesh(&mut self, id: MeshId, verts: &[Vertex3D]) {
+        unsafe {
+            let _ = self.device.device_wait_idle();
+        }
+        self.scene
+            .update_mesh(&self.device, self.allocator.as_mut().unwrap(), id, verts);
+    }
+
+    /// Stage the 3D scene for the next `draw_frame`. Draws render into the
+    /// backdrop image (scissored to the viewport pane, physical pixels), which
+    /// is copied beneath the UI and doubles as the blur-behind source. Frames
+    /// with no staged scene reuse the previous backdrop — the ash equivalent of
+    /// the app's viewport-changed cache.
+    pub fn stage_scene(&mut self, scissor: (u32, u32, u32, u32), draws: Vec<SceneDraw>) {
+        self.scene.stage(scissor, draws);
+    }
+
+    /// Request a new physical size (from xdg configure / scale changes). Applied
+    /// lazily on the next `draw_frame`.
+    pub fn resize(&mut self, width: u32, height: u32) {
+        let extent = vk::Extent2D { width: width.max(1), height: height.max(1) };
+        if extent.width != self.extent.width || extent.height != self.extent.height {
+            self.desired_extent = extent;
+            self.swapchain_dirty = true;
+        }
+    }
+
+    // Used at cutover, when scale changes re-derive the radius; vk-smoke fixes it at init.
+    #[allow(dead_code)]
+    pub fn set_corner_radius(&mut self, radius_px: f32) {
+        self.corner_radius_px = radius_px;
+        // Written on the next swapchain rebuild or draw-idle moment; a mapped write
+        // here would race in-flight frames, so route it through the dirty path.
+        self.swapchain_dirty = true;
+    }
+
+    /// Stage text for the next `draw_frame`: shape-cache misses are rasterized
+    /// into the glyph atlas and vertices are built against the current extent.
+    /// Mirrors `glyphon::TextRenderer::prepare`.
+    pub fn prepare_text(
+        &mut self,
+        font_system: &mut glyphon::FontSystem,
+        swash_cache: &mut glyphon::SwashCache,
+        spans: &[TextSpan<'_>],
+    ) {
+        self.text.prepare(font_system, swash_cache, spans, self.extent);
+    }
+
+    /// Render one frame of plain 2D geometry: a single unclipped batch, no
+    /// overlay, transparent clear. See [`VkRenderer::draw_frame_2d`].
+    pub fn draw_frame(&mut self, verts: &[Vertex]) -> bool {
+        self.draw_frame_2d(Frame2D {
+            verts,
+            batches: &[],
+            overlay_verts: &[],
+            clear_color: [0.0; 4],
+        })
+    }
+
+    /// Render one frame: the 2D geometry (optionally as scissored batches),
+    /// then any text staged via `prepare_text`, then the overlay vertices on
+    /// top. Returns false if the frame was skipped (swapchain rebuild); the
+    /// caller just draws again next tick.
+    pub fn draw_frame_2d(&mut self, frame2d: Frame2D<'_>) -> bool {
+        if self.swapchain_dirty {
+            self.swapchain_dirty = false;
+            self.recreate_swapchain();
+        }
+
+        unsafe {
+            let frame_index = self.frame_index;
+            let (in_flight, image_available) = {
+                let f = &self.frames[frame_index];
+                (f.in_flight, f.image_available)
+            };
+            self.device
+                .wait_for_fences(&[in_flight], true, u64::MAX)
+                .expect("Fence wait failed");
+
+            let image_index = match self.swapchain_loader.acquire_next_image(
+                self.swapchain,
+                u64::MAX,
+                image_available,
+                vk::Fence::null(),
+            ) {
+                Ok((index, suboptimal)) => {
+                    if suboptimal {
+                        self.swapchain_dirty = true;
+                    }
+                    index
+                }
+                Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
+                    self.swapchain_dirty = true;
+                    return false;
+                }
+                Err(e) => {
+                    log::error!("acquire_next_image failed: {e:?}");
+                    return false;
+                }
+            };
+
+            self.device.reset_fences(&[in_flight]).unwrap();
+
+            // Upload display-list + overlay vertices into this frame's buffer
+            // (its fence has signaled, so the GPU is done with it; growing swaps
+            // in a fresh buffer). Overlay verts sit after the main range.
+            let vert_bytes: &[u8] = bytemuck::cast_slice(frame2d.verts);
+            let overlay_bytes: &[u8] = bytemuck::cast_slice(frame2d.overlay_verts);
+            let needed = (vert_bytes.len() + overlay_bytes.len()) as vk::DeviceSize;
+            if needed > self.frames[frame_index].vertex.size {
+                let mut old =
+                    std::mem::replace(&mut self.frames[frame_index].vertex, AllocatedBuffer::null());
+                let allocator = self.allocator.as_mut().unwrap();
+                destroy_cpu_buffer(&self.device, allocator, &mut old);
+                self.frames[frame_index].vertex = create_cpu_buffer(
+                    &self.device,
+                    allocator,
+                    needed.next_power_of_two(),
+                    vk::BufferUsageFlags::VERTEX_BUFFER,
+                    "vertices",
+                );
+            }
+            if needed > 0 {
+                let mapped = self.frames[frame_index]
+                    .vertex
+                    .allocation
+                    .as_mut()
+                    .unwrap()
+                    .mapped_slice_mut()
+                    .unwrap();
+                mapped[..vert_bytes.len()].copy_from_slice(vert_bytes);
+                mapped[vert_bytes.len()..vert_bytes.len() + overlay_bytes.len()]
+                    .copy_from_slice(overlay_bytes);
+            }
+            self.frames[frame_index].vertex_count = frame2d.verts.len() as u32;
+            self.frames[frame_index].overlay_start = frame2d.verts.len() as u32;
+            self.frames[frame_index].overlay_count = frame2d.overlay_verts.len() as u32;
+            self.text.write_frame_buffers(
+                &self.device,
+                self.allocator.as_mut().unwrap(),
+                frame_index,
+            );
+            self.scene.write_frame_uniforms(
+                &self.device,
+                self.allocator.as_mut().unwrap(),
+                frame_index,
+                self.corner_radius_px,
+            );
+
+            // Record.
+            let cmd = self.frames[frame_index].cmd;
+            self.device
+                .begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default())
+                .unwrap();
+            self.text.record_upload(&self.device, cmd, frame_index);
+
+            // Offscreen 3D pass (only when a scene was staged); leaves the
+            // backdrop in TRANSFER_SRC.
+            let scene_recorded = self.scene.record(&self.device, cmd, frame_index);
+
+            // With a valid backdrop, replay it under the UI: copy it into the
+            // swapchain image and open the UI pass with LOAD instead of CLEAR.
+            let use_backdrop = self.scene.backdrop_valid;
+            if use_backdrop {
+                if !scene_recorded {
+                    // Reused backdrop is in SHADER_READ_ONLY from last frame.
+                    self.device.cmd_pipeline_barrier(
+                        cmd,
+                        vk::PipelineStageFlags::FRAGMENT_SHADER,
+                        vk::PipelineStageFlags::TRANSFER,
+                        vk::DependencyFlags::empty(),
+                        &[],
+                        &[],
+                        &[vk::ImageMemoryBarrier::default()
+                            .src_access_mask(vk::AccessFlags::SHADER_READ)
+                            .dst_access_mask(vk::AccessFlags::TRANSFER_READ)
+                            .old_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
+                            .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
+                            .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                            .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                            .image(self.scene.backdrop_image)
+                            .subresource_range(COLOR_RANGE)],
+                    );
+                }
+                let swapchain_image = self.swapchain_images[image_index as usize];
+                self.device.cmd_pipeline_barrier(
+                    cmd,
+                    vk::PipelineStageFlags::TOP_OF_PIPE,
+                    vk::PipelineStageFlags::TRANSFER,
+                    vk::DependencyFlags::empty(),
+                    &[],
+                    &[],
+                    &[vk::ImageMemoryBarrier::default()
+                        .src_access_mask(vk::AccessFlags::empty())
+                        .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+                        .old_layout(vk::ImageLayout::UNDEFINED)
+                        .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+                        .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                        .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                        .image(swapchain_image)
+                        .subresource_range(COLOR_RANGE)],
+                );
+                let subresource = vk::ImageSubresourceLayers::default()
+                    .aspect_mask(vk::ImageAspectFlags::COLOR)
+                    .layer_count(1);
+                self.device.cmd_copy_image(
+                    cmd,
+                    self.scene.backdrop_image,
+                    vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
+                    swapchain_image,
+                    vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+                    &[vk::ImageCopy::default()
+                        .src_subresource(subresource)
+                        .dst_subresource(subresource)
+                        .extent(vk::Extent3D {
+                            width: self.extent.width,
+                            height: self.extent.height,
+                            depth: 1,
+                        })],
+                );
+                // Backdrop back to sampleable for the UI pass's blur plates.
+                self.device.cmd_pipeline_barrier(
+                    cmd,
+                    vk::PipelineStageFlags::TRANSFER,
+                    vk::PipelineStageFlags::FRAGMENT_SHADER,
+                    vk::DependencyFlags::empty(),
+                    &[],
+                    &[],
+                    &[vk::ImageMemoryBarrier::default()
+                        .src_access_mask(vk::AccessFlags::TRANSFER_READ)
+                        .dst_access_mask(vk::AccessFlags::SHADER_READ)
+                        .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
+                        .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
+                        .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                        .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                        .image(self.scene.backdrop_image)
+                        .subresource_range(COLOR_RANGE)],
+                );
+            }
+
+            let frame = &self.frames[frame_index];
+            let clear_values = [vk::ClearValue {
+                color: vk::ClearColorValue { float32: frame2d.clear_color },
+            }];
+            let (ui_pass, ui_clear_values): (vk::RenderPass, &[vk::ClearValue]) = if use_backdrop {
+                (self.render_pass_load, &[])
+            } else {
+                (self.render_pass, &clear_values)
+            };
+            self.device.cmd_begin_render_pass(
+                cmd,
+                &vk::RenderPassBeginInfo::default()
+                    .render_pass(ui_pass)
+                    .framebuffer(self.framebuffers[image_index as usize])
+                    .render_area(vk::Rect2D {
+                        offset: vk::Offset2D { x: 0, y: 0 },
+                        extent: self.extent,
+                    })
+                    .clear_values(ui_clear_values),
+                vk::SubpassContents::INLINE,
+            );
+            self.device
+                .cmd_set_viewport(cmd, 0, &[flipped_viewport(self.extent)]);
+            self.device.cmd_set_scissor(
+                cmd,
+                0,
+                &[vk::Rect2D {
+                    offset: vk::Offset2D { x: 0, y: 0 },
+                    extent: self.extent,
+                }],
+            );
+            let full_scissor = vk::Rect2D {
+                offset: vk::Offset2D { x: 0, y: 0 },
+                extent: self.extent,
+            };
+            if frame.vertex_count > 0 {
+                self.device
+                    .cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline);
+                self.device.cmd_bind_descriptor_sets(
+                    cmd,
+                    vk::PipelineBindPoint::GRAPHICS,
+                    self.pipeline_layout,
+                    0,
+                    &[self.descriptor_set],
+                    &[],
+                );
+                self.device
+                    .cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
+                if frame2d.batches.is_empty() {
+                    self.device.cmd_draw(cmd, frame.vertex_count, 1, 0, 0);
+                } else {
+                    // Each batch draws its range under its own scissor.
+                    for batch in frame2d.batches {
+                        if batch.end <= batch.start {
+                            continue;
+                        }
+                        match batch.scissor {
+                            Some((bx, by, bw, bh)) => {
+                                if bx >= self.extent.width || by >= self.extent.height {
+                                    continue;
+                                }
+                                let bw = bw.min(self.extent.width - bx);
+                                let bh = bh.min(self.extent.height - by);
+                                if bw == 0 || bh == 0 {
+                                    continue;
+                                }
+                                self.device.cmd_set_scissor(
+                                    cmd,
+                                    0,
+                                    &[vk::Rect2D {
+                                        offset: vk::Offset2D { x: bx as i32, y: by as i32 },
+                                        extent: vk::Extent2D { width: bw, height: bh },
+                                    }],
+                                );
+                            }
+                            None => self.device.cmd_set_scissor(cmd, 0, &[full_scissor]),
+                        }
+                        self.device
+                            .cmd_draw(cmd, batch.end - batch.start, 1, batch.start, 0);
+                    }
+                    // Restore for the text/overlay draws.
+                    self.device.cmd_set_scissor(cmd, 0, &[full_scissor]);
+                }
+            }
+            self.text.record_draw(&self.device, cmd, frame_index);
+            if frame.overlay_count > 0 {
+                // The text pass bound its own pipeline; rebind for the overlay.
+                self.device
+                    .cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline);
+                self.device.cmd_bind_descriptor_sets(
+                    cmd,
+                    vk::PipelineBindPoint::GRAPHICS,
+                    self.pipeline_layout,
+                    0,
+                    &[self.descriptor_set],
+                    &[],
+                );
+                self.device
+                    .cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
+                self.device
+                    .cmd_draw(cmd, frame.overlay_count, 1, frame.overlay_start, 0);
+            }
+            self.device.cmd_end_render_pass(cmd);
+            self.device.end_command_buffer(cmd).unwrap();
+
+            // Submit + present. The acquire semaphore gates the swapchain image's
+            // first use: the backdrop copy (TRANSFER) or the UI pass (COLOR).
+            let wait_semaphores = [image_available];
+            let wait_stages = [vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
+                | vk::PipelineStageFlags::TRANSFER];
+            let cmds = [cmd];
+            let signal_semaphores = [self.render_finished[image_index as usize]];
+            let submit = vk::SubmitInfo::default()
+                .wait_semaphores(&wait_semaphores)
+                .wait_dst_stage_mask(&wait_stages)
+                .command_buffers(&cmds)
+                .signal_semaphores(&signal_semaphores);
+            self.device
+                .queue_submit(self.queue, &[submit], in_flight)
+                .expect("Queue submit failed");
+
+            let swapchains = [self.swapchain];
+            let image_indices = [image_index];
+            let present = vk::PresentInfoKHR::default()
+                .wait_semaphores(&signal_semaphores)
+                .swapchains(&swapchains)
+                .image_indices(&image_indices);
+            match self.swapchain_loader.queue_present(self.queue, &present) {
+                Ok(suboptimal) => {
+                    if suboptimal {
+                        self.swapchain_dirty = true;
+                    }
+                }
+                Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
+                    self.swapchain_dirty = true;
+                }
+                Err(e) => log::error!("queue_present failed: {e:?}"),
+            }
+
+            self.frame_index = (self.frame_index + 1) % FRAMES_IN_FLIGHT;
+        }
+        true
+    }
+}
+
+impl Drop for VkRenderer {
+    fn drop(&mut self) {
+        unsafe {
+            let _ = self.device.device_wait_idle();
+
+            let mut frames = std::mem::take(&mut self.frames);
+            for frame in &mut frames {
+                self.device.destroy_semaphore(frame.image_available, None);
+                self.device.destroy_fence(frame.in_flight, None);
+                let mut vertex = std::mem::replace(&mut frame.vertex, AllocatedBuffer::null());
+                if let Some(allocator) = self.allocator.as_mut() {
+                    destroy_cpu_buffer(&self.device, allocator, &mut vertex);
+                }
+            }
+
+            self.destroy_swapchain_resources();
+            if self.swapchain != vk::SwapchainKHR::null() {
+                self.swapchain_loader.destroy_swapchain(self.swapchain, None);
+            }
+
+            if let Some(allocator) = self.allocator.as_mut() {
+                self.text.destroy(&self.device, allocator);
+            }
+
+            self.device.destroy_sampler(self.backdrop_sampler, None);
+            if let Some(allocator) = self.allocator.as_mut() {
+                self.scene.destroy(&self.device, allocator);
+            }
+            let mut window_info = std::mem::replace(&mut self.window_info, AllocatedBuffer::null());
+            if let Some(allocator) = self.allocator.as_mut() {
+                destroy_cpu_buffer(&self.device, allocator, &mut window_info);
+            }
+
+            self.device.destroy_descriptor_pool(self.descriptor_pool, None);
+            self.device
+                .destroy_descriptor_set_layout(self.descriptor_set_layout, None);
+            self.device.destroy_pipeline(self.pipeline, None);
+            self.device.destroy_pipeline_layout(self.pipeline_layout, None);
+            self.device.destroy_shader_module(self.shader_module, None);
+            self.device.destroy_render_pass(self.render_pass, None);
+            self.device.destroy_render_pass(self.render_pass_load, None);
+            self.device.destroy_command_pool(self.command_pool, None);
+
+            // The allocator must go before the device it allocates from.
+            drop(self.allocator.take());
+
+            self.device.destroy_device(None);
+            self.surface_loader.destroy_surface(self.surface, None);
+            if let Some((loader, messenger)) = self.debug.take() {
+                loader.destroy_debug_utils_messenger(messenger, None);
+            }
+            self.instance.destroy_instance(None);
+        }
+    }
+}
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
new file mode 100644
index 0000000..615f5ca
--- /dev/null
+++ b/src/vk/scene.rs
@@ -0,0 +1,726 @@
+//! 3D scene stage: the ash port of the app's "3D canvas render pass". Draws
+//! Vertex3D meshes (shader_3d.wgsl: mvp transform, z=9.99 background-quad
+//! special case, window-corner discard) into the full-size backdrop image with
+//! a depth buffer, scissored to the viewport pane. The renderer then copies the
+//! backdrop into the swapchain image and draws the UI pass over it — the same
+//! image doubles as the blur-behind source for the 2D shader, replacing
+//! milestone 1's 1x1 placeholder.
+//!
+//! Meshes are handle-based (`MeshId`); per-draw uniforms (mvp + window info) go
+//! into one dynamic-offset uniform buffer per frame in flight, so a frame's
+//! draws share a single descriptor set.
+
+use ash::vk;
+use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
+use gpu_allocator::MemoryLocation;
+
+use super::renderer::{compile_wgsl, create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
+
+/// Layout-identical to the app's `geometry::Vertex3D` (bytemuck-castable at cutover).
+#[repr(C)]
+#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct Vertex3D {
+    pub position: [f32; 3],
+    pub color: [f32; 3],
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MeshId(usize);
+
+/// One draw in the staged scene: a mesh under an mvp. The window-size/radius
+/// tail of shader_3d's uniform block is filled in by the renderer.
+pub struct SceneDraw {
+    pub mesh: MeshId,
+    pub mvp: [[f32; 4]; 4],
+}
+
+/// shader_3d.wgsl's uniform block.
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+struct SceneUniforms {
+    mvp: [[f32; 4]; 4],
+    window_size: [f32; 2],
+    window_radius: f32,
+    _padding: f32,
+}
+
+const UNIFORM_SIZE: vk::DeviceSize = std::mem::size_of::<SceneUniforms>() as vk::DeviceSize;
+
+struct Mesh {
+    buffer: AllocatedBuffer,
+    count: u32,
+}
+
+struct StagedScene {
+    scissor: (u32, u32, u32, u32),
+    draws: Vec<SceneDraw>,
+}
+
+struct SceneFrame {
+    uniforms: AllocatedBuffer,
+    descriptor_set: vk::DescriptorSet,
+    draw_count: u32,
+}
+
+pub(crate) struct SceneStage {
+    render_pass: vk::RenderPass,
+    pipeline: vk::Pipeline,
+    pipeline_layout: vk::PipelineLayout,
+    descriptor_set_layout: vk::DescriptorSetLayout,
+    descriptor_pool: vk::DescriptorPool,
+    shader_module: vk::ShaderModule,
+    uniform_stride: vk::DeviceSize,
+
+    format: vk::Format,
+    extent: vk::Extent2D,
+    pub(crate) backdrop_image: vk::Image,
+    pub(crate) backdrop_view: vk::ImageView,
+    backdrop_allocation: Option<Allocation>,
+    depth_image: vk::Image,
+    depth_view: vk::ImageView,
+    depth_allocation: Option<Allocation>,
+    framebuffer: vk::Framebuffer,
+
+    meshes: Vec<Mesh>,
+    frames: Vec<SceneFrame>,
+    staged: Option<StagedScene>,
+    /// True once the backdrop holds rendered content worth copying to screen.
+    pub(crate) backdrop_valid: bool,
+}
+
+impl SceneStage {
+    pub(crate) fn new(
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        format: vk::Format,
+        extent: vk::Extent2D,
+        frames_in_flight: usize,
+        min_uniform_align: vk::DeviceSize,
+    ) -> Self {
+        unsafe {
+            // Offscreen pass: color -> TRANSFER_SRC (copied to the swapchain
+            // right after), depth is transient.
+            let attachments = [
+                vk::AttachmentDescription::default()
+                    .format(format)
+                    .samples(vk::SampleCountFlags::TYPE_1)
+                    .load_op(vk::AttachmentLoadOp::CLEAR)
+                    .store_op(vk::AttachmentStoreOp::STORE)
+                    .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
+                    .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
+                    .initial_layout(vk::ImageLayout::UNDEFINED)
+                    .final_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL),
+                vk::AttachmentDescription::default()
+                    .format(vk::Format::D32_SFLOAT)
+                    .samples(vk::SampleCountFlags::TYPE_1)
+                    .load_op(vk::AttachmentLoadOp::CLEAR)
+                    .store_op(vk::AttachmentStoreOp::DONT_CARE)
+                    .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
+                    .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
+                    .initial_layout(vk::ImageLayout::UNDEFINED)
+                    .final_layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL),
+            ];
+            let color_refs = [vk::AttachmentReference::default()
+                .attachment(0)
+                .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
+            let depth_ref = vk::AttachmentReference::default()
+                .attachment(1)
+                .layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
+            let subpasses = [vk::SubpassDescription::default()
+                .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
+                .color_attachments(&color_refs)
+                .depth_stencil_attachment(&depth_ref)];
+            let dependencies = [
+                // Prior frame sampled the backdrop (blur plates) and used the depth
+                // image; execution dependency before we overwrite from UNDEFINED.
+                vk::SubpassDependency::default()
+                    .src_subpass(vk::SUBPASS_EXTERNAL)
+                    .dst_subpass(0)
+                    .src_stage_mask(
+                        vk::PipelineStageFlags::FRAGMENT_SHADER
+                            | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
+                    )
+                    .src_access_mask(vk::AccessFlags::empty())
+                    .dst_stage_mask(
+                        vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
+                            | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS,
+                    )
+                    .dst_access_mask(
+                        vk::AccessFlags::COLOR_ATTACHMENT_WRITE
+                            | vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE,
+                    ),
+                // The copy to the swapchain reads the color attachment right after.
+                vk::SubpassDependency::default()
+                    .src_subpass(0)
+                    .dst_subpass(vk::SUBPASS_EXTERNAL)
+                    .src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
+                    .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
+                    .dst_stage_mask(vk::PipelineStageFlags::TRANSFER)
+                    .dst_access_mask(vk::AccessFlags::TRANSFER_READ),
+            ];
+            let render_pass = device
+                .create_render_pass(
+                    &vk::RenderPassCreateInfo::default()
+                        .attachments(&attachments)
+                        .subpasses(&subpasses)
+                        .dependencies(&dependencies),
+                    None,
+                )
+                .expect("Failed to create scene render pass");
+
+            let bindings = [vk::DescriptorSetLayoutBinding::default()
+                .binding(0)
+                .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
+                .descriptor_count(1)
+                .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT)];
+            let descriptor_set_layout = device
+                .create_descriptor_set_layout(
+                    &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
+                    None,
+                )
+                .expect("Failed to create scene descriptor set layout");
+            let set_layouts_one = [descriptor_set_layout];
+            let pipeline_layout = device
+                .create_pipeline_layout(
+                    &vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts_one),
+                    None,
+                )
+                .expect("Failed to create scene pipeline layout");
+
+            let spirv = compile_wgsl(include_str!("scene3d.wgsl"));
+            let shader_module = device
+                .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spirv), None)
+                .expect("Failed to create 3D shader module");
+            let stages = [
+                vk::PipelineShaderStageCreateInfo::default()
+                    .stage(vk::ShaderStageFlags::VERTEX)
+                    .module(shader_module)
+                    .name(c"vs_main"),
+                vk::PipelineShaderStageCreateInfo::default()
+                    .stage(vk::ShaderStageFlags::FRAGMENT)
+                    .module(shader_module)
+                    .name(c"fs_main"),
+            ];
+            let vertex_bindings = [vk::VertexInputBindingDescription::default()
+                .binding(0)
+                .stride(std::mem::size_of::<Vertex3D>() as u32)
+                .input_rate(vk::VertexInputRate::VERTEX)];
+            let vertex_attributes = [
+                vk::VertexInputAttributeDescription::default()
+                    .location(0)
+                    .binding(0)
+                    .format(vk::Format::R32G32B32_SFLOAT)
+                    .offset(0),
+                vk::VertexInputAttributeDescription::default()
+                    .location(1)
+                    .binding(0)
+                    .format(vk::Format::R32G32B32_SFLOAT)
+                    .offset(12),
+            ];
+            let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
+                .vertex_binding_descriptions(&vertex_bindings)
+                .vertex_attribute_descriptions(&vertex_attributes);
+            let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
+                .topology(vk::PrimitiveTopology::TRIANGLE_LIST);
+            let viewport_state = vk::PipelineViewportStateCreateInfo::default()
+                .viewport_count(1)
+                .scissor_count(1);
+            // wgpu pipeline_3d: CCW front, back-face culling. Winding survives
+            // because the renderer flips Y via negative viewport height (like
+            // wgpu-hal), not in the shader.
+            let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
+                .polygon_mode(vk::PolygonMode::FILL)
+                .cull_mode(vk::CullModeFlags::BACK)
+                .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
+                .line_width(1.0);
+            let multisample = vk::PipelineMultisampleStateCreateInfo::default()
+                .rasterization_samples(vk::SampleCountFlags::TYPE_1);
+            let depth_stencil = vk::PipelineDepthStencilStateCreateInfo::default()
+                .depth_test_enable(true)
+                .depth_write_enable(true)
+                .depth_compare_op(vk::CompareOp::LESS);
+            let blend_attachments = [vk::PipelineColorBlendAttachmentState::default()
+                .blend_enable(true)
+                .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
+                .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+                .color_blend_op(vk::BlendOp::ADD)
+                .src_alpha_blend_factor(vk::BlendFactor::ONE)
+                .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+                .alpha_blend_op(vk::BlendOp::ADD)
+                .color_write_mask(vk::ColorComponentFlags::RGBA)];
+            let color_blend = vk::PipelineColorBlendStateCreateInfo::default()
+                .attachments(&blend_attachments);
+            let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
+            let dynamic_state =
+                vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
+            let pipeline = device
+                .create_graphics_pipelines(
+                    vk::PipelineCache::null(),
+                    &[vk::GraphicsPipelineCreateInfo::default()
+                        .stages(&stages)
+                        .vertex_input_state(&vertex_input)
+                        .input_assembly_state(&input_assembly)
+                        .viewport_state(&viewport_state)
+                        .rasterization_state(&rasterization)
+                        .multisample_state(&multisample)
+                        .depth_stencil_state(&depth_stencil)
+                        .color_blend_state(&color_blend)
+                        .dynamic_state(&dynamic_state)
+                        .layout(pipeline_layout)
+                        .render_pass(render_pass)
+                        .subpass(0)],
+                    None,
+                )
+                .expect("Failed to create 3D pipeline")[0];
+
+            let uniform_stride = UNIFORM_SIZE.next_multiple_of(min_uniform_align.max(1));
+
+            let pool_sizes = [vk::DescriptorPoolSize::default()
+                .ty(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
+                .descriptor_count(frames_in_flight as u32)];
+            let descriptor_pool = device
+                .create_descriptor_pool(
+                    &vk::DescriptorPoolCreateInfo::default()
+                        .max_sets(frames_in_flight as u32)
+                        .pool_sizes(&pool_sizes),
+                    None,
+                )
+                .expect("Failed to create scene descriptor pool");
+            let set_layouts: Vec<vk::DescriptorSetLayout> =
+                vec![descriptor_set_layout; frames_in_flight];
+            let sets = device
+                .allocate_descriptor_sets(
+                    &vk::DescriptorSetAllocateInfo::default()
+                        .descriptor_pool(descriptor_pool)
+                        .set_layouts(&set_layouts),
+                )
+                .expect("Failed to allocate scene descriptor sets");
+            let frames: Vec<SceneFrame> = sets
+                .into_iter()
+                .map(|descriptor_set| {
+                    let uniforms = create_cpu_buffer(
+                        device,
+                        allocator,
+                        uniform_stride * 16,
+                        vk::BufferUsageFlags::UNIFORM_BUFFER,
+                        "scene-uniforms",
+                    );
+                    SceneFrame { uniforms, descriptor_set, draw_count: 0 }
+                })
+                .collect();
+            for frame in &frames {
+                Self::write_descriptor(device, frame);
+            }
+
+            let mut stage = SceneStage {
+                render_pass,
+                pipeline,
+                pipeline_layout,
+                descriptor_set_layout,
+                descriptor_pool,
+                shader_module,
+                uniform_stride,
+                format,
+                extent: vk::Extent2D { width: 0, height: 0 },
+                backdrop_image: vk::Image::null(),
+                backdrop_view: vk::ImageView::null(),
+                backdrop_allocation: None,
+                depth_image: vk::Image::null(),
+                depth_view: vk::ImageView::null(),
+                depth_allocation: None,
+                framebuffer: vk::Framebuffer::null(),
+                meshes: Vec::new(),
+                frames,
+                staged: None,
+                backdrop_valid: false,
+            };
+            stage.resize(device, allocator, extent);
+            stage
+        }
+    }
+
+    fn write_descriptor(device: &ash::Device, frame: &SceneFrame) {
+        let buffer_infos = [vk::DescriptorBufferInfo::default()
+            .buffer(frame.uniforms.buffer)
+            .offset(0)
+            .range(UNIFORM_SIZE)];
+        unsafe {
+            device.update_descriptor_sets(
+                &[vk::WriteDescriptorSet::default()
+                    .dst_set(frame.descriptor_set)
+                    .dst_binding(0)
+                    .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
+                    .buffer_info(&buffer_infos)],
+                &[],
+            );
+        }
+    }
+
+    fn destroy_targets(&mut self, device: &ash::Device, allocator: &mut Allocator) {
+        unsafe {
+            if self.framebuffer != vk::Framebuffer::null() {
+                device.destroy_framebuffer(self.framebuffer, None);
+                self.framebuffer = vk::Framebuffer::null();
+            }
+            if self.backdrop_view != vk::ImageView::null() {
+                device.destroy_image_view(self.backdrop_view, None);
+                device.destroy_image(self.backdrop_image, None);
+                self.backdrop_view = vk::ImageView::null();
+                self.backdrop_image = vk::Image::null();
+            }
+            if self.depth_view != vk::ImageView::null() {
+                device.destroy_image_view(self.depth_view, None);
+                device.destroy_image(self.depth_image, None);
+                self.depth_view = vk::ImageView::null();
+                self.depth_image = vk::Image::null();
+            }
+        }
+        if let Some(a) = self.backdrop_allocation.take() {
+            let _ = allocator.free(a);
+        }
+        if let Some(a) = self.depth_allocation.take() {
+            let _ = allocator.free(a);
+        }
+    }
+
+    /// (Re)create the backdrop + depth targets at `extent`. Caller must have the
+    /// device idle (the renderer's swapchain-rebuild path guarantees it) and must
+    /// re-point the UI descriptor at the new `backdrop_view` and re-init its layout.
+    pub(crate) fn resize(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        extent: vk::Extent2D,
+    ) {
+        if extent == self.extent && self.framebuffer != vk::Framebuffer::null() {
+            return;
+        }
+        self.destroy_targets(device, allocator);
+        self.extent = extent;
+        self.backdrop_valid = false;
+        unsafe {
+            let backdrop_image = device
+                .create_image(
+                    &vk::ImageCreateInfo::default()
+                        .image_type(vk::ImageType::TYPE_2D)
+                        .format(self.format)
+                        .extent(vk::Extent3D {
+                            width: extent.width,
+                            height: extent.height,
+                            depth: 1,
+                        })
+                        .mip_levels(1)
+                        .array_layers(1)
+                        .samples(vk::SampleCountFlags::TYPE_1)
+                        .tiling(vk::ImageTiling::OPTIMAL)
+                        .usage(
+                            vk::ImageUsageFlags::COLOR_ATTACHMENT
+                                | vk::ImageUsageFlags::SAMPLED
+                                | vk::ImageUsageFlags::TRANSFER_SRC
+                                | vk::ImageUsageFlags::TRANSFER_DST,
+                        )
+                        .initial_layout(vk::ImageLayout::UNDEFINED),
+                    None,
+                )
+                .expect("Failed to create backdrop image");
+            let requirements = device.get_image_memory_requirements(backdrop_image);
+            let allocation = allocator
+                .allocate(&AllocationCreateDesc {
+                    name: "backdrop",
+                    requirements,
+                    location: MemoryLocation::GpuOnly,
+                    linear: false,
+                    allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+                })
+                .expect("Failed to allocate backdrop memory");
+            device
+                .bind_image_memory(backdrop_image, allocation.memory(), allocation.offset())
+                .expect("Failed to bind backdrop memory");
+            let backdrop_view = device
+                .create_image_view(
+                    &vk::ImageViewCreateInfo::default()
+                        .image(backdrop_image)
+                        .view_type(vk::ImageViewType::TYPE_2D)
+                        .format(self.format)
+                        .subresource_range(
+                            vk::ImageSubresourceRange::default()
+                                .aspect_mask(vk::ImageAspectFlags::COLOR)
+                                .level_count(1)
+                                .layer_count(1),
+                        ),
+                    None,
+                )
+                .expect("Failed to create backdrop view");
+            self.backdrop_image = backdrop_image;
+            self.backdrop_view = backdrop_view;
+            self.backdrop_allocation = Some(allocation);
+
+            let depth_image = device
+                .create_image(
+                    &vk::ImageCreateInfo::default()
+                        .image_type(vk::ImageType::TYPE_2D)
+                        .format(vk::Format::D32_SFLOAT)
+                        .extent(vk::Extent3D {
+                            width: extent.width,
+                            height: extent.height,
+                            depth: 1,
+                        })
+                        .mip_levels(1)
+                        .array_layers(1)
+                        .samples(vk::SampleCountFlags::TYPE_1)
+                        .tiling(vk::ImageTiling::OPTIMAL)
+                        .usage(vk::ImageUsageFlags::DEPTH_STENCIL_ATTACHMENT)
+                        .initial_layout(vk::ImageLayout::UNDEFINED),
+                    None,
+                )
+                .expect("Failed to create depth image");
+            let requirements = device.get_image_memory_requirements(depth_image);
+            let allocation = allocator
+                .allocate(&AllocationCreateDesc {
+                    name: "depth",
+                    requirements,
+                    location: MemoryLocation::GpuOnly,
+                    linear: false,
+                    allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+                })
+                .expect("Failed to allocate depth memory");
+            device
+                .bind_image_memory(depth_image, allocation.memory(), allocation.offset())
+                .expect("Failed to bind depth memory");
+            let depth_view = device
+                .create_image_view(
+                    &vk::ImageViewCreateInfo::default()
+                        .image(depth_image)
+                        .view_type(vk::ImageViewType::TYPE_2D)
+                        .format(vk::Format::D32_SFLOAT)
+                        .subresource_range(
+                            vk::ImageSubresourceRange::default()
+                                .aspect_mask(vk::ImageAspectFlags::DEPTH)
+                                .level_count(1)
+                                .layer_count(1),
+                        ),
+                    None,
+                )
+                .expect("Failed to create depth view");
+            self.depth_image = depth_image;
+            self.depth_view = depth_view;
+            self.depth_allocation = Some(allocation);
+
+            let attachments = [self.backdrop_view, self.depth_view];
+            self.framebuffer = device
+                .create_framebuffer(
+                    &vk::FramebufferCreateInfo::default()
+                        .render_pass(self.render_pass)
+                        .attachments(&attachments)
+                        .width(extent.width)
+                        .height(extent.height)
+                        .layers(1),
+                    None,
+                )
+                .expect("Failed to create scene framebuffer");
+        }
+    }
+
+    pub(crate) fn create_mesh(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        verts: &[Vertex3D],
+    ) -> MeshId {
+        let bytes: &[u8] = bytemuck::cast_slice(verts);
+        let mut buffer = create_cpu_buffer(
+            device,
+            allocator,
+            (bytes.len() as vk::DeviceSize).max(64),
+            vk::BufferUsageFlags::VERTEX_BUFFER,
+            "mesh",
+        );
+        if !bytes.is_empty() {
+            buffer.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..bytes.len()]
+                .copy_from_slice(bytes);
+        }
+        self.meshes.push(Mesh { buffer, count: verts.len() as u32 });
+        MeshId(self.meshes.len() - 1)
+    }
+
+    /// Replace a mesh's vertices. Caller must have the device idle: meshes may be
+    /// referenced by in-flight frames (geometry updates are rare — settings
+    /// changes and graph rebuilds — so a wait is acceptable here).
+    #[allow(dead_code)] // cutover API: the app's rebuild_scene_geometry path
+    pub(crate) fn update_mesh(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        id: MeshId,
+        verts: &[Vertex3D],
+    ) {
+        let mesh = &mut self.meshes[id.0];
+        let bytes: &[u8] = bytemuck::cast_slice(verts);
+        let needed = bytes.len() as vk::DeviceSize;
+        if needed > mesh.buffer.size {
+            let mut old = std::mem::replace(&mut mesh.buffer, AllocatedBuffer::null());
+            destroy_cpu_buffer(device, allocator, &mut old);
+            mesh.buffer = create_cpu_buffer(
+                device,
+                allocator,
+                needed.next_power_of_two(),
+                vk::BufferUsageFlags::VERTEX_BUFFER,
+                "mesh",
+            );
+        }
+        if !bytes.is_empty() {
+            mesh.buffer.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..bytes.len()]
+                .copy_from_slice(bytes);
+        }
+        mesh.count = verts.len() as u32;
+    }
+
+    pub(crate) fn stage(&mut self, scissor: (u32, u32, u32, u32), draws: Vec<SceneDraw>) {
+        self.staged = Some(StagedScene { scissor, draws });
+    }
+
+    /// After the frame fence: write this frame's per-draw uniforms (mvp + the
+    /// window-corner info shader_3d shares with the 2D shader).
+    pub(crate) fn write_frame_uniforms(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        frame_index: usize,
+        corner_radius_px: f32,
+    ) {
+        let Some(staged) = &self.staged else {
+            self.frames[frame_index].draw_count = 0;
+            return;
+        };
+        let frame = &mut self.frames[frame_index];
+        let needed = self.uniform_stride * staged.draws.len().max(1) as vk::DeviceSize;
+        if needed > frame.uniforms.size {
+            let mut old = std::mem::replace(&mut frame.uniforms, AllocatedBuffer::null());
+            destroy_cpu_buffer(device, allocator, &mut old);
+            frame.uniforms = create_cpu_buffer(
+                device,
+                allocator,
+                needed.next_power_of_two(),
+                vk::BufferUsageFlags::UNIFORM_BUFFER,
+                "scene-uniforms",
+            );
+            Self::write_descriptor(device, frame);
+        }
+        let window_size = [self.extent.width as f32, self.extent.height as f32];
+        let mapped = frame.uniforms.allocation.as_mut().unwrap().mapped_slice_mut().unwrap();
+        for (i, draw) in staged.draws.iter().enumerate() {
+            let uniforms = SceneUniforms {
+                mvp: draw.mvp,
+                window_size,
+                window_radius: corner_radius_px,
+                _padding: 0.0,
+            };
+            let offset = (self.uniform_stride as usize) * i;
+            mapped[offset..offset + UNIFORM_SIZE as usize]
+                .copy_from_slice(bytemuck::bytes_of(&uniforms));
+        }
+        frame.draw_count = staged.draws.len() as u32;
+    }
+
+    /// Record the offscreen scene pass. Consumes the staged scene; afterwards the
+    /// backdrop is in TRANSFER_SRC layout, ready for the swapchain copy. Returns
+    /// false if nothing was staged.
+    pub(crate) fn record(
+        &mut self,
+        device: &ash::Device,
+        cmd: vk::CommandBuffer,
+        frame_index: usize,
+    ) -> bool {
+        let Some(staged) = self.staged.take() else {
+            return false;
+        };
+        let frame = &self.frames[frame_index];
+        unsafe {
+            let clear_values = [
+                vk::ClearValue { color: vk::ClearColorValue { float32: [0.0, 0.0, 0.0, 0.0] } },
+                vk::ClearValue {
+                    depth_stencil: vk::ClearDepthStencilValue { depth: 1.0, stencil: 0 },
+                },
+            ];
+            device.cmd_begin_render_pass(
+                cmd,
+                &vk::RenderPassBeginInfo::default()
+                    .render_pass(self.render_pass)
+                    .framebuffer(self.framebuffer)
+                    .render_area(vk::Rect2D {
+                        offset: vk::Offset2D { x: 0, y: 0 },
+                        extent: self.extent,
+                    })
+                    .clear_values(&clear_values),
+                vk::SubpassContents::INLINE,
+            );
+            // Negative-height viewport: wgpu's Y-up NDC without touching winding.
+            device.cmd_set_viewport(
+                cmd,
+                0,
+                &[vk::Viewport {
+                    x: 0.0,
+                    y: self.extent.height as f32,
+                    width: self.extent.width as f32,
+                    height: -(self.extent.height as f32),
+                    min_depth: 0.0,
+                    max_depth: 1.0,
+                }],
+            );
+            let (sx, sy, sw, sh) = staged.scissor;
+            let sx = sx.min(self.extent.width);
+            let sy = sy.min(self.extent.height);
+            device.cmd_set_scissor(
+                cmd,
+                0,
+                &[vk::Rect2D {
+                    offset: vk::Offset2D { x: sx as i32, y: sy as i32 },
+                    extent: vk::Extent2D {
+                        width: sw.min(self.extent.width - sx),
+                        height: sh.min(self.extent.height - sy),
+                    },
+                }],
+            );
+            device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline);
+            for (i, draw) in staged.draws.iter().enumerate() {
+                let mesh = &self.meshes[draw.mesh.0];
+                if mesh.count == 0 {
+                    continue;
+                }
+                device.cmd_bind_descriptor_sets(
+                    cmd,
+                    vk::PipelineBindPoint::GRAPHICS,
+                    self.pipeline_layout,
+                    0,
+                    &[frame.descriptor_set],
+                    &[(self.uniform_stride as u32) * i as u32],
+                );
+                device.cmd_bind_vertex_buffers(cmd, 0, &[mesh.buffer.buffer], &[0]);
+                device.cmd_draw(cmd, mesh.count, 1, 0, 0);
+            }
+            device.cmd_end_render_pass(cmd);
+        }
+        self.backdrop_valid = true;
+        true
+    }
+
+    pub(crate) fn destroy(&mut self, device: &ash::Device, allocator: &mut Allocator) {
+        self.destroy_targets(device, allocator);
+        unsafe {
+            for frame in &mut self.frames {
+                let mut uniforms = std::mem::replace(&mut frame.uniforms, AllocatedBuffer::null());
+                destroy_cpu_buffer(device, allocator, &mut uniforms);
+            }
+            for mesh in &mut self.meshes {
+                let mut buffer = std::mem::replace(&mut mesh.buffer, AllocatedBuffer::null());
+                destroy_cpu_buffer(device, allocator, &mut buffer);
+            }
+            device.destroy_descriptor_pool(self.descriptor_pool, None);
+            device.destroy_descriptor_set_layout(self.descriptor_set_layout, None);
+            device.destroy_pipeline(self.pipeline, None);
+            device.destroy_pipeline_layout(self.pipeline_layout, None);
+            device.destroy_shader_module(self.shader_module, None);
+            device.destroy_render_pass(self.render_pass, None);
+        }
+    }
+}
diff --git a/src/vk/scene3d.wgsl b/src/vk/scene3d.wgsl
new file mode 100644
index 0000000..193dcda
--- /dev/null
+++ b/src/vk/scene3d.wgsl
@@ -0,0 +1,72 @@
+struct Uniforms {
+    mvp: mat4x4<f32>,
+    window_size: vec2<f32>,
+    window_radius: f32,
+    padding: f32,
+}
+
+@group(0) @binding(0) var<uniform> uniforms: Uniforms;
+
+fn is_outside_window_corners(pos: vec2<f32>) -> bool {
+    let w = uniforms.window_size.x;
+    let h = uniforms.window_size.y;
+    let r = uniforms.window_radius;
+    
+    // Top-left
+    if (pos.x < r && pos.y < r) {
+        let dx = pos.x - r;
+        let dy = pos.y - r;
+        return (dx * dx + dy * dy) > r * r;
+    }
+    // Top-right
+    if (pos.x > w - r && pos.y < r) {
+        let dx = pos.x - (w - r);
+        let dy = pos.y - r;
+        return (dx * dx + dy * dy) > r * r;
+    }
+    // Bottom-left
+    if (pos.x < r && pos.y > h - r) {
+        let dx = pos.x - r;
+        let dy = pos.y - (h - r);
+        return (dx * dx + dy * dy) > r * r;
+    }
+    // Bottom-right
+    if (pos.x > w - r && pos.y > h - r) {
+        let dx = pos.x - (w - r);
+        let dy = pos.y - (h - r);
+        return (dx * dx + dy * dy) > r * r;
+    }
+    // Boundary check
+    if (pos.x < 0.0 || pos.x > w || pos.y < 0.0 || pos.y > h) {
+        return true;
+    }
+    return false;
+}
+
+struct VertexOutput {
+    @builtin(position) position: vec4f,
+    @location(0) color: vec3f,
+};
+
+@vertex
+fn vs_main(
+    @location(0) position: vec3f,
+    @location(1) color: vec3f,
+) -> VertexOutput {
+    var out: VertexOutput;
+    if (abs(position.z - 9.99) < 0.01) {
+        out.position = vec4f(position.xy, 0.9999, 1.0);
+    } else {
+        out.position = uniforms.mvp * vec4f(position, 1.0);
+    }
+    out.color = color;
+    return out;
+}
+
+@fragment
+fn fs_main(in: VertexOutput) -> @location(0) vec4f {
+    if (is_outside_window_corners(in.position.xy)) {
+        discard;
+    }
+    return vec4f(in.color, 1.0);
+}
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
new file mode 100644
index 0000000..edf97ef
--- /dev/null
+++ b/src/vk/shader2d.wgsl
@@ -0,0 +1,151 @@
+// The toolkit's 2D pipeline shader — the union of the two wgpu-era dialects:
+// the engine shader's wavy-blob effect (clip_circle.x == -999 sentinel) and the
+// designer shader's window-corner rounding + circle clip + blur-behind branch
+// (negative alpha samples the backdrop). Clients that don't use a feature pay
+// nothing: radius 0 disables corner rounding, the backdrop is renderer-managed,
+// and plain quads take the final `return in.color` path.
+
+@group(0) @binding(0) var t_backdrop: texture_2d<f32>;
+@group(0) @binding(1) var s_backdrop: sampler;
+
+struct WindowInfo {
+    window_size: vec2<f32>,
+    corner_radius: f32,
+    padding: f32,
+}
+
+@group(0) @binding(2) var<uniform> window_info: WindowInfo;
+
+fn is_outside_window_corners(pos: vec2<f32>) -> bool {
+    let w = window_info.window_size.x;
+    let h = window_info.window_size.y;
+    let r = window_info.corner_radius;
+
+    if (r <= 0.0) {
+        return false;
+    }
+    // Top-left
+    if (pos.x < r && pos.y < r) {
+        let dx = pos.x - r;
+        let dy = pos.y - r;
+        return (dx * dx + dy * dy) > r * r;
+    }
+    // Top-right
+    if (pos.x > w - r && pos.y < r) {
+        let dx = pos.x - (w - r);
+        let dy = pos.y - r;
+        return (dx * dx + dy * dy) > r * r;
+    }
+    // Bottom-left
+    if (pos.x < r && pos.y > h - r) {
+        let dx = pos.x - r;
+        let dy = pos.y - (h - r);
+        return (dx * dx + dy * dy) > r * r;
+    }
+    // Bottom-right
+    if (pos.x > w - r && pos.y > h - r) {
+        let dx = pos.x - (w - r);
+        let dy = pos.y - (h - r);
+        return (dx * dx + dy * dy) > r * r;
+    }
+    // Boundary check
+    if (pos.x < 0.0 || pos.x > w || pos.y < 0.0 || pos.y > h) {
+        return true;
+    }
+    return false;
+}
+
+struct VertexOutput {
+    @builtin(position) clip_position: vec4f,
+    @location(0) color: vec4f,
+    @location(1) ndc_position: vec2f,
+    @location(2) clip_circle: vec3f,
+}
+
+@vertex
+fn vs_main(
+    @location(0) position: vec2f,
+    @location(1) color: vec4f,
+    @location(2) clip_circle: vec3f,
+) -> VertexOutput {
+    var out: VertexOutput;
+    out.clip_position = vec4f(position, 0.0, 1.0);
+    out.color = color;
+    out.ndc_position = position;
+    out.clip_circle = clip_circle;
+    return out;
+}
+
+@fragment
+fn fs_main(in: VertexOutput) -> @location(0) vec4f {
+    // Wavy-blob effect (engine shader.wgsl): the -999 sentinel renders a
+    // rippled, fading disc in NDC space.
+    if (in.clip_circle.x == -999.0) {
+        let y = length(vec2f(in.ndc_position.x, in.ndc_position.y));
+        let x = atan2(in.ndc_position.y, in.ndc_position.x);
+
+        // Wavy boundary radius with 7 lobes
+        let R_theta = 0.60 + 0.06 * sin(7.0 * x);
+
+        // Radial density: 1.0 at center, fading out to 0.0 at R_theta
+        let density = 1.0 - smoothstep(R_theta - 0.25, R_theta, y);
+
+        // Sine wave effect driven by the x value (distance around the circle)
+        let sin_effect = sin(7.0 * x);
+
+        // Normalized radius from 0.0 (center) to 1.0 (boundary)
+        let r_normalized = clamp(y / R_theta, 0.0, 1.0);
+
+        let gray = in.color.xyz;
+
+        // Scale the ripple amplitude by the normalized radius to fade it out at the center
+        let alpha = clamp(density * (1.0 - r_normalized * 0.25 * (1.0 - sin_effect)), 0.0, 1.0);
+
+        if (y > R_theta + 0.02) {
+            discard;
+        }
+
+        let final_alpha = alpha * (1.0 - smoothstep(R_theta - 0.02, R_theta + 0.02, y)) * in.color.w;
+        return vec4f(gray, final_alpha);
+    }
+
+    if (is_outside_window_corners(in.clip_position.xy)) {
+        discard;
+    }
+    if (in.clip_circle.z > 0.0) {
+        let dx = in.clip_position.x - in.clip_circle.x;
+        let dy = in.clip_position.y - in.clip_circle.y;
+        if (dx * dx + dy * dy > in.clip_circle.z * in.clip_circle.z) {
+            discard;
+        }
+    }
+
+    // Blur-behind plate: negative alpha mixes the (blurred) backdrop with the
+    // plate color at |alpha| opacity.
+    if (in.color.a < 0.0) {
+        let tex_size = vec2f(textureDimensions(t_backdrop));
+        let clean_backdrop = textureSample(t_backdrop, s_backdrop, in.clip_position.xy / tex_size);
+
+        var blurred = vec4f(0.0);
+        var total_weight = 0.0;
+
+        // 7x7 Gaussian blur kernel
+        for (var x = -3.0; x <= 3.0; x += 1.0) {
+            for (var y = -3.0; y <= 3.0; y += 1.0) {
+                let offset = vec2f(x, y) * 2.0; // sample every 2 pixels for a wider blur
+                let sample_uv = (in.clip_position.xy + 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;
+                total_weight += weight;
+            }
+        }
+
+        let backdrop_color = blurred / total_weight;
+        let opacity = -in.color.a;
+        let plate_color = vec4f(in.color.rgb, 1.0);
+        let blurred_plate = mix(backdrop_color, plate_color, opacity);
+        return mix(clean_backdrop, blurred_plate, opacity);
+    }
+
+    return in.color;
+}
diff --git a/src/vk/text.rs b/src/vk/text.rs
new file mode 100644
index 0000000..ecc5c63
--- /dev/null
+++ b/src/vk/text.rs
@@ -0,0 +1,771 @@
+//! Text on ash: cosmic-text shaping (reached through glyphon's re-export, so the
+//! shaping behavior and fonts are byte-identical to the wgpu path) + swash
+//! rasterization into a self-managed RGBA glyph atlas, drawn by the glyph.wgsl
+//! pipeline inside the renderer's render pass.
+//!
+//! `TextSpan` mirrors `glyphon::TextArea` (buffer + position + scale + bounds +
+//! default color) so the eventual cutover from `text_renderer.prepare(...)` is
+//! mechanical.
+//!
+//! Atlas strategy: shelf packing into a 1024² RGBA8 image with a CPU mirror.
+//! When new glyphs land, the whole mirror is re-uploaded before the next render
+//! pass (bounded 4 MiB, and only on glyph-miss frames); if the atlas fills, it is
+//! cleared and repacked with just the current frame's glyphs. Mask glyphs are
+//! 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::{
+    Allocation, AllocationCreateDesc, AllocationScheme, Allocator,
+};
+use gpu_allocator::MemoryLocation;
+
+use glyphon::cosmic_text::{Buffer as TextBuffer, CacheKey, SwashContent};
+use glyphon::{FontSystem, SwashCache};
+
+use super::renderer::{compile_wgsl, create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
+
+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
+/// 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],
+}
+
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+struct GlyphVertex {
+    position: [f32; 2],
+    uv: [f32; 2],
+    color: [f32; 4],
+    clip_circle: [f32; 3],
+}
+
+#[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)
+    }
+}
+
+struct TextFrame {
+    vertex: AllocatedBuffer,
+    vertex_count: u32,
+    staging: AllocatedBuffer,
+    /// Atlas generation this frame's staging buffer last uploaded.
+    uploaded_generation: u64,
+}
+
+pub(crate) struct TextStage {
+    pipeline: vk::Pipeline,
+    pipeline_layout: vk::PipelineLayout,
+    descriptor_set_layout: vk::DescriptorSetLayout,
+    descriptor_pool: vk::DescriptorPool,
+    descriptor_set: vk::DescriptorSet,
+    shader_module: vk::ShaderModule,
+    sampler: vk::Sampler,
+
+    atlas_image: vk::Image,
+    atlas_view: vk::ImageView,
+    atlas_allocation: Option<Allocation>,
+    /// CPU mirror of the atlas (RGBA8, ATLAS_SIZE²).
+    atlas_cpu: Vec<u8>,
+    atlas_initialized: bool,
+    generation: u64,
+
+    glyphs: HashMap<CacheKey, GlyphEntry>,
+    shelf: Shelf,
+
+    pending_vertices: Vec<GlyphVertex>,
+    frames: Vec<TextFrame>,
+}
+
+impl TextStage {
+    pub(crate) fn new(
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        render_pass: vk::RenderPass,
+        frames_in_flight: usize,
+    ) -> Self {
+        unsafe {
+            let bindings = [
+                vk::DescriptorSetLayoutBinding::default()
+                    .binding(0)
+                    .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+                    .descriptor_count(1)
+                    .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+                vk::DescriptorSetLayoutBinding::default()
+                    .binding(1)
+                    .descriptor_type(vk::DescriptorType::SAMPLER)
+                    .descriptor_count(1)
+                    .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+            ];
+            let descriptor_set_layout = device
+                .create_descriptor_set_layout(
+                    &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
+                    None,
+                )
+                .expect("Failed to create text descriptor set layout");
+            let set_layouts = [descriptor_set_layout];
+            let pipeline_layout = device
+                .create_pipeline_layout(
+                    &vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts),
+                    None,
+                )
+                .expect("Failed to create text pipeline layout");
+
+            let spirv = compile_wgsl(include_str!("glyph.wgsl"));
+            let shader_module = device
+                .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spirv), None)
+                .expect("Failed to create glyph shader module");
+
+            let stages = [
+                vk::PipelineShaderStageCreateInfo::default()
+                    .stage(vk::ShaderStageFlags::VERTEX)
+                    .module(shader_module)
+                    .name(c"vs_main"),
+                vk::PipelineShaderStageCreateInfo::default()
+                    .stage(vk::ShaderStageFlags::FRAGMENT)
+                    .module(shader_module)
+                    .name(c"fs_main"),
+            ];
+            let vertex_bindings = [vk::VertexInputBindingDescription::default()
+                .binding(0)
+                .stride(std::mem::size_of::<GlyphVertex>() as u32)
+                .input_rate(vk::VertexInputRate::VERTEX)];
+            let vertex_attributes = [
+                vk::VertexInputAttributeDescription::default()
+                    .location(0)
+                    .binding(0)
+                    .format(vk::Format::R32G32_SFLOAT)
+                    .offset(0),
+                vk::VertexInputAttributeDescription::default()
+                    .location(1)
+                    .binding(0)
+                    .format(vk::Format::R32G32_SFLOAT)
+                    .offset(8),
+                vk::VertexInputAttributeDescription::default()
+                    .location(2)
+                    .binding(0)
+                    .format(vk::Format::R32G32B32A32_SFLOAT)
+                    .offset(16),
+                vk::VertexInputAttributeDescription::default()
+                    .location(3)
+                    .binding(0)
+                    .format(vk::Format::R32G32B32_SFLOAT)
+                    .offset(32),
+            ];
+            let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
+                .vertex_binding_descriptions(&vertex_bindings)
+                .vertex_attribute_descriptions(&vertex_attributes);
+            let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
+                .topology(vk::PrimitiveTopology::TRIANGLE_LIST);
+            let viewport_state = vk::PipelineViewportStateCreateInfo::default()
+                .viewport_count(1)
+                .scissor_count(1);
+            let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
+                .polygon_mode(vk::PolygonMode::FILL)
+                .cull_mode(vk::CullModeFlags::NONE)
+                .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
+                .line_width(1.0);
+            let multisample = vk::PipelineMultisampleStateCreateInfo::default()
+                .rasterization_samples(vk::SampleCountFlags::TYPE_1);
+            let blend_attachments = [vk::PipelineColorBlendAttachmentState::default()
+                .blend_enable(true)
+                .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
+                .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+                .color_blend_op(vk::BlendOp::ADD)
+                .src_alpha_blend_factor(vk::BlendFactor::ONE)
+                .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+                .alpha_blend_op(vk::BlendOp::ADD)
+                .color_write_mask(vk::ColorComponentFlags::RGBA)];
+            let color_blend = vk::PipelineColorBlendStateCreateInfo::default()
+                .attachments(&blend_attachments);
+            let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
+            let dynamic_state =
+                vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
+            let pipeline = device
+                .create_graphics_pipelines(
+                    vk::PipelineCache::null(),
+                    &[vk::GraphicsPipelineCreateInfo::default()
+                        .stages(&stages)
+                        .vertex_input_state(&vertex_input)
+                        .input_assembly_state(&input_assembly)
+                        .viewport_state(&viewport_state)
+                        .rasterization_state(&rasterization)
+                        .multisample_state(&multisample)
+                        .color_blend_state(&color_blend)
+                        .dynamic_state(&dynamic_state)
+                        .layout(pipeline_layout)
+                        .render_pass(render_pass)
+                        .subpass(0)],
+                    None,
+                )
+                .expect("Failed to create glyph pipeline")[0];
+
+            let atlas_image = device
+                .create_image(
+                    &vk::ImageCreateInfo::default()
+                        .image_type(vk::ImageType::TYPE_2D)
+                        .format(vk::Format::R8G8B8A8_UNORM)
+                        .extent(vk::Extent3D { width: ATLAS_SIZE, height: ATLAS_SIZE, depth: 1 })
+                        .mip_levels(1)
+                        .array_layers(1)
+                        .samples(vk::SampleCountFlags::TYPE_1)
+                        .tiling(vk::ImageTiling::OPTIMAL)
+                        .usage(vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST)
+                        .initial_layout(vk::ImageLayout::UNDEFINED),
+                    None,
+                )
+                .expect("Failed to create atlas image");
+            let requirements = device.get_image_memory_requirements(atlas_image);
+            let atlas_allocation = allocator
+                .allocate(&AllocationCreateDesc {
+                    name: "glyph-atlas",
+                    requirements,
+                    location: MemoryLocation::GpuOnly,
+                    linear: false,
+                    allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+                })
+                .expect("Failed to allocate atlas memory");
+            device
+                .bind_image_memory(atlas_image, atlas_allocation.memory(), atlas_allocation.offset())
+                .expect("Failed to bind atlas memory");
+            let atlas_view = device
+                .create_image_view(
+                    &vk::ImageViewCreateInfo::default()
+                        .image(atlas_image)
+                        .view_type(vk::ImageViewType::TYPE_2D)
+                        .format(vk::Format::R8G8B8A8_UNORM)
+                        .subresource_range(
+                            vk::ImageSubresourceRange::default()
+                                .aspect_mask(vk::ImageAspectFlags::COLOR)
+                                .level_count(1)
+                                .layer_count(1),
+                        ),
+                    None,
+                )
+                .expect("Failed to create atlas view");
+
+            // Glyphs are sampled 1:1; NEAREST keeps them crisp.
+            let sampler = device
+                .create_sampler(
+                    &vk::SamplerCreateInfo::default()
+                        .mag_filter(vk::Filter::NEAREST)
+                        .min_filter(vk::Filter::NEAREST)
+                        .mipmap_mode(vk::SamplerMipmapMode::NEAREST)
+                        .address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
+                        .address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
+                        .address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE),
+                    None,
+                )
+                .expect("Failed to create atlas sampler");
+
+            let pool_sizes = [
+                vk::DescriptorPoolSize::default()
+                    .ty(vk::DescriptorType::SAMPLED_IMAGE)
+                    .descriptor_count(1),
+                vk::DescriptorPoolSize::default()
+                    .ty(vk::DescriptorType::SAMPLER)
+                    .descriptor_count(1),
+            ];
+            let descriptor_pool = device
+                .create_descriptor_pool(
+                    &vk::DescriptorPoolCreateInfo::default()
+                        .max_sets(1)
+                        .pool_sizes(&pool_sizes),
+                    None,
+                )
+                .expect("Failed to create text descriptor pool");
+            let descriptor_set = device
+                .allocate_descriptor_sets(
+                    &vk::DescriptorSetAllocateInfo::default()
+                        .descriptor_pool(descriptor_pool)
+                        .set_layouts(&set_layouts),
+                )
+                .expect("Failed to allocate text descriptor set")[0];
+            let image_infos = [vk::DescriptorImageInfo::default()
+                .image_view(atlas_view)
+                .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
+            let sampler_infos = [vk::DescriptorImageInfo::default().sampler(sampler)];
+            device.update_descriptor_sets(
+                &[
+                    vk::WriteDescriptorSet::default()
+                        .dst_set(descriptor_set)
+                        .dst_binding(0)
+                        .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+                        .image_info(&image_infos),
+                    vk::WriteDescriptorSet::default()
+                        .dst_set(descriptor_set)
+                        .dst_binding(1)
+                        .descriptor_type(vk::DescriptorType::SAMPLER)
+                        .image_info(&sampler_infos),
+                ],
+                &[],
+            );
+
+            let atlas_bytes = (ATLAS_SIZE * ATLAS_SIZE * 4) as vk::DeviceSize;
+            let frames = (0..frames_in_flight)
+                .map(|_| TextFrame {
+                    vertex: create_cpu_buffer(
+                        device,
+                        allocator,
+                        64 * 1024,
+                        vk::BufferUsageFlags::VERTEX_BUFFER,
+                        "glyph-vertices",
+                    ),
+                    vertex_count: 0,
+                    staging: create_cpu_buffer(
+                        device,
+                        allocator,
+                        atlas_bytes,
+                        vk::BufferUsageFlags::TRANSFER_SRC,
+                        "atlas-staging",
+                    ),
+                    uploaded_generation: 0,
+                })
+                .collect();
+
+            TextStage {
+                pipeline,
+                pipeline_layout,
+                descriptor_set_layout,
+                descriptor_pool,
+                descriptor_set,
+                shader_module,
+                sampler,
+                atlas_image,
+                atlas_view,
+                atlas_allocation: Some(atlas_allocation),
+                atlas_cpu: vec![0u8; (ATLAS_SIZE * ATLAS_SIZE * 4) as usize],
+                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).
+    pub(crate) fn prepare(
+        &mut self,
+        font_system: &mut FontSystem,
+        swash_cache: &mut SwashCache,
+        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, 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 tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle };
+                    let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle };
+                    let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle };
+                    let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle };
+                    self.pending_vertices.extend([tl, tr, bl, tr, br, bl]);
+                }
+            }
+        }
+        true
+    }
+
+    /// Called after this frame's fence has been waited: move pending vertices into
+    /// the frame's buffer and refresh its staging copy if the atlas changed.
+    pub(crate) fn write_frame_buffers(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        frame_index: usize,
+    ) {
+        let vertices = std::mem::take(&mut self.pending_vertices);
+        let frame = &mut self.frames[frame_index];
+
+        let bytes: &[u8] = bytemuck::cast_slice(&vertices);
+        let needed = bytes.len() as vk::DeviceSize;
+        if needed > frame.vertex.size {
+            let mut old = std::mem::replace(&mut frame.vertex, AllocatedBuffer::null());
+            destroy_cpu_buffer(device, allocator, &mut old);
+            frame.vertex = create_cpu_buffer(
+                device,
+                allocator,
+                needed.next_power_of_two(),
+                vk::BufferUsageFlags::VERTEX_BUFFER,
+                "glyph-vertices",
+            );
+        }
+        if !bytes.is_empty() {
+            frame.vertex.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()
+                [..bytes.len()]
+                .copy_from_slice(bytes);
+        }
+        frame.vertex_count = vertices.len() as u32;
+        self.pending_vertices = vertices;
+        self.pending_vertices.clear();
+
+        let frame = &mut self.frames[frame_index];
+        if frame.uploaded_generation != self.generation {
+            frame.staging.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()
+                [..self.atlas_cpu.len()]
+                .copy_from_slice(&self.atlas_cpu);
+        }
+    }
+
+    /// Record the atlas upload (if this frame's staging is newer than the image).
+    /// 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 {
+            return;
+        }
+        frame.uploaded_generation = self.generation;
+
+        let range = vk::ImageSubresourceRange::default()
+            .aspect_mask(vk::ImageAspectFlags::COLOR)
+            .level_count(1)
+            .layer_count(1);
+        let (old_layout, src_stage, src_access) = if self.atlas_initialized {
+            (
+                vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
+                vk::PipelineStageFlags::FRAGMENT_SHADER,
+                vk::AccessFlags::SHADER_READ,
+            )
+        } else {
+            (
+                vk::ImageLayout::UNDEFINED,
+                vk::PipelineStageFlags::TOP_OF_PIPE,
+                vk::AccessFlags::empty(),
+            )
+        };
+        self.atlas_initialized = true;
+
+        unsafe {
+            device.cmd_pipeline_barrier(
+                cmd,
+                src_stage,
+                vk::PipelineStageFlags::TRANSFER,
+                vk::DependencyFlags::empty(),
+                &[],
+                &[],
+                &[vk::ImageMemoryBarrier::default()
+                    .src_access_mask(src_access)
+                    .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+                    .old_layout(old_layout)
+                    .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+                    .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                    .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                    .image(self.atlas_image)
+                    .subresource_range(range)],
+            );
+            device.cmd_copy_buffer_to_image(
+                cmd,
+                frame.staging.buffer,
+                self.atlas_image,
+                vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+                &[vk::BufferImageCopy::default()
+                    .buffer_offset(0)
+                    .buffer_row_length(ATLAS_SIZE)
+                    .buffer_image_height(ATLAS_SIZE)
+                    .image_subresource(
+                        vk::ImageSubresourceLayers::default()
+                            .aspect_mask(vk::ImageAspectFlags::COLOR)
+                            .layer_count(1),
+                    )
+                    .image_extent(vk::Extent3D {
+                        width: ATLAS_SIZE,
+                        height: ATLAS_SIZE,
+                        depth: 1,
+                    })],
+            );
+            device.cmd_pipeline_barrier(
+                cmd,
+                vk::PipelineStageFlags::TRANSFER,
+                vk::PipelineStageFlags::FRAGMENT_SHADER,
+                vk::DependencyFlags::empty(),
+                &[],
+                &[],
+                &[vk::ImageMemoryBarrier::default()
+                    .src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+                    .dst_access_mask(vk::AccessFlags::SHADER_READ)
+                    .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+                    .new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
+                    .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                    .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
+                    .image(self.atlas_image)
+                    .subresource_range(range)],
+            );
+        }
+    }
+
+    /// Record the glyph draw. Must be called inside the render pass, after the
+    /// 2D quads (text goes on top). Viewport/scissor are inherited (dynamic,
+    /// already set by the caller).
+    pub(crate) fn record_draw(&self, device: &ash::Device, cmd: vk::CommandBuffer, frame_index: usize) {
+        let frame = &self.frames[frame_index];
+        if frame.vertex_count == 0 || !self.atlas_initialized {
+            return;
+        }
+        unsafe {
+            device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline);
+            device.cmd_bind_descriptor_sets(
+                cmd,
+                vk::PipelineBindPoint::GRAPHICS,
+                self.pipeline_layout,
+                0,
+                &[self.descriptor_set],
+                &[],
+            );
+            device.cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
+            device.cmd_draw(cmd, frame.vertex_count, 1, 0, 0);
+        }
+    }
+
+    pub(crate) fn destroy(&mut self, device: &ash::Device, allocator: &mut Allocator) {
+        unsafe {
+            for frame in &mut self.frames {
+                let mut vertex = std::mem::replace(&mut frame.vertex, AllocatedBuffer::null());
+                destroy_cpu_buffer(device, allocator, &mut vertex);
+                let mut staging = std::mem::replace(&mut frame.staging, AllocatedBuffer::null());
+                destroy_cpu_buffer(device, allocator, &mut staging);
+            }
+            device.destroy_sampler(self.sampler, None);
+            device.destroy_image_view(self.atlas_view, None);
+            device.destroy_image(self.atlas_image, None);
+            if let Some(allocation) = self.atlas_allocation.take() {
+                let _ = allocator.free(allocation);
+            }
+            device.destroy_descriptor_pool(self.descriptor_pool, None);
+            device.destroy_descriptor_set_layout(self.descriptor_set_layout, None);
+            device.destroy_pipeline(self.pipeline, None);
+            device.destroy_pipeline_layout(self.pipeline_layout, None);
+            device.destroy_shader_module(self.shader_module, None);
+        }
+    }
+}