git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commite6836ef30a123deccb886fb8693d97e0542a2e30
parent2d5c4d4c49
authorLucas Galante <[email protected]>
date2026-07-14 13:10
refactor: consume cce_ui::vk — the local vk module graduates to the toolkit

The renderer/text/scene stages and shaders now live in cce-ui (commit d84e138
there); this crate keeps only its usage. src/vk/, shader.wgsl, and
shader_3d.wgsl are deleted; app/render import cce_ui::vk; vk-smoke drives the
library module instead of a #[path] copy; ash/gpu-allocator/naga arrive
transitively through cce-ui.

The only behavioral delta is the merged 2D shader, which adds the engine
dialect's wavy-blob sentinel branch (inert unless a vertex carries the -999
clip_circle marker). Verified: designer launches clean on the shared module.

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

 Cargo.toml         |    5 -
 src/app.rs         |   18 +-
 src/main.rs        |    1 -
 src/render.rs      |    2 +-
 src/shader.wgsl    |  106 ----
 src/shader_3d.wgsl |   72 ---
 src/vk/glyph.wgsl  |   42 --
 src/vk/mod.rs      |   19 -
 src/vk/renderer.rs | 1400 ----------------------------------------------------
 src/vk/scene.rs    |  726 ---------------------------
 src/vk/text.rs     |  771 -----------------------------
 src/vk_smoke.rs    |    3 +-
 12 files changed, 11 insertions(+), 3154 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 481f74f..b33b36a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,11 +24,6 @@ opencl3 = "0.9"
 libc = "0.2"
 log = "0.4"
 env_logger = "0.11"
-# Raw-Vulkan migration (versions match what wgpu 24 already pulls in, so the
-# dependency tree gains no duplicate builds while both stacks coexist).
-ash = "0.38"
-gpu-allocator = { version = "0.27", default-features = false, features = ["vulkan"] }
-naga = { version = "24", features = ["wgsl-in", "spv-out"] }
 
 [[bin]]
 name = "cce-designer"
diff --git a/src/app.rs b/src/app.rs
index f35cf67..3c86817 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -43,7 +43,7 @@ use glam::{Mat4, Vec3};
 
 use crate::geometry::*;
 use crate::shortcut::{ShortcutManager, Action};
-use crate::vk::{SceneDraw, TextSpan};
+use cce_ui::vk::{SceneDraw, TextSpan};
 use cce_ui::engine::Vertex;
 use crate::window::{AppState, WindowEvent};
 
@@ -963,19 +963,19 @@ pub struct ViewportUniforms {
 }
 
 pub struct State {
-    pub renderer: crate::vk::VkRenderer,
+    pub renderer: cce_ui::vk::VkRenderer,
     pub font_system: FontSystem,
     pub swash_cache: glyphon::SwashCache,
     pub window: XdgWindow,
     pub wl_surface: wl_surface::WlSurface,
     pub vertex_data: Vec<Vertex>,
 
-    pub mesh_cube: crate::vk::MeshId,
-    pub mesh_viewport_bg: crate::vk::MeshId,
-    pub mesh_spheres: crate::vk::MeshId,
-    pub mesh_grid: crate::vk::MeshId,
-    pub mesh_origin: crate::vk::MeshId,
-    pub mesh_pivot: crate::vk::MeshId,
+    pub mesh_cube: cce_ui::vk::MeshId,
+    pub mesh_viewport_bg: cce_ui::vk::MeshId,
+    pub mesh_spheres: cce_ui::vk::MeshId,
+    pub mesh_grid: cce_ui::vk::MeshId,
+    pub mesh_origin: cce_ui::vk::MeshId,
+    pub mesh_pivot: cce_ui::vk::MeshId,
     pub vertex_count_spheres: u32,
     pub node_color: [f32; 3],
     pub grid_color: [f32; 3],
@@ -2447,7 +2447,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         let surface_ptr = wl_surface.id().as_ptr() as *mut std::ffi::c_void;
         let corner_radius = cce_ui::color::backplate_corner_radius() * scale as f32;
         let mut renderer = unsafe {
-            crate::vk::VkRenderer::new(display_ptr, surface_ptr, pw, ph, corner_radius)
+            cce_ui::vk::VkRenderer::new(display_ptr, surface_ptr, pw, ph, corner_radius)
         };
         // Bundled fonts only (the designer's UI uses bundled families).
         let font_system = cce_ui::create_font_system();
diff --git a/src/main.rs b/src/main.rs
index 964ba9e..463da13 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -42,7 +42,6 @@ use glam::{Mat4, Vec3};
 
 pub mod app;
 pub mod viewport_3d;
-pub mod vk;
 pub mod api;
 pub mod window;
 pub mod geometry;
diff --git a/src/render.rs b/src/render.rs
index 3390111..5fef997 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -17,7 +17,7 @@ use cce_ui::engine::Vertex;
 use crate::geometry::{
     network_sphere_vertices_with_errors,
 };
-use crate::vk::TextSpan;
+use cce_ui::vk::TextSpan;
 use cce_ui::engine::{
     push_widget_vertices, push_extra_quad_vertices,
     push_extra_quad_vertices_clipped, push_arc_background_vertices,
diff --git a/src/shader.wgsl b/src/shader.wgsl
deleted file mode 100644
index 2050430..0000000
--- a/src/shader.wgsl
+++ /dev/null
@@ -1,106 +0,0 @@
-@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;
-    
-    // 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) 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.clip_circle = clip_circle;
-    return out;
-}
-
-@fragment
-fn fs_main(in: VertexOutput) -> @location(0) vec4f {
-    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;
-        }
-    }
-    
-    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/shader_3d.wgsl b/src/shader_3d.wgsl
deleted file mode 100644
index 193dcda..0000000
--- a/src/shader_3d.wgsl
+++ /dev/null
@@ -1,72 +0,0 @@
-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/glyph.wgsl b/src/vk/glyph.wgsl
deleted file mode 100644
index 8cd26e0..0000000
--- a/src/vk/glyph.wgsl
+++ /dev/null
@@ -1,42 +0,0 @@
-// 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
deleted file mode 100644
index 7527939..0000000
--- a/src/vk/mod.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-//! Raw-Vulkan (ash) rendering foundation — milestone 1 of the wgpu → ash migration.
-//!
-//! `VkRenderer` reproduces the designer's 2D UI pipeline (`shader.wgsl`: NDC quads
-//! with window-corner rounding, circle clipping, and the blur-behind branch) directly
-//! on Vulkan: instance (+ validation layers when available), VK_KHR_wayland_surface,
-//! swapchain, gpu-allocator memory, and the WGSL compiled to SPIR-V through naga at
-//! startup — so `shader.wgsl` stays the single source of truth for both stacks.
-//!
-//! The 3D pass, glyphon text, and curved-text passes stay on wgpu until the cutover;
-//! `vk-smoke` is the standalone proof of this renderer.
-
-mod renderer;
-mod scene;
-mod text;
-
-pub use renderer::VkRenderer;
-#[allow(unused_imports)] // MeshId is cutover API; smoke uses ids via inference
-pub use scene::{MeshId, SceneDraw, Vertex3D};
-pub use text::TextSpan;
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
deleted file mode 100644
index 054f7a8..0000000
--- a/src/vk/renderer.rs
+++ /dev/null
@@ -1,1400 +0,0 @@
-//! 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 cce_ui::engine::Vertex;
-
-use super::scene::{MeshId, SceneDraw, SceneStage, Vertex3D};
-use super::text::{TextSpan, TextStage};
-
-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,
-}
-
-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-designer";
-        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!("../shader.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,
-            })
-            .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: 2D geometry, then any text staged via `prepare_text`.
-    /// Returns false if the frame was skipped (swapchain rebuild); the caller
-    /// just draws again next tick.
-    pub fn draw_frame(&mut self, verts: &[Vertex]) -> 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 vertices into this frame's buffer (its fence has signaled, so
-            // the GPU is done with it; growing swaps in a fresh buffer).
-            let bytes: &[u8] = bytemuck::cast_slice(verts);
-            let needed = 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 !bytes.is_empty() {
-                self.frames[frame_index]
-                    .vertex
-                    .allocation
-                    .as_mut()
-                    .unwrap()
-                    .mapped_slice_mut()
-                    .unwrap()[..bytes.len()]
-                    .copy_from_slice(bytes);
-            }
-            self.frames[frame_index].vertex_count = 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: [0.0, 0.0, 0.0, 0.0] },
-            }];
-            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,
-                }],
-            );
-            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]);
-                self.device.cmd_draw(cmd, frame.vertex_count, 1, 0, 0);
-            }
-            self.text.record_draw(&self.device, cmd, frame_index);
-            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
deleted file mode 100644
index ca79cdd..0000000
--- a/src/vk/scene.rs
+++ /dev/null
@@ -1,726 +0,0 @@
-//! 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!("../shader_3d.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/text.rs b/src/vk/text.rs
deleted file mode 100644
index ecc5c63..0000000
--- a/src/vk/text.rs
+++ /dev/null
@@ -1,771 +0,0 @@
-//! 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);
-        }
-    }
-}
diff --git a/src/vk_smoke.rs b/src/vk_smoke.rs
index a4b11cd..5e7c94d 100644
--- a/src/vk_smoke.rs
+++ b/src/vk_smoke.rs
@@ -6,8 +6,7 @@
 //! Run inside a Wayland session:
 //!   cargo run -p cce-designer --bin vk-smoke
 
-#[path = "vk/mod.rs"]
-mod vk;
+use cce_ui::vk;
 
 use smithay_client_toolkit::{
     compositor::{CompositorHandler, CompositorState},