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

commit0af15424cf0c73f50eac0759e4d7c0ed4195c3dd
parenta6af54de61
authorLucas Galante <[email protected]>
date2026-07-14 12:38
feat: 3D scene stage on ash — depth, offscreen backdrop, real blur source (milestone 3)

SceneStage ports the app's "3D canvas render pass": Vertex3D meshes drawn by
shader_3d.wgsl (compiled via naga) into a full-size backdrop image with a D32
depth buffer, scissored to the viewport pane. The backdrop is copied under the
UI pass (loadOp LOAD variant of the swapchain render pass) and doubles as the
blur-behind source — milestone 1's 1x1 placeholder is gone, and 2D plates now
blur real 3D content. Frames with no staged scene reuse the previous backdrop,
mirroring the app's viewport-changed cache.

Meshes are handle-based; per-draw uniforms (mvp + window-corner info) share one
dynamic-offset uniform buffer per frame in flight. Backdrop and depth follow
swapchain resizes, with the UI descriptor re-pointed and fresh images cleared
to a sampleable layout.

Coordinate-space fix that culling forced: the naga ADJUST_COORDINATE_SPACE
Y-flip is replaced by a negative-height viewport (wgpu-hal's approach) — the
shader flip reverses screen-space winding, which would break the 3D pipeline's
CCW-front back-face culling.

Validation layers caught a real bug during bring-up: framebuffers are only
compatible across render passes with IDENTICAL subpass dependencies (just
load/store ops and layouts may differ), so both UI pass variants now share one
unioned dependency. Verified live: cube/grid/depth correct, blur plate blurs
the 3D scene, zero validation messages.

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

 src/vk/mod.rs      |   3 +
 src/vk/renderer.rs | 497 ++++++++++++++++++++++++------------
 src/vk/scene.rs    | 726 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/vk_smoke.rs    | 134 +++++++---
 4 files changed, 1161 insertions(+), 199 deletions(-)

diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 7b88996..7527939 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -10,7 +10,10 @@
 //! `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
index 799facd..054f7a8 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -15,6 +15,7 @@ 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;
@@ -112,6 +113,7 @@ pub struct VkRenderer {
     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
@@ -119,6 +121,9 @@ pub struct VkRenderer {
     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,
@@ -126,9 +131,6 @@ pub struct VkRenderer {
 
     descriptor_pool: vk::DescriptorPool,
     descriptor_set: vk::DescriptorSet,
-    backdrop_image: vk::Image,
-    backdrop_view: vk::ImageView,
-    backdrop_allocation: Option<Allocation>,
     backdrop_sampler: vk::Sampler,
     window_info: AllocatedBuffer,
 
@@ -136,15 +138,17 @@ pub struct VkRenderer {
     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 with the same coordinate-space adjustment wgpu applies
-/// (wgpu NDC is Y-up; ADJUST_COORDINATE_SPACE emits the Vulkan Y-flip), so the
-/// existing NDC math in the app carries over unchanged.
+/// 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(
@@ -155,13 +159,108 @@ pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
     .expect("WGSL validation failed");
     let options = naga::back::spv::Options {
         lang_version: (1, 0),
-        flags: naga::back::spv::WriterFlags::ADJUST_COORDINATE_SPACE
-            | naga::back::spv::WriterFlags::LABEL_VARYINGS,
+        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,
@@ -381,13 +480,22 @@ impl VkRenderer {
         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)
-            .src_access_mask(vk::AccessFlags::empty())
+            .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_WRITE)];
+            .dst_access_mask(
+                vk::AccessFlags::COLOR_ATTACHMENT_READ | vk::AccessFlags::COLOR_ATTACHMENT_WRITE,
+            )];
         let render_pass = device
             .create_render_pass(
                 &vk::RenderPassCreateInfo::default()
@@ -398,6 +506,27 @@ impl VkRenderer {
             )
             .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 = [
@@ -531,127 +660,22 @@ impl VkRenderer {
             )
             .expect("Failed to create command pool");
 
-        // Backdrop placeholder: 1x1 black, cleared + transitioned once at startup.
-        let backdrop_image = device
-            .create_image(
-                &vk::ImageCreateInfo::default()
-                    .image_type(vk::ImageType::TYPE_2D)
-                    .format(vk::Format::R8G8B8A8_UNORM)
-                    .extent(vk::Extent3D { width: 1, height: 1, 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 backdrop image");
-        let backdrop_requirements = device.get_image_memory_requirements(backdrop_image);
-        let backdrop_allocation = allocator
-            .allocate(&AllocationCreateDesc {
-                name: "backdrop",
-                requirements: backdrop_requirements,
-                location: MemoryLocation::GpuOnly,
-                linear: false,
-                allocation_scheme: AllocationScheme::GpuAllocatorManaged,
-            })
-            .expect("Failed to allocate backdrop memory");
-        device
-            .bind_image_memory(
-                backdrop_image,
-                backdrop_allocation.memory(),
-                backdrop_allocation.offset(),
-            )
-            .expect("Failed to bind backdrop memory");
-
-        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);
-
-        // One-time init: clear the backdrop and move it to SHADER_READ_ONLY.
-        {
-            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();
-            let to_transfer = 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(backdrop_image)
-                .subresource_range(subresource_range);
-            device.cmd_pipeline_barrier(
-                cmd,
-                vk::PipelineStageFlags::TOP_OF_PIPE,
-                vk::PipelineStageFlags::TRANSFER,
-                vk::DependencyFlags::empty(),
-                &[],
-                &[],
-                &[to_transfer],
-            );
-            device.cmd_clear_color_image(
-                cmd,
-                backdrop_image,
-                vk::ImageLayout::TRANSFER_DST_OPTIMAL,
-                &vk::ClearColorValue { float32: [0.0, 0.0, 0.0, 0.0] },
-                &[subresource_range],
-            );
-            let to_sampled = 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(backdrop_image)
-                .subresource_range(subresource_range);
-            device.cmd_pipeline_barrier(
-                cmd,
-                vk::PipelineStageFlags::TRANSFER,
-                vk::PipelineStageFlags::FRAGMENT_SHADER,
-                vk::DependencyFlags::empty(),
-                &[],
-                &[],
-                &[to_sampled],
-            );
-            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);
-        }
-
-        let backdrop_view = device
-            .create_image_view(
-                &vk::ImageViewCreateInfo::default()
-                    .image(backdrop_image)
-                    .view_type(vk::ImageViewType::TYPE_2D)
-                    .format(vk::Format::R8G8B8A8_UNORM)
-                    .subresource_range(subresource_range),
-                None,
-            )
-            .expect("Failed to create backdrop view");
+        // 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
@@ -703,7 +727,7 @@ impl VkRenderer {
             .expect("Failed to allocate descriptor set")[0];
 
         let image_infos = [vk::DescriptorImageInfo::default()
-            .image_view(backdrop_view)
+            .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()
@@ -779,33 +803,36 @@ impl VkRenderer {
             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_image,
-            backdrop_view,
-            backdrop_allocation: Some(backdrop_allocation),
             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
     }
 
@@ -830,6 +857,7 @@ impl VkRenderer {
             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);
             }
@@ -888,7 +916,10 @@ impl VkRenderer {
                         .image_color_space(self.surface_format.color_space)
                         .image_extent(extent)
                         .image_array_layers(1)
-                        .image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT)
+                        .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)
@@ -907,6 +938,7 @@ impl VkRenderer {
                 .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)
@@ -956,6 +988,64 @@ impl VkRenderer {
         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
@@ -1065,41 +1155,127 @@ impl VkRenderer {
                 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 frame = &self.frames[frame_index];
-            let cmd = frame.cmd;
+            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(self.render_pass)
+                    .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(&clear_values),
+                    .clear_values(ui_clear_values),
                 vk::SubpassContents::INLINE,
             );
-            self.device.cmd_set_viewport(
-                cmd,
-                0,
-                &[vk::Viewport {
-                    x: 0.0,
-                    y: 0.0,
-                    width: self.extent.width as f32,
-                    height: self.extent.height as f32,
-                    min_depth: 0.0,
-                    max_depth: 1.0,
-                }],
-            );
+            self.device
+                .cmd_set_viewport(cmd, 0, &[flipped_viewport(self.extent)]);
             self.device.cmd_set_scissor(
                 cmd,
                 0,
@@ -1127,9 +1303,11 @@ impl VkRenderer {
             self.device.cmd_end_render_pass(cmd);
             self.device.end_command_buffer(cmd).unwrap();
 
-            // Submit + present.
+            // 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];
+            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()
@@ -1190,12 +1368,8 @@ impl Drop for VkRenderer {
             }
 
             self.device.destroy_sampler(self.backdrop_sampler, None);
-            self.device.destroy_image_view(self.backdrop_view, None);
-            self.device.destroy_image(self.backdrop_image, None);
-            if let (Some(allocator), Some(allocation)) =
-                (self.allocator.as_mut(), self.backdrop_allocation.take())
-            {
-                let _ = allocator.free(allocation);
+            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() {
@@ -1209,6 +1383,7 @@ impl Drop for VkRenderer {
             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.
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
new file mode 100644
index 0000000..ca79cdd
--- /dev/null
+++ b/src/vk/scene.rs
@@ -0,0 +1,726 @@
+//! 3D scene stage: the ash port of the app's "3D canvas render pass". Draws
+//! Vertex3D meshes (shader_3d.wgsl: mvp transform, z=9.99 background-quad
+//! special case, window-corner discard) into the full-size backdrop image with
+//! a depth buffer, scissored to the viewport pane. The renderer then copies the
+//! backdrop into the swapchain image and draws the UI pass over it — the same
+//! image doubles as the blur-behind source for the 2D shader, replacing
+//! milestone 1's 1x1 placeholder.
+//!
+//! Meshes are handle-based (`MeshId`); per-draw uniforms (mvp + window info) go
+//! into one dynamic-offset uniform buffer per frame in flight, so a frame's
+//! draws share a single descriptor set.
+
+use ash::vk;
+use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
+use gpu_allocator::MemoryLocation;
+
+use super::renderer::{compile_wgsl, create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
+
+/// Layout-identical to the app's `geometry::Vertex3D` (bytemuck-castable at cutover).
+#[repr(C)]
+#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct Vertex3D {
+    pub position: [f32; 3],
+    pub color: [f32; 3],
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MeshId(usize);
+
+/// One draw in the staged scene: a mesh under an mvp. The window-size/radius
+/// tail of shader_3d's uniform block is filled in by the renderer.
+pub struct SceneDraw {
+    pub mesh: MeshId,
+    pub mvp: [[f32; 4]; 4],
+}
+
+/// shader_3d.wgsl's uniform block.
+#[repr(C)]
+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+struct SceneUniforms {
+    mvp: [[f32; 4]; 4],
+    window_size: [f32; 2],
+    window_radius: f32,
+    _padding: f32,
+}
+
+const UNIFORM_SIZE: vk::DeviceSize = std::mem::size_of::<SceneUniforms>() as vk::DeviceSize;
+
+struct Mesh {
+    buffer: AllocatedBuffer,
+    count: u32,
+}
+
+struct StagedScene {
+    scissor: (u32, u32, u32, u32),
+    draws: Vec<SceneDraw>,
+}
+
+struct SceneFrame {
+    uniforms: AllocatedBuffer,
+    descriptor_set: vk::DescriptorSet,
+    draw_count: u32,
+}
+
+pub(crate) struct SceneStage {
+    render_pass: vk::RenderPass,
+    pipeline: vk::Pipeline,
+    pipeline_layout: vk::PipelineLayout,
+    descriptor_set_layout: vk::DescriptorSetLayout,
+    descriptor_pool: vk::DescriptorPool,
+    shader_module: vk::ShaderModule,
+    uniform_stride: vk::DeviceSize,
+
+    format: vk::Format,
+    extent: vk::Extent2D,
+    pub(crate) backdrop_image: vk::Image,
+    pub(crate) backdrop_view: vk::ImageView,
+    backdrop_allocation: Option<Allocation>,
+    depth_image: vk::Image,
+    depth_view: vk::ImageView,
+    depth_allocation: Option<Allocation>,
+    framebuffer: vk::Framebuffer,
+
+    meshes: Vec<Mesh>,
+    frames: Vec<SceneFrame>,
+    staged: Option<StagedScene>,
+    /// True once the backdrop holds rendered content worth copying to screen.
+    pub(crate) backdrop_valid: bool,
+}
+
+impl SceneStage {
+    pub(crate) fn new(
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        format: vk::Format,
+        extent: vk::Extent2D,
+        frames_in_flight: usize,
+        min_uniform_align: vk::DeviceSize,
+    ) -> Self {
+        unsafe {
+            // Offscreen pass: color -> TRANSFER_SRC (copied to the swapchain
+            // right after), depth is transient.
+            let attachments = [
+                vk::AttachmentDescription::default()
+                    .format(format)
+                    .samples(vk::SampleCountFlags::TYPE_1)
+                    .load_op(vk::AttachmentLoadOp::CLEAR)
+                    .store_op(vk::AttachmentStoreOp::STORE)
+                    .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
+                    .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
+                    .initial_layout(vk::ImageLayout::UNDEFINED)
+                    .final_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL),
+                vk::AttachmentDescription::default()
+                    .format(vk::Format::D32_SFLOAT)
+                    .samples(vk::SampleCountFlags::TYPE_1)
+                    .load_op(vk::AttachmentLoadOp::CLEAR)
+                    .store_op(vk::AttachmentStoreOp::DONT_CARE)
+                    .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
+                    .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
+                    .initial_layout(vk::ImageLayout::UNDEFINED)
+                    .final_layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL),
+            ];
+            let color_refs = [vk::AttachmentReference::default()
+                .attachment(0)
+                .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
+            let depth_ref = vk::AttachmentReference::default()
+                .attachment(1)
+                .layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
+            let subpasses = [vk::SubpassDescription::default()
+                .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
+                .color_attachments(&color_refs)
+                .depth_stencil_attachment(&depth_ref)];
+            let dependencies = [
+                // Prior frame sampled the backdrop (blur plates) and used the depth
+                // image; execution dependency before we overwrite from UNDEFINED.
+                vk::SubpassDependency::default()
+                    .src_subpass(vk::SUBPASS_EXTERNAL)
+                    .dst_subpass(0)
+                    .src_stage_mask(
+                        vk::PipelineStageFlags::FRAGMENT_SHADER
+                            | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
+                    )
+                    .src_access_mask(vk::AccessFlags::empty())
+                    .dst_stage_mask(
+                        vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
+                            | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS,
+                    )
+                    .dst_access_mask(
+                        vk::AccessFlags::COLOR_ATTACHMENT_WRITE
+                            | vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE,
+                    ),
+                // The copy to the swapchain reads the color attachment right after.
+                vk::SubpassDependency::default()
+                    .src_subpass(0)
+                    .dst_subpass(vk::SUBPASS_EXTERNAL)
+                    .src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
+                    .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
+                    .dst_stage_mask(vk::PipelineStageFlags::TRANSFER)
+                    .dst_access_mask(vk::AccessFlags::TRANSFER_READ),
+            ];
+            let render_pass = device
+                .create_render_pass(
+                    &vk::RenderPassCreateInfo::default()
+                        .attachments(&attachments)
+                        .subpasses(&subpasses)
+                        .dependencies(&dependencies),
+                    None,
+                )
+                .expect("Failed to create scene render pass");
+
+            let bindings = [vk::DescriptorSetLayoutBinding::default()
+                .binding(0)
+                .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
+                .descriptor_count(1)
+                .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT)];
+            let descriptor_set_layout = device
+                .create_descriptor_set_layout(
+                    &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
+                    None,
+                )
+                .expect("Failed to create scene descriptor set layout");
+            let set_layouts_one = [descriptor_set_layout];
+            let pipeline_layout = device
+                .create_pipeline_layout(
+                    &vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts_one),
+                    None,
+                )
+                .expect("Failed to create scene pipeline layout");
+
+            let spirv = compile_wgsl(include_str!("../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_smoke.rs b/src/vk_smoke.rs
index 70d5b99..ad5b992 100644
--- a/src/vk_smoke.rs
+++ b/src/vk_smoke.rs
@@ -32,9 +32,10 @@ use wayland_client::{
 use calloop_wayland_source::WaylandSource;
 
 use cce_ui::engine::{quad_vertices, Vertex};
+use glam::{Mat4, Vec3};
 use glyphon::cosmic_text::{Attrs, Buffer as TextBuffer, Family, Metrics, Shaping};
 use glyphon::{FontSystem, SwashCache};
-use vk::{TextSpan, VkRenderer};
+use vk::{SceneDraw, TextSpan, Vertex3D, VkRenderer};
 
 struct SmokeApp {
     registry_state: RegistryState,
@@ -167,52 +168,78 @@ fn make_buffer(font_system: &mut FontSystem, text: &str, size: f32) -> TextBuffe
     buffer
 }
 
-/// Designer-style test scene in logical coordinates.
+fn quad3(v: &mut Vec<Vertex3D>, a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3], col: [f32; 3]) {
+    for p in [a, b, c, a, c, d] {
+        v.push(Vertex3D { position: p, color: col });
+    }
+}
+
+/// Colored cube, CCW-from-outside winding (the 3D pipeline culls back faces).
+fn cube_verts() -> Vec<Vertex3D> {
+    let s = 0.5;
+    let mut v = Vec::new();
+    quad3(&mut v, [-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s], [0.85, 0.35, 0.30]);
+    quad3(&mut v, [s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s], [0.30, 0.55, 0.90]);
+    quad3(&mut v, [s, -s, s], [s, -s, -s], [s, s, -s], [s, s, s], [0.90, 0.75, 0.25]);
+    quad3(&mut v, [-s, -s, -s], [-s, -s, s], [-s, s, s], [-s, s, -s], [0.35, 0.80, 0.45]);
+    quad3(&mut v, [-s, s, s], [s, s, s], [s, s, -s], [-s, s, -s], [0.80, 0.80, 0.85]);
+    quad3(&mut v, [-s, -s, -s], [s, -s, -s], [s, -s, s], [-s, -s, s], [0.45, 0.40, 0.60]);
+    v
+}
+
+/// Floor grid on the XZ plane, both windings (visible from above and below).
+fn grid_verts() -> Vec<Vertex3D> {
+    let col = [0.22, 0.22, 0.28];
+    let (half, t) = (2.0, 0.01);
+    let mut v = Vec::new();
+    for i in -4i32..=4 {
+        let p = i as f32 * 0.5;
+        quad3(&mut v, [-half, 0.0, p - t], [half, 0.0, p - t], [half, 0.0, p + t], [-half, 0.0, p + t], col);
+        quad3(&mut v, [-half, 0.0, p + t], [half, 0.0, p + t], [half, 0.0, p - t], [-half, 0.0, p - t], col);
+        quad3(&mut v, [p - t, 0.0, -half], [p + t, 0.0, -half], [p + t, 0.0, half], [p - t, 0.0, half], col);
+        quad3(&mut v, [p + t, 0.0, -half], [p - t, 0.0, -half], [p - t, 0.0, half], [p + t, 0.0, half], col);
+    }
+    v
+}
+
+/// The designer's viewport-background quad: z=9.99 triggers the shader's
+/// far-plane special case, filling the scissor with the (linear) bg color.
+fn viewport_bg_verts() -> Vec<Vertex3D> {
+    let color = cce_ui::colors::to_linear_rgb([0.10, 0.10, 0.13]);
+    let mut v = Vec::new();
+    quad3(&mut v, [-1.0, -1.0, 9.99], [1.0, -1.0, 9.99], [1.0, 1.0, 9.99], [-1.0, 1.0, 9.99], color);
+    v
+}
+
+/// Designer-style 2D chrome in logical coordinates. No full-window background —
+/// the 3D viewport (copied in as the backdrop) shows through everywhere the UI
+/// doesn't paint, exactly like the app.
 fn build_scene(lw: f32, lh: f32, scale: f32, t: f32) -> Vec<Vertex> {
     let mut verts: Vec<Vertex> = Vec::new();
 
-    // Window background (full-surface quad; the shader rounds the window corners).
-    verts.extend(quad_vertices(0.0, 0.0, lw, lh, lw, lh, [0.09, 0.09, 0.11, 1.0]));
-
-    // A header bar and a side panel, like the app's chrome.
+    // Header bar + side panel chrome.
     verts.extend(quad_vertices(0.0, 0.0, lw, 36.0, lw, lh, [0.13, 0.13, 0.17, 1.0]));
     verts.extend(quad_vertices(0.0, 36.0, 56.0, lh - 36.0, lw, lh, [0.11, 0.11, 0.145, 1.0]));
 
-    // A row of alpha-blended quads.
-    let palette = [
-        [0.90, 0.35, 0.30, 0.9],
-        [0.95, 0.75, 0.25, 0.9],
-        [0.35, 0.80, 0.45, 0.9],
-        [0.30, 0.55, 0.95, 0.9],
-    ];
+    // Floating alpha-blended quads over the 3D scene.
+    let palette = [[0.90, 0.35, 0.30, 0.9], [0.95, 0.75, 0.25, 0.9], [0.35, 0.80, 0.45, 0.9]];
     for (i, color) in palette.iter().enumerate() {
-        let x = 90.0 + i as f32 * 90.0;
-        verts.extend(quad_vertices(x, 70.0, 70.0, 70.0, lw, lh, *color));
+        verts.extend(quad_vertices(70.0 + i as f32 * 50.0, 46.0, 40.0, 40.0, lw, lh, *color));
     }
 
-    // Animated quad: hue-cycled color proves per-frame uploads and presentation.
+    // Animated quad: hue-cycled color proves per-frame uploads.
     let pulse = |phase: f32| 0.5 + 0.5 * (t * 2.0 + phase).sin();
-    verts.extend(quad_vertices(
-        90.0,
-        170.0,
-        160.0,
-        90.0,
-        lw,
-        lh,
-        [pulse(0.0), pulse(2.1), pulse(4.2), 1.0],
-    ));
-
-    // Circle-clipped quad: exercises the clip_circle fragment path. The clip
-    // center/radius are in physical pixels (the shader tests clip_position).
-    let (ccx, ccy, ccr) = (420.0f32, 215.0f32, 45.0f32);
+    verts.extend(quad_vertices(70.0, 96.0, 80.0, 30.0, lw, lh, [pulse(0.0), pulse(2.1), pulse(4.2), 1.0]));
+
+    // Circle-clipped quad (clip center/radius in physical pixels).
+    let (ccx, ccy, ccr) = (320.0f32, 90.0f32, 26.0f32);
     let clip = [ccx * scale, ccy * scale, ccr * scale];
-    for v in quad_vertices(ccx - 60.0, ccy - 60.0, 120.0, 120.0, lw, lh, [0.85, 0.45, 0.85, 1.0]) {
+    for v in quad_vertices(ccx - 30.0, ccy - 30.0, 60.0, 60.0, lw, lh, [0.85, 0.45, 0.85, 1.0]) {
         verts.push(Vertex { clip_circle: clip, ..v });
     }
 
-    // Blur-behind plate (negative alpha): mixes with the backdrop texture — a 1x1
-    // placeholder for now, so it reads as a darkened plate.
-    verts.extend(quad_vertices(90.0, 290.0, 375.0, 80.0, lw, lh, [0.45, 0.55, 0.95, -0.55]));
+    // Blur-behind plate (negative alpha): blurs the real 3D backdrop beneath it.
+    verts.extend(quad_vertices(110.0, 140.0, 200.0, 70.0, lw, lh, [0.45, 0.55, 0.95, -0.55]));
 
     verts
 }
@@ -276,6 +303,16 @@ fn main() {
         Some(unsafe { VkRenderer::new(display_ptr, surface_ptr, pw, ph, radius) });
     log::info!("vk-smoke: renderer up at {pw}x{ph} (scale {})", app.scale);
 
+    // 3D meshes for the viewport scene.
+    let (bg_mesh, grid_mesh, cube_mesh) = {
+        let r = app.renderer.as_mut().unwrap();
+        (
+            r.create_mesh(&viewport_bg_verts()),
+            r.create_mesh(&grid_verts()),
+            r.create_mesh(&cube_verts()),
+        )
+    };
+
     // Text stack: same bundled fonts as the app, shaped once up front.
     let mut font_system = cce_ui::create_font_system();
     let mut swash_cache = SwashCache::new();
@@ -315,8 +352,8 @@ fn main() {
             },
             TextSpan {
                 buffer: &body_buf,
-                left: 66.0 * s,
-                top: 44.0 * s,
+                left: 120.0 * s,
+                top: 158.0 * s,
                 scale: s,
                 bounds: None,
                 // Animated color: proves per-frame vertex rebuilds.
@@ -325,18 +362,39 @@ fn main() {
             TextSpan {
                 buffer: &clipped_buf,
                 left: 66.0 * s,
-                top: 148.0 * s,
+                top: 215.0 * s,
                 scale: s,
                 bounds: Some([
                     (66.0 * s) as i32,
-                    (148.0 * s) as i32,
+                    (215.0 * s) as i32,
                     (260.0 * s) as i32,
-                    (166.0 * s) as i32,
+                    (233.0 * s) as i32,
                 ]),
                 default_color: [0.70, 0.85, 1.00, 1.0],
             },
         ];
         if let Some(renderer) = &mut app.renderer {
+            // 3D pane: right of the side panel, below the header (physical px).
+            // Full-frame NDC scissored to the pane, exactly like the app.
+            let pane = (
+                (56.0 * s) as u32,
+                (36.0 * s) as u32,
+                ((lw - 56.0) * s) as u32,
+                ((lh - 36.0) * s) as u32,
+            );
+            let aspect = (lw - 56.0) / (lh - 36.0);
+            let proj = Mat4::perspective_rh(0.9, aspect, 0.1, 100.0);
+            let view = Mat4::look_at_rh(Vec3::new(2.5, 1.8, 2.5), Vec3::ZERO, Vec3::Y);
+            let model = Mat4::from_rotation_y(t * 0.8);
+            let mvp = (proj * view * model).to_cols_array_2d();
+            renderer.stage_scene(
+                pane,
+                vec![
+                    SceneDraw { mesh: bg_mesh, mvp: Mat4::IDENTITY.to_cols_array_2d() },
+                    SceneDraw { mesh: grid_mesh, mvp },
+                    SceneDraw { mesh: cube_mesh, mvp },
+                ],
+            );
             renderer.prepare_text(&mut font_system, &mut swash_cache, &spans);
             // FIFO present paces this loop to the display's refresh rate.
             renderer.draw_frame(&verts);