graphic design tool
git clone https://git.lucas.co/cce-designer.git
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);