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

commit17c4a48015f2214a89e2d6d7470ccb71bf028821
parent9e19c48d29
authorLucas Galante <[email protected]>
date2026-07-14 15:55
feat: VkCore split + user images in the 2D pass (RT-renderer phase 1)

VkCore extracts the device-level half of the backend — instance/validation,
physical device + graphics queue, gpu-allocator, command pool — with two
constructors: new_for_wayland_surface (what VkRenderer uses; picks a
present-capable device) and new_headless (no surface extensions, any graphics
device) for offscreen consumers: thumbnail rendering, previews, and the coming
RT engine. VkRenderer keeps only presentation + pipelines; the core is its
last field so drop order tears the swapchain down before the device.

Images become first-class 2D content end to end: vk::upload_rgba queues RGBA
pixels from anywhere (apps never see the renderer) and returns an id usable
immediately; the renderer drains uploads per frame into an ImageStage
(per-image descriptor sets over the glyph shader). Frame2D::images carries
ImageQuads whose z_before names the vertex they sort before, and the batch
draw interleaves them under their own clips — geometry below stays below,
later geometry (popovers, plates) covers them. Prim::Image + PaintCtx::image
plumb the display list through tessellate_display_list (now also returning
DlImages) and the runner.

Verified live in vk-smoke: checkerboard above the 3D backdrop and the
circle-clipped quad, beneath the blur plate, zero validation messages.
Workspace builds; 169 + 8 tests pass.

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

 src/backend/window_runner.rs |  54 +++-
 src/scene/paint.rs           |  10 +
 src/vk/core.rs               | 297 ++++++++++++++++++++++
 src/vk/image.rs              | 574 +++++++++++++++++++++++++++++++++++++++++++
 src/vk/mod.rs                |   4 +
 src/vk/renderer.rs           | 532 +++++++++++++++------------------------
 src/widget/model.rs          |   1 +
 7 files changed, 1137 insertions(+), 335 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index b6a4e5f..a5470f2 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1009,6 +1009,17 @@ pub struct DlBatch {
     pub end: u32,
 }
 
+/// An image draw from the display list: `at` is the vertex index it sorts
+/// before (its position in the tessellated stream); `clip` is the item's
+/// paint-walk clip. Logical coordinates throughout.
+pub struct DlImage {
+    pub image: u32,
+    pub rect: crate::scene::layout::Rect,
+    pub alpha: f32,
+    pub at: u32,
+    pub clip: Option<crate::scene::layout::Rect>,
+}
+
 /// Tessellate a `scene::paint::DisplayList`'s geometry into a flat vertex buffer plus per-clip draw
 /// batches, reusing the same tessellators as the legacy path so vertices are identical. `Text`
 /// prims are skipped here — text is still rendered via the app's `text_areas()` path. `sw`/`sh` are
@@ -1018,16 +1029,27 @@ pub fn tessellate_display_list(
     dl: &crate::scene::paint::DisplayList,
     sw: f32,
     sh: f32,
-) -> (Vec<Vertex>, Vec<DlBatch>) {
+) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>) {
     use crate::scene::paint::{Cap, Prim};
     let no = [0.0f32, 0.0, 0.0];
     let mut verts: Vec<Vertex> = Vec::new();
     let mut batches: Vec<DlBatch> = Vec::new();
+    let mut images: Vec<DlImage> = Vec::new();
 
     for item in &dl.items {
         let start = verts.len() as u32;
         match &item.prim {
-            Prim::Text { .. } => continue, // text goes through the glyphon/text_areas path
+            Prim::Text { .. } => continue, // text goes through the glyph/text-span path
+            Prim::Image { image, rect, alpha } => {
+                images.push(DlImage {
+                    image: *image,
+                    rect: *rect,
+                    alpha: *alpha,
+                    at: verts.len() as u32,
+                    clip: item.clip,
+                });
+                continue;
+            }
             Prim::Quad { rect, color } => {
                 verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
             }
@@ -1086,7 +1108,7 @@ pub fn tessellate_display_list(
         batches.push(DlBatch { scissor: item.clip, start, end });
     }
 
-    (verts, batches)
+    (verts, batches, images)
 }
 
 pub fn extra_quad_vertices(
@@ -1740,7 +1762,7 @@ impl<A: Application> EngineState<A> {
             }
         }
 
-        let (mut verts, mut dl_batches) = tessellate_display_list(&dl, logical_w, logical_h);
+        let (mut verts, mut dl_batches, dl_images) = tessellate_display_list(&dl, logical_w, logical_h);
         // custom_vertices (e.g. graph geometry) is appended as a final unclipped batch drawn on top.
         let pre_custom = verts.len() as u32;
         self.inner.as_mut().unwrap().custom_vertices(&mut verts, LogicalSize::new(logical_w, logical_h), scale_factor);
@@ -1830,6 +1852,29 @@ impl<A: Application> EngineState<A> {
         let renderer = self.renderer.as_mut().unwrap();
         renderer.prepare_text(self.font_system.as_mut().unwrap(), &mut self.swash_cache, &spans);
 
+        let image_quads: Vec<crate::vk::ImageQuad> = dl_images
+            .iter()
+            .map(|di| crate::vk::ImageQuad {
+                image: di.image,
+                rect: (
+                    di.rect.x * scale_f32,
+                    di.rect.y * scale_f32,
+                    di.rect.width * scale_f32,
+                    di.rect.height * scale_f32,
+                ),
+                alpha: di.alpha,
+                z_before: di.at,
+                clip: di.clip.map(|c| {
+                    (
+                        (c.x * scale_f32).max(0.0) as u32,
+                        (c.y * scale_f32).max(0.0) as u32,
+                        (c.width * scale_f32) as u32,
+                        (c.height * scale_f32) as u32,
+                    )
+                }),
+            })
+            .collect();
+
         let batches: Vec<Batch2D> = dl_batches
             .iter()
             .map(|batch| Batch2D {
@@ -1858,6 +1903,7 @@ impl<A: Application> EngineState<A> {
             verts: &verts,
             batches: &batches,
             overlay_verts: &overlay_verts,
+            images: &image_quads,
             clear_color,
         });
     }
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 8986251..b55c01d 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -56,6 +56,10 @@ pub enum Prim {
     /// within a box (the placed-text-box case, e.g. cce-layout-interface's canvas elements);
     /// `None` is the ordinary single-run label.
     Text { text: String, x: f32, y: f32, font_size: f32, color: [u8; 3], font: Option<String>, bounds: Option<[f32; 4]>, attrs: TextAttrs, layout: Option<TextLayout> },
+    /// A user image (id from `cce_ui::vk::upload_rgba`) drawn as a quad, in
+    /// display-list order like any other primitive. The paint walk's clip
+    /// applies through the item's `clip` as usual.
+    Image { image: u32, rect: Rect, alpha: f32 },
 }
 
 /// Horizontal alignment of laid-out (boxed) text — the toolkit-plain mirror of
@@ -209,6 +213,12 @@ impl PaintCtx {
         self.push(Prim::Quad { rect, color });
     }
 
+    /// A user image (id from `cce_ui::vk::upload_rgba`) drawn at `rect`.
+    pub fn image(&mut self, image: u32, rect: Rect, alpha: f32) {
+        let rect = self.apply_offset(rect);
+        self.push(Prim::Image { image, rect, alpha });
+    }
+
     pub fn rounded_rect(&mut self, rect: Rect, radius: f32, corners: (bool, bool, bool, bool), color: [f32; 4]) {
         let rect = self.apply_offset(rect);
         self.push(Prim::RoundedRect { rect, radius, corners, color });
diff --git a/src/vk/core.rs b/src/vk/core.rs
new file mode 100644
index 0000000..f4eedbd
--- /dev/null
+++ b/src/vk/core.rs
@@ -0,0 +1,297 @@
+//! `VkCore`: the device-level half of the Vulkan backend — instance (+
+//! validation layers), physical device + graphics queue, gpu-allocator, and
+//! the shared command pool.
+//!
+//! Two ways in: [`VkCore::new_for_wayland_surface`] picks a present-capable
+//! device for a window (what `VkRenderer` uses), and [`VkCore::new_headless`]
+//! builds the same core with no surface at all — for offscreen consumers
+//! (thumbnail rendering, previews, the future RT engine) that render into
+//! images instead of a swapchain.
+
+use std::ffi::{c_void, CStr, CString};
+
+use ash::vk;
+use gpu_allocator::vulkan::{Allocator, AllocatorCreateDesc};
+
+const VALIDATION_LAYER: &CStr = c"VK_LAYER_KHRONOS_validation";
+
+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
+}
+
+pub struct VkCore {
+    // Field order is drop order: allocator and command pool go before the
+    // device, the device before debug/instance; `_entry` (the loaded library)
+    // must outlive everything.
+    pub(crate) allocator: Option<Allocator>,
+    pub(crate) command_pool: vk::CommandPool,
+    pub(crate) queue: vk::Queue,
+    #[allow(dead_code)] // RT engine / future consumers select by family
+    pub(crate) queue_family: u32,
+    pub(crate) device: ash::Device,
+    pub(crate) physical_device: vk::PhysicalDevice,
+    pub(crate) surface_loader: ash::khr::surface::Instance,
+    pub(crate) debug: Option<(ash::ext::debug_utils::Instance, vk::DebugUtilsMessengerEXT)>,
+    pub(crate) instance: ash::Instance,
+    pub(crate) _entry: ash::Entry,
+    pub(crate) min_uniform_align: vk::DeviceSize,
+}
+
+impl VkCore {
+    /// A core bound to a Wayland surface: the returned `vk::SurfaceKHR` is
+    /// created from the raw pointers and the chosen device supports presenting
+    /// to it. The caller owns the surface handle (destroy it before the core).
+    ///
+    /// # Safety
+    /// `display_ptr` and `surface_ptr` must be live `wl_display` / `wl_surface`
+    /// pointers that outlive the core and everything created from it.
+    pub unsafe fn new_for_wayland_surface(
+        display_ptr: *mut c_void,
+        surface_ptr: *mut c_void,
+    ) -> (Self, vk::SurfaceKHR) {
+        let (core, surface) = Self::new_inner(Some((display_ptr, surface_ptr)));
+        (core, surface.expect("surface requested but not created"))
+    }
+
+    /// A windowless core: no surface extensions, any graphics-capable device.
+    /// For offscreen rendering (thumbnails, previews) and compute.
+    pub fn new_headless() -> Self {
+        unsafe { Self::new_inner(None).0 }
+    }
+
+    unsafe fn new_inner(
+        wayland: Option<(*mut c_void, *mut c_void)>,
+    ) -> (Self, Option<vk::SurfaceKHR>) {
+        let entry = ash::Entry::load().expect("Failed to load libvulkan");
+
+        // Validation when available (debug builds or CCE_VK_VALIDATION=1).
+        let want_validation =
+            cfg!(debug_assertions) || std::env::var_os("CCE_VK_VALIDATION").is_some();
+        let validation_available = want_validation
+            && entry
+                .enumerate_instance_layer_properties()
+                .map(|layers| {
+                    layers
+                        .iter()
+                        .any(|l| CStr::from_ptr(l.layer_name.as_ptr()) == VALIDATION_LAYER)
+                })
+                .unwrap_or(false);
+        if want_validation && !validation_available {
+            log::warn!(
+                "Vulkan validation requested but VK_LAYER_KHRONOS_validation is not installed"
+            );
+        }
+
+        let api_version = match entry.try_enumerate_instance_version().ok().flatten() {
+            Some(v) if v >= vk::API_VERSION_1_2 => vk::API_VERSION_1_2,
+            Some(v) => v,
+            None => vk::API_VERSION_1_0,
+        };
+        let app_name = c"cce-ui";
+        let app_info = vk::ApplicationInfo::default()
+            .application_name(app_name)
+            .engine_name(app_name)
+            .api_version(api_version);
+
+        let mut extension_names: Vec<*const i8> = Vec::new();
+        if wayland.is_some() {
+            extension_names.push(ash::khr::surface::NAME.as_ptr());
+            extension_names.push(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
+        };
+
+        // Instance-level loader; only usable when VK_KHR_surface was enabled.
+        let surface_loader = ash::khr::surface::Instance::new(&entry, &instance);
+
+        let surface = wayland.map(|(display_ptr, surface_ptr)| {
+            let wayland_loader = ash::khr::wayland_surface::Instance::new(&entry, &instance);
+            wayland_loader
+                .create_wayland_surface(
+                    &vk::WaylandSurfaceCreateInfoKHR::default()
+                        .display(display_ptr)
+                        .surface(surface_ptr),
+                    None,
+                )
+                .expect("Failed to create Wayland surface")
+        });
+
+        // Physical device + queue family: graphics, plus present support when
+        // a surface exists. Prefer integrated (the toolkit's LowPower default).
+        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 = match surface {
+                    Some(surface) => surface_loader
+                        .get_physical_device_surface_support(pd, i as u32, surface)
+                        .unwrap_or(false),
+                    None => true,
+                };
+                (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 suitable Vulkan device found");
+        {
+            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 min_uniform_align = instance
+            .get_physical_device_properties(physical_device)
+            .limits
+            .min_uniform_buffer_offset_alignment;
+
+        let queue_priorities = [1.0f32];
+        let queue_infos = [vk::DeviceQueueCreateInfo::default()
+            .queue_family_index(queue_family)
+            .queue_priorities(&queue_priorities)];
+        let device_extensions: Vec<*const i8> = if wayland.is_some() {
+            vec![ash::khr::swapchain::NAME.as_ptr()]
+        } else {
+            Vec::new()
+        };
+        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 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");
+
+        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");
+
+        (
+            VkCore {
+                allocator: Some(allocator),
+                command_pool,
+                queue,
+                queue_family,
+                device,
+                physical_device,
+                surface_loader,
+                debug,
+                instance,
+                _entry: entry,
+                min_uniform_align,
+            },
+            surface,
+        )
+    }
+}
+
+impl Drop for VkCore {
+    fn drop(&mut self) {
+        unsafe {
+            let _ = self.device.device_wait_idle();
+            // The allocator must go before the device it allocates from.
+            drop(self.allocator.take());
+            self.device.destroy_command_pool(self.command_pool, None);
+            self.device.destroy_device(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/image.rs b/src/vk/image.rs
new file mode 100644
index 0000000..0caf84c
--- /dev/null
+++ b/src/vk/image.rs
@@ -0,0 +1,574 @@
+//! User images in the 2D pass: upload RGBA pixels once, then draw them as
+//! quads interleaved with the display list — 3D previews in graph nodes,
+//! thumbnails in the files grid, any raster content in the UI.
+//!
+//! Upload is decoupled from the renderer because most apps never touch it
+//! (the engine runner owns the frame): [`upload_rgba`] queues pixels from any
+//! code and returns a stable id usable immediately in draws; the renderer
+//! drains the queue at the next frame. [`free_image`] queues destruction the
+//! same way. Draw ordering comes from [`super::Frame2D::images`]: each
+//! [`ImageQuad`] carries the vertex index it sorts before.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicU32, Ordering};
+use std::sync::Mutex;
+
+use ash::vk;
+use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
+use gpu_allocator::MemoryLocation;
+
+use super::renderer::{compile_wgsl, create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
+
+/// One image draw in a 2D frame.
+pub struct ImageQuad {
+    /// Id from [`upload_rgba`].
+    pub image: u32,
+    /// Destination rect (x, y, w, h) in physical pixels.
+    pub rect: (f32, f32, f32, f32),
+    pub alpha: f32,
+    /// Draw order: this quad renders before the vertex at this index of
+    /// `Frame2D::verts` (so vertices below it stay below, later ones cover it).
+    /// Use `u32::MAX` to draw on top of all display-list geometry.
+    pub z_before: u32,
+    /// Optional scissor (x, y, w, h) in physical pixels.
+    pub clip: Option<(u32, u32, u32, u32)>,
+}
+
+enum Pending {
+    Upload { id: u32, pixels: Vec<u8>, width: u32, height: u32 },
+    Free { id: u32 },
+}
+
+static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
+static NEXT_ID: AtomicU32 = AtomicU32::new(1);
+
+/// Queue an RGBA8 image for upload; the id is usable in [`ImageQuad`]s right
+/// away (draws before the upload lands are skipped, not errors).
+pub fn upload_rgba(pixels: Vec<u8>, width: u32, height: u32) -> u32 {
+    assert_eq!(pixels.len(), (width * height * 4) as usize, "RGBA8 size mismatch");
+    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
+    PENDING.lock().unwrap().push(Pending::Upload { id, pixels, width, height });
+    id
+}
+
+/// Queue an image's GPU resources for destruction.
+pub fn free_image(id: u32) {
+    PENDING.lock().unwrap().push(Pending::Free { id });
+}
+
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+struct ImageVertex {
+    position: [f32; 2],
+    uv: [f32; 2],
+    color: [f32; 4],
+    clip_circle: [f32; 3],
+}
+
+struct GpuImage {
+    image: vk::Image,
+    view: vk::ImageView,
+    allocation: Option<Allocation>,
+    descriptor_set: vk::DescriptorSet,
+}
+
+const MAX_IMAGES: u32 = 256;
+
+pub(crate) struct ImageStage {
+    pipeline: vk::Pipeline,
+    pipeline_layout: vk::PipelineLayout,
+    descriptor_set_layout: vk::DescriptorSetLayout,
+    descriptor_pool: vk::DescriptorPool,
+    shader_module: vk::ShaderModule,
+    sampler: vk::Sampler,
+    images: HashMap<u32, GpuImage>,
+    /// Per frame in flight: this frame's quad vertices (6 per ImageQuad).
+    frame_buffers: Vec<AllocatedBuffer>,
+}
+
+impl ImageStage {
+    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 image 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 image pipeline layout");
+
+            // Same shader as glyphs: sampled texel * vertex color (+ circle clip).
+            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 image 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::<ImageVertex>() 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 image pipeline")[0];
+
+            // Linear filtering: thumbnails scale smoothly.
+            let 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 image sampler");
+
+            let pool_sizes = [
+                vk::DescriptorPoolSize::default()
+                    .ty(vk::DescriptorType::SAMPLED_IMAGE)
+                    .descriptor_count(MAX_IMAGES),
+                vk::DescriptorPoolSize::default()
+                    .ty(vk::DescriptorType::SAMPLER)
+                    .descriptor_count(MAX_IMAGES),
+            ];
+            let descriptor_pool = device
+                .create_descriptor_pool(
+                    &vk::DescriptorPoolCreateInfo::default()
+                        .flags(vk::DescriptorPoolCreateFlags::FREE_DESCRIPTOR_SET)
+                        .max_sets(MAX_IMAGES)
+                        .pool_sizes(&pool_sizes),
+                    None,
+                )
+                .expect("Failed to create image descriptor pool");
+
+            let frame_buffers = (0..frames_in_flight)
+                .map(|_| {
+                    create_cpu_buffer(
+                        device,
+                        allocator,
+                        16 * 1024,
+                        vk::BufferUsageFlags::VERTEX_BUFFER,
+                        "image-quads",
+                    )
+                })
+                .collect();
+
+            ImageStage {
+                pipeline,
+                pipeline_layout,
+                descriptor_set_layout,
+                descriptor_pool,
+                shader_module,
+                sampler,
+                images: HashMap::new(),
+                frame_buffers,
+            }
+        }
+    }
+
+    /// Drain the global upload/free queue. Uploads are synchronous one-time
+    /// submits (rare: images load once); frees wait for device idle.
+    pub(crate) fn process_pending(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        queue: vk::Queue,
+        command_pool: vk::CommandPool,
+    ) {
+        let pending: Vec<Pending> = std::mem::take(&mut *PENDING.lock().unwrap());
+        for item in pending {
+            match item {
+                Pending::Upload { id, pixels, width, height } => {
+                    self.upload(device, allocator, queue, command_pool, id, &pixels, width, height);
+                }
+                Pending::Free { id } => {
+                    if let Some(mut gpu) = self.images.remove(&id) {
+                        unsafe {
+                            let _ = device.device_wait_idle();
+                            device.destroy_image_view(gpu.view, None);
+                            device.destroy_image(gpu.image, None);
+                            let _ = device.free_descriptor_sets(
+                                self.descriptor_pool,
+                                &[gpu.descriptor_set],
+                            );
+                        }
+                        if let Some(a) = gpu.allocation.take() {
+                            let _ = allocator.free(a);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    #[allow(clippy::too_many_arguments)]
+    fn upload(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        queue: vk::Queue,
+        command_pool: vk::CommandPool,
+        id: u32,
+        pixels: &[u8],
+        width: u32,
+        height: u32,
+    ) {
+        if self.images.len() as u32 >= MAX_IMAGES {
+            log::error!("image registry full ({MAX_IMAGES}); dropping upload {id}");
+            return;
+        }
+        unsafe {
+            let image = device
+                .create_image(
+                    &vk::ImageCreateInfo::default()
+                        .image_type(vk::ImageType::TYPE_2D)
+                        .format(vk::Format::R8G8B8A8_UNORM)
+                        .extent(vk::Extent3D { width, height, 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 user image");
+            let requirements = device.get_image_memory_requirements(image);
+            let allocation = allocator
+                .allocate(&AllocationCreateDesc {
+                    name: "user-image",
+                    requirements,
+                    location: MemoryLocation::GpuOnly,
+                    linear: false,
+                    allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+                })
+                .expect("Failed to allocate user image memory");
+            device
+                .bind_image_memory(image, allocation.memory(), allocation.offset())
+                .expect("Failed to bind user image memory");
+
+            let mut staging = create_cpu_buffer(
+                device,
+                allocator,
+                pixels.len() as vk::DeviceSize,
+                vk::BufferUsageFlags::TRANSFER_SRC,
+                "image-staging",
+            );
+            staging.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..pixels.len()]
+                .copy_from_slice(pixels);
+
+            let range = vk::ImageSubresourceRange::default()
+                .aspect_mask(vk::ImageAspectFlags::COLOR)
+                .level_count(1)
+                .layer_count(1);
+            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 upload 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(range)],
+            );
+            device.cmd_copy_buffer_to_image(
+                cmd,
+                staging.buffer,
+                image,
+                vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+                &[vk::BufferImageCopy::default()
+                    .buffer_row_length(width)
+                    .buffer_image_height(height)
+                    .image_subresource(
+                        vk::ImageSubresourceLayers::default()
+                            .aspect_mask(vk::ImageAspectFlags::COLOR)
+                            .layer_count(1),
+                    )
+                    .image_extent(vk::Extent3D { width, height, 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(image)
+                    .subresource_range(range)],
+            );
+            device.end_command_buffer(cmd).unwrap();
+            let cmds = [cmd];
+            device
+                .queue_submit(queue, &[vk::SubmitInfo::default().command_buffers(&cmds)], vk::Fence::null())
+                .expect("Image upload submit failed");
+            device.queue_wait_idle(queue).expect("Image upload wait failed");
+            device.free_command_buffers(command_pool, &cmds);
+            destroy_cpu_buffer(device, allocator, &mut staging);
+
+            let view = device
+                .create_image_view(
+                    &vk::ImageViewCreateInfo::default()
+                        .image(image)
+                        .view_type(vk::ImageViewType::TYPE_2D)
+                        .format(vk::Format::R8G8B8A8_UNORM)
+                        .subresource_range(range),
+                    None,
+                )
+                .expect("Failed to create user image view");
+
+            let set_layouts = [self.descriptor_set_layout];
+            let descriptor_set = device
+                .allocate_descriptor_sets(
+                    &vk::DescriptorSetAllocateInfo::default()
+                        .descriptor_pool(self.descriptor_pool)
+                        .set_layouts(&set_layouts),
+                )
+                .expect("Failed to allocate image descriptor set")[0];
+            let image_infos = [vk::DescriptorImageInfo::default()
+                .image_view(view)
+                .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
+            let sampler_infos = [vk::DescriptorImageInfo::default().sampler(self.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),
+                ],
+                &[],
+            );
+
+            self.images.insert(id, GpuImage { image, view, allocation: Some(allocation), descriptor_set });
+        }
+    }
+
+    /// After the frame fence: build this frame's quad vertices (6 per image,
+    /// in `images` order).
+    pub(crate) fn write_frame_buffer(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        frame_index: usize,
+        images: &[ImageQuad],
+        extent: vk::Extent2D,
+    ) {
+        let sw = extent.width as f32;
+        let sh = extent.height as f32;
+        let mut verts: Vec<ImageVertex> = Vec::with_capacity(images.len() * 6);
+        for q in images {
+            let (x, y, w, h) = q.rect;
+            let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
+            let color = [1.0, 1.0, 1.0, q.alpha];
+            let clip_circle = [0.0; 3];
+            let tl = ImageVertex { position: ndc(x, y), uv: [0.0, 0.0], color, clip_circle };
+            let tr = ImageVertex { position: ndc(x + w, y), uv: [1.0, 0.0], color, clip_circle };
+            let bl = ImageVertex { position: ndc(x, y + h), uv: [0.0, 1.0], color, clip_circle };
+            let br = ImageVertex { position: ndc(x + w, y + h), uv: [1.0, 1.0], color, clip_circle };
+            verts.extend([tl, tr, bl, tr, br, bl]);
+        }
+        let bytes: &[u8] = bytemuck::cast_slice(&verts);
+        let buf = &mut self.frame_buffers[frame_index];
+        if bytes.len() as vk::DeviceSize > buf.size {
+            let mut old = std::mem::replace(buf, AllocatedBuffer::null());
+            destroy_cpu_buffer(device, allocator, &mut old);
+            *buf = create_cpu_buffer(
+                device,
+                allocator,
+                (bytes.len() as vk::DeviceSize).next_power_of_two(),
+                vk::BufferUsageFlags::VERTEX_BUFFER,
+                "image-quads",
+            );
+        }
+        if !bytes.is_empty() {
+            buf.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..bytes.len()]
+                .copy_from_slice(bytes);
+        }
+    }
+
+    /// Record one image quad (index `i` of this frame's list). The caller
+    /// restores its own pipeline/scissor state afterwards. Returns false if the
+    /// image hasn't finished uploading (draw skipped).
+    pub(crate) fn record_quad(
+        &self,
+        device: &ash::Device,
+        cmd: vk::CommandBuffer,
+        frame_index: usize,
+        i: usize,
+        image_id: u32,
+    ) -> bool {
+        let Some(gpu) = self.images.get(&image_id) else {
+            return false;
+        };
+        unsafe {
+            device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline);
+            device.cmd_bind_descriptor_sets(
+                cmd,
+                vk::PipelineBindPoint::GRAPHICS,
+                self.pipeline_layout,
+                0,
+                &[gpu.descriptor_set],
+                &[],
+            );
+            device.cmd_bind_vertex_buffers(cmd, 0, &[self.frame_buffers[frame_index].buffer], &[0]);
+            device.cmd_draw(cmd, 6, 1, (i * 6) as u32, 0);
+        }
+        true
+    }
+
+    pub(crate) fn destroy(&mut self, device: &ash::Device, allocator: &mut Allocator) {
+        unsafe {
+            for (_, mut gpu) in self.images.drain() {
+                device.destroy_image_view(gpu.view, None);
+                device.destroy_image(gpu.image, None);
+                if let Some(a) = gpu.allocation.take() {
+                    let _ = allocator.free(a);
+                }
+            }
+            for buf in &mut self.frame_buffers {
+                let mut b = std::mem::replace(buf, AllocatedBuffer::null());
+                destroy_cpu_buffer(device, allocator, &mut b);
+            }
+            device.destroy_sampler(self.sampler, None);
+            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/mod.rs b/src/vk/mod.rs
index d85f0d2..d7f7dff 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -23,10 +23,14 @@
 //! naga's ADJUST_COORDINATE_SPACE — a shader-side flip would reverse winding
 //! and break the 3D pipeline's back-face culling.
 
+mod core;
+pub mod image;
 mod renderer;
 mod scene;
 mod text;
 
+pub use core::VkCore;
+pub use image::{free_image, upload_rgba, ImageQuad};
 pub use renderer::{Batch2D, Frame2D, VkRenderer};
 pub use scene::{MeshId, SceneDraw, Vertex3D};
 pub use text::TextSpan;
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 3d0db38..42f5ce7 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -5,16 +5,17 @@
 //! 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 std::ffi::c_void;
 
 use ash::vk;
 use gpu_allocator::vulkan::{
-    Allocation, AllocationCreateDesc, AllocationScheme, Allocator, AllocatorCreateDesc,
+    Allocation, AllocationCreateDesc, AllocationScheme, Allocator,
 };
 use gpu_allocator::MemoryLocation;
 
 use crate::engine::Vertex;
 
+use super::image::{ImageQuad, ImageStage};
 use super::scene::{MeshId, SceneDraw, SceneStage, Vertex3D};
 use super::text::{TextSpan, TextStage};
 
@@ -33,11 +34,12 @@ pub struct Frame2D<'a> {
     pub verts: &'a [Vertex],
     pub batches: &'a [Batch2D],
     pub overlay_verts: &'a [Vertex],
+    /// User images drawn interleaved with `verts` by each quad's `z_before`.
+    pub images: &'a [ImageQuad],
     pub clear_color: [f32; 4],
 }
 
 const FRAMES_IN_FLIGHT: usize = 2;
-const VALIDATION_LAYER: &CStr = c"VK_LAYER_KHRONOS_validation";
 
 pub(crate) struct AllocatedBuffer {
     pub(crate) buffer: vk::Buffer,
@@ -119,15 +121,7 @@ struct Frame {
 }
 
 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,
@@ -154,15 +148,19 @@ pub struct VkRenderer {
     backdrop_sampler: vk::Sampler,
     window_info: AllocatedBuffer,
 
-    command_pool: vk::CommandPool,
     frames: Vec<Frame>,
     frame_index: usize,
     text: TextStage,
     scene: SceneStage,
+    image: ImageStage,
 
     desired_extent: vk::Extent2D,
     corner_radius_px: f32,
     swapchain_dirty: bool,
+
+    // Declared last: everything above must be destroyed before the device/
+    // instance the core tears down in its own Drop.
+    core: super::core::VkCore,
 }
 
 /// Compile WGSL to SPIR-V. The Y-flip between wgpu NDC (Y-up) and Vulkan NDC
@@ -281,31 +279,6 @@ pub(crate) fn flipped_viewport(extent: vk::Extent2D) -> vk::Viewport {
     }
 }
 
-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
@@ -318,157 +291,16 @@ impl VkRenderer {
         height: u32,
         corner_radius_px: f32,
     ) -> Self {
-        let entry = ash::Entry::load().expect("Failed to load libvulkan");
-
-        // Instance, with validation when available (debug builds or CCE_VK_VALIDATION=1).
-        let want_validation =
-            cfg!(debug_assertions) || std::env::var_os("CCE_VK_VALIDATION").is_some();
-        let validation_available = want_validation
-            && entry
-                .enumerate_instance_layer_properties()
-                .map(|layers| {
-                    layers.iter().any(|l| {
-                        CStr::from_ptr(l.layer_name.as_ptr()) == VALIDATION_LAYER
-                    })
-                })
-                .unwrap_or(false);
-        if want_validation && !validation_available {
-            log::warn!("Vulkan validation requested but VK_LAYER_KHRONOS_validation is not installed");
-        }
-
-        let api_version = match entry.try_enumerate_instance_version().ok().flatten() {
-            Some(v) if v >= vk::API_VERSION_1_2 => vk::API_VERSION_1_2,
-            Some(v) => v,
-            None => vk::API_VERSION_1_0,
-        };
-        let app_name = c"cce-ui";
-        let app_info = vk::ApplicationInfo::default()
-            .application_name(app_name)
-            .engine_name(app_name)
-            .api_version(api_version);
-
-        let mut extension_names = vec![
-            ash::khr::surface::NAME.as_ptr(),
-            ash::khr::wayland_surface::NAME.as_ptr(),
-        ];
-        if validation_available {
-            extension_names.push(ash::ext::debug_utils::NAME.as_ptr());
-        }
-        let layer_names_owned: Vec<CString> = if validation_available {
-            vec![VALIDATION_LAYER.to_owned()]
-        } else {
-            Vec::new()
-        };
-        let layer_names: Vec<*const i8> =
-            layer_names_owned.iter().map(|l| l.as_ptr()).collect();
-
-        let instance = entry
-            .create_instance(
-                &vk::InstanceCreateInfo::default()
-                    .application_info(&app_info)
-                    .enabled_extension_names(&extension_names)
-                    .enabled_layer_names(&layer_names),
-                None,
-            )
-            .expect("Failed to create Vulkan instance");
-
-        let debug = if validation_available {
-            let loader = ash::ext::debug_utils::Instance::new(&entry, &instance);
-            let messenger = loader
-                .create_debug_utils_messenger(
-                    &vk::DebugUtilsMessengerCreateInfoEXT::default()
-                        .message_severity(
-                            vk::DebugUtilsMessageSeverityFlagsEXT::ERROR
-                                | vk::DebugUtilsMessageSeverityFlagsEXT::WARNING,
-                        )
-                        .message_type(
-                            vk::DebugUtilsMessageTypeFlagsEXT::GENERAL
-                                | vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION
-                                | vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE,
-                        )
-                        .pfn_user_callback(Some(debug_callback)),
-                    None,
-                )
-                .expect("Failed to create debug messenger");
-            log::info!("Vulkan validation layers enabled");
-            Some((loader, messenger))
-        } else {
-            None
-        };
-
-        // Wayland surface from the same raw pointers WgpuAdapter uses.
-        let wayland_loader = ash::khr::wayland_surface::Instance::new(&entry, &instance);
-        let surface = wayland_loader
-            .create_wayland_surface(
-                &vk::WaylandSurfaceCreateInfoKHR::default()
-                    .display(display_ptr)
-                    .surface(surface_ptr),
-                None,
-            )
-            .expect("Failed to create Wayland surface");
-        let surface_loader = ash::khr::surface::Instance::new(&entry, &instance);
-
-        // Physical device + queue family: graphics with present support on this
-        // surface. Prefer integrated (matches WgpuAdapter's LowPower preference).
-        let mut candidates: Vec<(vk::PhysicalDevice, u32, i32)> = Vec::new();
-        for pd in instance
-            .enumerate_physical_devices()
-            .expect("No Vulkan physical devices")
-        {
-            let families = instance.get_physical_device_queue_family_properties(pd);
-            let family = families.iter().enumerate().find_map(|(i, f)| {
-                let graphics = f.queue_flags.contains(vk::QueueFlags::GRAPHICS);
-                let present = surface_loader
-                    .get_physical_device_surface_support(pd, i as u32, surface)
-                    .unwrap_or(false);
-                (graphics && present).then_some(i as u32)
-            });
-            if let Some(family) = family {
-                let props = instance.get_physical_device_properties(pd);
-                let rank = match props.device_type {
-                    vk::PhysicalDeviceType::INTEGRATED_GPU => 0,
-                    vk::PhysicalDeviceType::DISCRETE_GPU => 1,
-                    vk::PhysicalDeviceType::VIRTUAL_GPU => 2,
-                    _ => 3,
-                };
-                candidates.push((pd, family, rank));
-            }
-        }
-        candidates.sort_by_key(|&(_, _, rank)| rank);
-        let (physical_device, queue_family, _) = *candidates
-            .first()
-            .expect("No Vulkan device supports this Wayland surface");
-        {
-            let props = instance.get_physical_device_properties(physical_device);
-            let name = CStr::from_ptr(props.device_name.as_ptr()).to_string_lossy();
-            log::info!("Vulkan device: {name}");
-        }
-
-        let queue_priorities = [1.0f32];
-        let queue_infos = [vk::DeviceQueueCreateInfo::default()
-            .queue_family_index(queue_family)
-            .queue_priorities(&queue_priorities)];
-        let device_extensions = [ash::khr::swapchain::NAME.as_ptr()];
-        let device = instance
-            .create_device(
-                physical_device,
-                &vk::DeviceCreateInfo::default()
-                    .queue_create_infos(&queue_infos)
-                    .enabled_extension_names(&device_extensions),
-                None,
-            )
-            .expect("Failed to create Vulkan device");
-        let queue = device.get_device_queue(queue_family, 0);
-
-        let mut allocator = Allocator::new(&AllocatorCreateDesc {
-            instance: instance.clone(),
-            device: device.clone(),
-            physical_device,
-            debug_settings: Default::default(),
-            buffer_device_address: false,
-            allocation_sizes: Default::default(),
-        })
-        .expect("Failed to create GPU allocator");
+        let (mut core, surface) =
+            super::core::VkCore::new_for_wayland_surface(display_ptr, surface_ptr);
+        // Locals over the core for the setup below (methods use self.core.*).
+        let device = core.device.clone();
+        let queue = core.queue;
+        let command_pool = core.command_pool;
+        let physical_device = core.physical_device;
+        let min_uniform_align = core.min_uniform_align;
+        let surface_loader = core.surface_loader.clone();
+        let allocator = core.allocator.as_mut().unwrap();
 
         // Surface format: prefer sRGB (wgpu's get_default_config sorts sRGB first,
         // so this matches the colors the app renders today).
@@ -671,25 +503,12 @@ impl VkRenderer {
             )
             .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,
+            allocator,
             surface_format.format,
             initial_extent,
             FRAMES_IN_FLIGHT,
@@ -713,7 +532,7 @@ impl VkRenderer {
 
         let window_info = create_cpu_buffer(
             &device,
-            &mut allocator,
+            allocator,
             16,
             vk::BufferUsageFlags::UNIFORM_BUFFER,
             "window-info",
@@ -799,7 +618,7 @@ impl VkRenderer {
                     .unwrap(),
                 vertex: create_cpu_buffer(
                     &device,
-                    &mut allocator,
+                    allocator,
                     64 * 1024,
                     vk::BufferUsageFlags::VERTEX_BUFFER,
                     "vertices",
@@ -810,19 +629,12 @@ impl VkRenderer {
             })
             .collect();
 
-        let text = TextStage::new(&device, &mut allocator, render_pass, FRAMES_IN_FLIGHT);
+        let text = TextStage::new(&device, allocator, render_pass, FRAMES_IN_FLIGHT);
+        let image = ImageStage::new(&device, allocator, render_pass, FRAMES_IN_FLIGHT);
 
-        let swapchain_loader = ash::khr::swapchain::Device::new(&instance, &device);
+        let swapchain_loader = ash::khr::swapchain::Device::new(&core.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(),
@@ -841,14 +653,15 @@ impl VkRenderer {
             descriptor_set,
             backdrop_sampler,
             window_info,
-            command_pool,
             frames,
             frame_index: 0,
             text,
             scene,
+            image,
             desired_extent: vk::Extent2D { width: width.max(1), height: height.max(1) },
             corner_radius_px,
             swapchain_dirty: false,
+            core,
         };
         renderer.create_swapchain();
         renderer.write_window_info();
@@ -874,23 +687,23 @@ impl VkRenderer {
     fn destroy_swapchain_resources(&mut self) {
         unsafe {
             for fb in self.framebuffers.drain(..) {
-                self.device.destroy_framebuffer(fb, None);
+                self.core.device.destroy_framebuffer(fb, None);
             }
             for view in self.swapchain_views.drain(..) {
-                self.device.destroy_image_view(view, None);
+                self.core.device.destroy_image_view(view, None);
             }
             self.swapchain_images.clear();
             for sem in self.render_finished.drain(..) {
-                self.device.destroy_semaphore(sem, None);
+                self.core.device.destroy_semaphore(sem, None);
             }
         }
     }
 
     fn create_swapchain(&mut self) {
         unsafe {
-            let caps = self
+            let caps = self.core
                 .surface_loader
-                .get_physical_device_surface_capabilities(self.physical_device, self.surface)
+                .get_physical_device_surface_capabilities(self.core.physical_device, self.surface)
                 .expect("Failed to query surface capabilities");
 
             // Wayland reports "extent defined by the swapchain" (u32::MAX); use the
@@ -968,7 +781,7 @@ impl VkRenderer {
                 .base_array_layer(0)
                 .layer_count(1);
             for image in &images {
-                let view = self
+                let view = self.core
                     .device
                     .create_image_view(
                         &vk::ImageViewCreateInfo::default()
@@ -981,7 +794,7 @@ impl VkRenderer {
                     .expect("Failed to create swapchain view");
                 self.swapchain_views.push(view);
                 let attachments = [view];
-                let fb = self
+                let fb = self.core
                     .device
                     .create_framebuffer(
                         &vk::FramebufferCreateInfo::default()
@@ -995,7 +808,7 @@ impl VkRenderer {
                     .expect("Failed to create framebuffer");
                 self.framebuffers.push(fb);
                 self.render_finished.push(
-                    self.device
+                    self.core.device
                         .create_semaphore(&vk::SemaphoreCreateInfo::default(), None)
                         .unwrap(),
                 );
@@ -1005,7 +818,7 @@ impl VkRenderer {
 
     fn recreate_swapchain(&mut self) {
         unsafe {
-            let _ = self.device.device_wait_idle();
+            let _ = self.core.device.device_wait_idle();
         }
         self.destroy_swapchain_resources();
         self.create_swapchain();
@@ -1018,21 +831,21 @@ impl VkRenderer {
     /// legal to sample.
     fn sync_backdrop_targets(&mut self) {
         self.scene.resize(
-            &self.device,
-            self.allocator.as_mut().unwrap(),
+            &self.core.device,
+            self.core.allocator.as_mut().unwrap(),
             self.extent,
         );
         clear_image_to_shader_read(
-            &self.device,
-            self.queue,
-            self.command_pool,
+            &self.core.device,
+            self.core.queue,
+            self.core.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(
+            self.core.device.update_descriptor_sets(
                 &[vk::WriteDescriptorSet::default()
                     .dst_set(self.descriptor_set)
                     .dst_binding(0)
@@ -1047,7 +860,7 @@ impl VkRenderer {
     /// renderer's lifetime.
     pub fn create_mesh(&mut self, verts: &[Vertex3D]) -> MeshId {
         self.scene
-            .create_mesh(&self.device, self.allocator.as_mut().unwrap(), verts)
+            .create_mesh(&self.core.device, self.core.allocator.as_mut().unwrap(), verts)
     }
 
     /// Replace a mesh's vertices. Waits for the GPU to go idle first — geometry
@@ -1055,10 +868,10 @@ impl VkRenderer {
     #[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();
+            let _ = self.core.device.device_wait_idle();
         }
         self.scene
-            .update_mesh(&self.device, self.allocator.as_mut().unwrap(), id, verts);
+            .update_mesh(&self.core.device, self.core.allocator.as_mut().unwrap(), id, verts);
     }
 
     /// Stage the 3D scene for the next `draw_frame`. Draws render into the
@@ -1108,6 +921,7 @@ impl VkRenderer {
             verts,
             batches: &[],
             overlay_verts: &[],
+            images: &[],
             clear_color: [0.0; 4],
         })
     }
@@ -1128,7 +942,7 @@ impl VkRenderer {
                 let f = &self.frames[frame_index];
                 (f.in_flight, f.image_available)
             };
-            self.device
+            self.core.device
                 .wait_for_fences(&[in_flight], true, u64::MAX)
                 .expect("Fence wait failed");
 
@@ -1154,7 +968,7 @@ impl VkRenderer {
                 }
             };
 
-            self.device.reset_fences(&[in_flight]).unwrap();
+            self.core.device.reset_fences(&[in_flight]).unwrap();
 
             // Upload display-list + overlay vertices into this frame's buffer
             // (its fence has signaled, so the GPU is done with it; growing swaps
@@ -1165,10 +979,10 @@ impl VkRenderer {
             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);
+                let allocator = self.core.allocator.as_mut().unwrap();
+                destroy_cpu_buffer(&self.core.device, allocator, &mut old);
                 self.frames[frame_index].vertex = create_cpu_buffer(
-                    &self.device,
+                    &self.core.device,
                     allocator,
                     needed.next_power_of_two(),
                     vk::BufferUsageFlags::VERTEX_BUFFER,
@@ -1191,27 +1005,40 @@ impl VkRenderer {
             self.frames[frame_index].overlay_start = frame2d.verts.len() as u32;
             self.frames[frame_index].overlay_count = frame2d.overlay_verts.len() as u32;
             self.text.write_frame_buffers(
-                &self.device,
-                self.allocator.as_mut().unwrap(),
+                &self.core.device,
+                self.core.allocator.as_mut().unwrap(),
                 frame_index,
             );
+            self.image.process_pending(
+                &self.core.device,
+                self.core.allocator.as_mut().unwrap(),
+                self.core.queue,
+                self.core.command_pool,
+            );
+            self.image.write_frame_buffer(
+                &self.core.device,
+                self.core.allocator.as_mut().unwrap(),
+                frame_index,
+                frame2d.images,
+                self.extent,
+            );
             self.scene.write_frame_uniforms(
-                &self.device,
-                self.allocator.as_mut().unwrap(),
+                &self.core.device,
+                self.core.allocator.as_mut().unwrap(),
                 frame_index,
                 self.corner_radius_px,
             );
 
             // Record.
             let cmd = self.frames[frame_index].cmd;
-            self.device
+            self.core.device
                 .begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default())
                 .unwrap();
-            self.text.record_upload(&self.device, cmd, frame_index);
+            self.text.record_upload(&self.core.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);
+            let scene_recorded = self.scene.record(&self.core.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.
@@ -1219,7 +1046,7 @@ impl VkRenderer {
             if use_backdrop {
                 if !scene_recorded {
                     // Reused backdrop is in SHADER_READ_ONLY from last frame.
-                    self.device.cmd_pipeline_barrier(
+                    self.core.device.cmd_pipeline_barrier(
                         cmd,
                         vk::PipelineStageFlags::FRAGMENT_SHADER,
                         vk::PipelineStageFlags::TRANSFER,
@@ -1238,7 +1065,7 @@ impl VkRenderer {
                     );
                 }
                 let swapchain_image = self.swapchain_images[image_index as usize];
-                self.device.cmd_pipeline_barrier(
+                self.core.device.cmd_pipeline_barrier(
                     cmd,
                     vk::PipelineStageFlags::TOP_OF_PIPE,
                     vk::PipelineStageFlags::TRANSFER,
@@ -1258,7 +1085,7 @@ impl VkRenderer {
                 let subresource = vk::ImageSubresourceLayers::default()
                     .aspect_mask(vk::ImageAspectFlags::COLOR)
                     .layer_count(1);
-                self.device.cmd_copy_image(
+                self.core.device.cmd_copy_image(
                     cmd,
                     self.scene.backdrop_image,
                     vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
@@ -1274,7 +1101,7 @@ impl VkRenderer {
                         })],
                 );
                 // Backdrop back to sampleable for the UI pass's blur plates.
-                self.device.cmd_pipeline_barrier(
+                self.core.device.cmd_pipeline_barrier(
                     cmd,
                     vk::PipelineStageFlags::TRANSFER,
                     vk::PipelineStageFlags::FRAGMENT_SHADER,
@@ -1302,7 +1129,7 @@ impl VkRenderer {
             } else {
                 (self.render_pass, &clear_values)
             };
-            self.device.cmd_begin_render_pass(
+            self.core.device.cmd_begin_render_pass(
                 cmd,
                 &vk::RenderPassBeginInfo::default()
                     .render_pass(ui_pass)
@@ -1314,9 +1141,9 @@ impl VkRenderer {
                     .clear_values(ui_clear_values),
                 vk::SubpassContents::INLINE,
             );
-            self.device
+            self.core.device
                 .cmd_set_viewport(cmd, 0, &[flipped_viewport(self.extent)]);
-            self.device.cmd_set_scissor(
+            self.core.device.cmd_set_scissor(
                 cmd,
                 0,
                 &[vk::Rect2D {
@@ -1328,61 +1155,111 @@ impl VkRenderer {
                 offset: vk::Offset2D { x: 0, y: 0 },
                 extent: self.extent,
             };
-            if frame.vertex_count > 0 {
-                self.device
-                    .cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline);
-                self.device.cmd_bind_descriptor_sets(
-                    cmd,
-                    vk::PipelineBindPoint::GRAPHICS,
-                    self.pipeline_layout,
-                    0,
-                    &[self.descriptor_set],
-                    &[],
-                );
-                self.device
-                    .cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
-                if frame2d.batches.is_empty() {
-                    self.device.cmd_draw(cmd, frame.vertex_count, 1, 0, 0);
-                } else {
-                    // Each batch draws its range under its own scissor.
-                    for batch in frame2d.batches {
-                        if batch.end <= batch.start {
-                            continue;
-                        }
-                        match batch.scissor {
-                            Some((bx, by, bw, bh)) => {
-                                if bx >= self.extent.width || by >= self.extent.height {
-                                    continue;
-                                }
+            // Display-list geometry interleaved with user images: each image
+            // quad draws before the vertex its `z_before` names, so it sits
+            // above earlier geometry and below later geometry.
+            {
+                let images = frame2d.images;
+                let mut order: Vec<usize> = (0..images.len()).collect();
+                order.sort_by_key(|&k| images[k].z_before);
+                let mut img_i = 0usize;
+
+                let default_batch =
+                    [Batch2D { scissor: None, start: 0, end: frame.vertex_count }];
+                let batches: &[Batch2D] =
+                    if frame2d.batches.is_empty() { &default_batch } else { frame2d.batches };
+
+                for batch in batches {
+                    // Resolve the batch scissor; a degenerate one skips the
+                    // vertex draws (images still process on their own clips).
+                    let batch_scissor: Option<vk::Rect2D> = match batch.scissor {
+                        Some((bx, by, bw, bh)) => {
+                            if bx >= self.extent.width || by >= self.extent.height {
+                                None
+                            } else {
                                 let bw = bw.min(self.extent.width - bx);
                                 let bh = bh.min(self.extent.height - by);
                                 if bw == 0 || bh == 0 {
-                                    continue;
-                                }
-                                self.device.cmd_set_scissor(
-                                    cmd,
-                                    0,
-                                    &[vk::Rect2D {
+                                    None
+                                } else {
+                                    Some(vk::Rect2D {
                                         offset: vk::Offset2D { x: bx as i32, y: by as i32 },
                                         extent: vk::Extent2D { width: bw, height: bh },
-                                    }],
-                                );
+                                    })
+                                }
                             }
-                            None => self.device.cmd_set_scissor(cmd, 0, &[full_scissor]),
                         }
-                        self.device
-                            .cmd_draw(cmd, batch.end - batch.start, 1, batch.start, 0);
+                        None => Some(full_scissor),
+                    };
+
+                    let mut cursor = batch.start;
+                    while cursor < batch.end {
+                        let next_z =
+                            order.get(img_i).map(|&k| images[k].z_before).unwrap_or(u32::MAX);
+                        if next_z <= cursor {
+                            let k = order[img_i];
+                            img_i += 1;
+                            let q = &images[k];
+                            let img_scissor = match q.clip {
+                                Some((cx, cy, cw, ch)) => vk::Rect2D {
+                                    offset: vk::Offset2D { x: cx as i32, y: cy as i32 },
+                                    extent: vk::Extent2D {
+                                        width: cw.min(self.extent.width.saturating_sub(cx)),
+                                        height: ch.min(self.extent.height.saturating_sub(cy)),
+                                    },
+                                },
+                                None => full_scissor,
+                            };
+                            self.core.device.cmd_set_scissor(cmd, 0, &[img_scissor]);
+                            self.image.record_quad(&self.core.device, cmd, frame_index, k, q.image);
+                            continue;
+                        }
+                        let upto = next_z.min(batch.end);
+                        if let Some(scissor) = batch_scissor {
+                            self.core.device
+                                .cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline);
+                            self.core.device.cmd_bind_descriptor_sets(
+                                cmd,
+                                vk::PipelineBindPoint::GRAPHICS,
+                                self.pipeline_layout,
+                                0,
+                                &[self.descriptor_set],
+                                &[],
+                            );
+                            self.core.device
+                                .cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
+                            self.core.device.cmd_set_scissor(cmd, 0, &[scissor]);
+                            self.core.device.cmd_draw(cmd, upto - cursor, 1, cursor, 0);
+                        }
+                        cursor = upto;
                     }
-                    // Restore for the text/overlay draws.
-                    self.device.cmd_set_scissor(cmd, 0, &[full_scissor]);
                 }
+                // Images sorting after all geometry.
+                while let Some(&k) = order.get(img_i) {
+                    img_i += 1;
+                    let q = &images[k];
+                    let img_scissor = match q.clip {
+                        Some((cx, cy, cw, ch)) => vk::Rect2D {
+                            offset: vk::Offset2D { x: cx as i32, y: cy as i32 },
+                            extent: vk::Extent2D {
+                                width: cw.min(self.extent.width.saturating_sub(cx)),
+                                height: ch.min(self.extent.height.saturating_sub(cy)),
+                            },
+                        },
+                        None => full_scissor,
+                    };
+                    self.core.device.cmd_set_scissor(cmd, 0, &[img_scissor]);
+                    self.image.record_quad(&self.core.device, cmd, frame_index, k, q.image);
+                }
+                // Restore for the text/overlay draws.
+                self.core.device.cmd_set_scissor(cmd, 0, &[full_scissor]);
             }
-            self.text.record_draw(&self.device, cmd, frame_index);
+            self.text.record_draw(&self.core.device, cmd, frame_index);
             if frame.overlay_count > 0 {
                 // The text pass bound its own pipeline; rebind for the overlay.
-                self.device
+                self.core.device
                     .cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline);
-                self.device.cmd_bind_descriptor_sets(
+                self.core.device.cmd_bind_descriptor_sets(
                     cmd,
                     vk::PipelineBindPoint::GRAPHICS,
                     self.pipeline_layout,
@@ -1390,13 +1267,13 @@ impl VkRenderer {
                     &[self.descriptor_set],
                     &[],
                 );
-                self.device
+                self.core.device
                     .cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
-                self.device
+                self.core.device
                     .cmd_draw(cmd, frame.overlay_count, 1, frame.overlay_start, 0);
             }
-            self.device.cmd_end_render_pass(cmd);
-            self.device.end_command_buffer(cmd).unwrap();
+            self.core.device.cmd_end_render_pass(cmd);
+            self.core.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).
@@ -1410,8 +1287,8 @@ impl VkRenderer {
                 .wait_dst_stage_mask(&wait_stages)
                 .command_buffers(&cmds)
                 .signal_semaphores(&signal_semaphores);
-            self.device
-                .queue_submit(self.queue, &[submit], in_flight)
+            self.core.device
+                .queue_submit(self.core.queue, &[submit], in_flight)
                 .expect("Queue submit failed");
 
             let swapchains = [self.swapchain];
@@ -1420,7 +1297,7 @@ impl VkRenderer {
                 .wait_semaphores(&signal_semaphores)
                 .swapchains(&swapchains)
                 .image_indices(&image_indices);
-            match self.swapchain_loader.queue_present(self.queue, &present) {
+            match self.swapchain_loader.queue_present(self.core.queue, &present) {
                 Ok(suboptimal) => {
                     if suboptimal {
                         self.swapchain_dirty = true;
@@ -1441,15 +1318,15 @@ impl VkRenderer {
 impl Drop for VkRenderer {
     fn drop(&mut self) {
         unsafe {
-            let _ = self.device.device_wait_idle();
+            let _ = self.core.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);
+                self.core.device.destroy_semaphore(frame.image_available, None);
+                self.core.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);
+                if let Some(allocator) = self.core.allocator.as_mut() {
+                    destroy_cpu_buffer(&self.core.device, allocator, &mut vertex);
                 }
             }
 
@@ -1458,38 +1335,31 @@ impl Drop for VkRenderer {
                 self.swapchain_loader.destroy_swapchain(self.swapchain, None);
             }
 
-            if let Some(allocator) = self.allocator.as_mut() {
-                self.text.destroy(&self.device, allocator);
+            if let Some(allocator) = self.core.allocator.as_mut() {
+                self.text.destroy(&self.core.device, allocator);
             }
 
-            self.device.destroy_sampler(self.backdrop_sampler, None);
-            if let Some(allocator) = self.allocator.as_mut() {
-                self.scene.destroy(&self.device, allocator);
+            self.core.device.destroy_sampler(self.backdrop_sampler, None);
+            if let Some(allocator) = self.core.allocator.as_mut() {
+                self.scene.destroy(&self.core.device, allocator);
+                self.image.destroy(&self.core.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);
+            if let Some(allocator) = self.core.allocator.as_mut() {
+                destroy_cpu_buffer(&self.core.device, allocator, &mut window_info);
             }
 
-            self.device.destroy_descriptor_pool(self.descriptor_pool, None);
-            self.device
+            self.core.device.destroy_descriptor_pool(self.descriptor_pool, None);
+            self.core.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);
+            self.core.device.destroy_pipeline(self.pipeline, None);
+            self.core.device.destroy_pipeline_layout(self.pipeline_layout, None);
+            self.core.device.destroy_shader_module(self.shader_module, None);
+            self.core.device.destroy_render_pass(self.render_pass, None);
+            self.core.device.destroy_render_pass(self.render_pass_load, None);
+            self.core.surface_loader.destroy_surface(self.surface, None);
+            // The rest (allocator, command pool, device, instance) is the
+            // core's Drop, which runs after this body.
         }
     }
 }
diff --git a/src/widget/model.rs b/src/widget/model.rs
index f97c9b9..3b69a78 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1238,6 +1238,7 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
                 Prim::Arc { cx, cy, radius, thickness, start, end, color } => ctx.arc(cx, cy, radius, thickness, start, end, color),
                 Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => ctx.vector(x1, y1, x2, y2, thickness, color, cap),
                 Prim::Circle { cx, cy, radius, color } => ctx.circle(cx, cy, radius, color),
+                Prim::Image { image, rect, alpha } => ctx.image(image, rect, alpha),
             }
         }
         // The legacy default `paint_self` drained `all_quads`, which carries the focus