GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
test: render the 2D path offscreen and hold a grouped carve to its overlay
vk::plate_probe (cfg(test)) tessellates a DisplayList with the runner's
own tessellate_display_list / dl_batches_2d and draws it through the live
UI pipeline into an image it reads back. What it needs of the renderer is
shared rather than copied: create_ui_pipeline, batch_push_constants,
window_info_data and relief_px_at are pulled out of VkRenderer, which
calls them unchanged (the probe example renders pixel-identically before
and after). It returns None without a Vulkan device, and the test skips.
a_grouped_carve_is_drawn_as_its_overlay_is renders a plate whose carves
group beside one whose carves are forced to overlay, checks the grouping
happened, and asserts the two are the same pixels. Against the pre-fix
shader (6634ba3) it fails at 32504 px; clean under the Khronos
validation layer. Adds ~2 s to the suite for opening the device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 18 +++
src/vk/mod.rs | 2 +
src/vk/plate_probe.rs | 408 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/vk/renderer.rs | 407 ++++++++++++++++++++++++++-----------------------
4 files changed, 650 insertions(+), 185 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 560cdf1..d636499 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -435,6 +435,24 @@ same recesses forced to overlay by a transparent quad): grouped and overlay colu
5,462 px apart before, 0 after; in the designer with grouped text wells, 0 px from the
`well_field` rendering.
+**And it is tested by rendering, not by reading** (`vk::plate_probe`, `cfg(test)`): an
+offscreen 2D render — a `DisplayList` through the runner's own `tessellate_display_list`
+and `dl_batches_2d`, drawn with the LIVE pipeline into an image and read back. The
+renderer's pieces it needs are shared functions, not copies, so the two cannot draw
+differently: `create_ui_pipeline` (descriptor layout, push range, vertex layout, blend),
+`batch_push_constants` (a batch's 32-float block, feature rebase included),
+`window_info_data` and `relief_px_at`. It draws plates, carves and flat geometry; it
+refuses blur-behind (the snapshot is the swapchain path's) and draws no text or images.
+`render` returns `None` with no Vulkan device and the test skips with a note; it is not
+`#[ignore]`d, since a device is the normal case here and a regression nobody runs is
+not caught. `a_grouped_carve_is_drawn_as_its_overlay_is` renders two plates, one grouped
+and one forced to overlay, asserts the grouping really happened (five features), and
+holds them equal to the pixel: against the pre-fix shader it fails at 32,504 px, worst
+channel 54. Clean under `VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation`. Opening the
+device adds about 2 s to the suite. A test of any other 2D look can use the same harness.
+(Across devices the live path agrees to within 5/255 on about 125 edge pixels of the
+probe — float rounding at antialiased edges, not a shading difference.)
+
**It was never the GPU.** The report was "doubled on the NVIDIA card, single on the
Iris Xe"; the 2D path is identical to the pixel on both, before the fix and after. Two
things made it look GPU-specific. Grouping is decided per frame by what is painted
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 3766eba..1d23520 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -43,6 +43,8 @@ mod core;
pub mod image;
mod renderer;
mod rt;
+#[cfg(test)]
+mod plate_probe;
mod scene;
mod text;
diff --git a/src/vk/plate_probe.rs b/src/vk/plate_probe.rs
new file mode 100644
index 0000000..41d62df
--- /dev/null
+++ b/src/vk/plate_probe.rs
@@ -0,0 +1,408 @@
+//! An offscreen 2D render for tests: a `DisplayList` tessellated by the
+//! runner's own `tessellate_display_list`, drawn through the live UI pipeline
+//! (`renderer::create_ui_pipeline`, the same push constants and `WindowInfo`)
+//! into an image, and read back. What it does not do is what a plate test does
+//! not need: no text, no images, no 3D backdrop (a 1x1 clear one is bound),
+//! and no blur-behind — a frosted plate is refused, since it samples a
+//! snapshot of the frame-so-far that only the swapchain path takes.
+//!
+//! It exists so the 2D shader can be TESTED rather than read: the first test
+//! holds a carve grouped into its plate to the same carve drawn as an overlay,
+//! pixel for pixel (see cce-ui/CLAUDE.md, "A grouped carve shades as its
+//! overlay does"). `render` returns `None` where there is no Vulkan device,
+//! and the tests skip with a note, as the GPU cross-checks elsewhere do.
+
+use ash::vk;
+use gpu_allocator::vulkan::{AllocationCreateDesc, AllocationScheme};
+use gpu_allocator::MemoryLocation;
+
+use super::core::VkCore;
+use super::renderer::{
+ batch_push_constants, clear_image_to_shader_read, create_cpu_buffer, create_ui_pipeline,
+ destroy_cpu_buffer, flipped_viewport, relief_px_at, window_info_data, FRAMES_IN_FLIGHT,
+ MAX_PLATE_FEATURES, PLATE_FEATURE_BYTES, WINDOW_INFO_BYTES,
+};
+use crate::scene::paint::DisplayList;
+
+/// The format the renderer prefers for its swapchain, so blending happens in
+/// the same (linear) space.
+const FORMAT: vk::Format = vk::Format::R8G8B8A8_SRGB;
+
+/// A rendered frame: `width * height` RGBA8 pixels (sRGB-encoded), rows top
+/// to bottom.
+pub(crate) struct Rendered {
+ pub width: u32,
+ pub height: u32,
+ pub pixels: Vec<u8>,
+}
+
+impl Rendered {
+ pub fn rgba(&self, x: u32, y: u32) -> [u8; 4] {
+ let i = ((y * self.width + x) * 4) as usize;
+ [self.pixels[i], self.pixels[i + 1], self.pixels[i + 2], self.pixels[i + 3]]
+ }
+}
+
+/// Render `dl` at `logical_w` x `logical_h` logical px and `scale`, over an
+/// opaque black clear. `None` when no Vulkan device can be opened.
+pub(crate) fn render(dl: &DisplayList, logical_w: f32, logical_h: f32, scale: f32) -> Option<Rendered> {
+ let mut core = std::panic::catch_unwind(VkCore::new_headless).ok()?;
+ let (verts, dl_batches, _images, features) =
+ crate::backend::window_runner::tessellate_display_list(dl, logical_w, logical_h, scale);
+ let batches = crate::backend::window_runner::dl_batches_2d(&dl_batches, scale);
+ assert!(
+ batches.iter().all(|b| !b.blur_behind),
+ "plate_probe draws no blur-behind batch: give the plates an unfrosted material"
+ );
+ assert!(features.len() <= MAX_PLATE_FEATURES, "more carves than one frame's feature slot");
+ let width = (logical_w * scale).round() as u32;
+ let height = (logical_h * scale).round() as u32;
+ let extent = vk::Extent2D { width, height };
+ let clip_shape = crate::layout::corner_shape();
+
+ unsafe {
+ let device = core.device.clone();
+ let queue = core.queue;
+ let pool = core.command_pool;
+ let allocator = core.allocator.as_mut().expect("allocator");
+
+ // Target: the colour attachment, then the copy source.
+ let mut image_of = |w: u32, h: u32, usage: vk::ImageUsageFlags, name: &str| {
+ let image = device
+ .create_image(
+ &vk::ImageCreateInfo::default()
+ .image_type(vk::ImageType::TYPE_2D)
+ .format(FORMAT)
+ .extent(vk::Extent3D { width: w, height: h, depth: 1 })
+ .mip_levels(1)
+ .array_layers(1)
+ .samples(vk::SampleCountFlags::TYPE_1)
+ .tiling(vk::ImageTiling::OPTIMAL)
+ .usage(usage)
+ .initial_layout(vk::ImageLayout::UNDEFINED),
+ None,
+ )
+ .expect("plate_probe image");
+ let allocation = allocator
+ .allocate(&AllocationCreateDesc {
+ name,
+ requirements: device.get_image_memory_requirements(image),
+ location: MemoryLocation::GpuOnly,
+ linear: false,
+ allocation_scheme: AllocationScheme::GpuAllocatorManaged,
+ })
+ .expect("plate_probe image memory");
+ device
+ .bind_image_memory(image, allocation.memory(), allocation.offset())
+ .expect("plate_probe bind image");
+ let view = device
+ .create_image_view(
+ &vk::ImageViewCreateInfo::default()
+ .image(image)
+ .view_type(vk::ImageViewType::TYPE_2D)
+ .format(FORMAT)
+ .subresource_range(color_range()),
+ None,
+ )
+ .expect("plate_probe view");
+ (image, allocation, view)
+ };
+ let (target, target_mem, target_view) = image_of(
+ width,
+ height,
+ vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_SRC,
+ "plate-probe-target",
+ );
+ // The backdrop binding: nothing samples it without blur-behind.
+ let (backdrop, backdrop_mem, backdrop_view) = image_of(
+ 1,
+ 1,
+ vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
+ "plate-probe-backdrop",
+ );
+ clear_image_to_shader_read(&device, queue, pool, backdrop);
+
+ 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)];
+ let color_refs = [vk::AttachmentReference::default()
+ .attachment(0)
+ .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
+ let subpasses = [vk::SubpassDescription::default()
+ .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
+ .color_attachments(&color_refs)];
+ let render_pass = device
+ .create_render_pass(
+ &vk::RenderPassCreateInfo::default().attachments(&attachments).subpasses(&subpasses),
+ None,
+ )
+ .expect("plate_probe render pass");
+ let (set_layout, pipeline_layout, shader_module, pipeline) = create_ui_pipeline(&device, render_pass);
+ let framebuffer_views = [target_view];
+ let framebuffer = device
+ .create_framebuffer(
+ &vk::FramebufferCreateInfo::default()
+ .render_pass(render_pass)
+ .attachments(&framebuffer_views)
+ .width(width)
+ .height(height)
+ .layers(1),
+ None,
+ )
+ .expect("plate_probe framebuffer");
+
+ // Buffers: vertices, WindowInfo, the feature UBO (slot 0 of the
+ // renderer's two), the readback.
+ // All the renderer's frame slots, as the shader declares them; the
+ // features go in slot 0, so their offsets need no rebase.
+ let feature_bytes = (FRAMES_IN_FLIGHT * MAX_PLATE_FEATURES * PLATE_FEATURE_BYTES) as vk::DeviceSize;
+ let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
+ let mut vbuf = create_cpu_buffer(
+ &device,
+ allocator,
+ (vert_bytes.len() as vk::DeviceSize).max(64),
+ vk::BufferUsageFlags::VERTEX_BUFFER,
+ "plate-probe-verts",
+ );
+ let mut info = create_cpu_buffer(&device, allocator, WINDOW_INFO_BYTES, vk::BufferUsageFlags::UNIFORM_BUFFER, "plate-probe-info");
+ let mut feat = create_cpu_buffer(&device, allocator, feature_bytes, vk::BufferUsageFlags::UNIFORM_BUFFER, "plate-probe-features");
+ let readback_bytes = (width as vk::DeviceSize) * (height as vk::DeviceSize) * 4;
+ let mut readback = create_cpu_buffer(&device, allocator, readback_bytes, vk::BufferUsageFlags::TRANSFER_DST, "plate-probe-readback");
+ vbuf.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..vert_bytes.len()].copy_from_slice(vert_bytes);
+ let info_data = window_info_data(extent, 0.0, relief_px_at(scale));
+ info.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..WINDOW_INFO_BYTES as usize]
+ .copy_from_slice(bytemuck::cast_slice(&info_data));
+ let feat_bytes: &[u8] = bytemuck::cast_slice(&features);
+ feat.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..feat_bytes.len()].copy_from_slice(feat_bytes);
+
+ let sampler = device
+ .create_sampler(&vk::SamplerCreateInfo::default(), None)
+ .expect("plate_probe sampler");
+ let pool_sizes = [
+ vk::DescriptorPoolSize::default().ty(vk::DescriptorType::SAMPLED_IMAGE).descriptor_count(1),
+ vk::DescriptorPoolSize::default().ty(vk::DescriptorType::SAMPLER).descriptor_count(1),
+ vk::DescriptorPoolSize::default().ty(vk::DescriptorType::UNIFORM_BUFFER).descriptor_count(2),
+ ];
+ let descriptor_pool = device
+ .create_descriptor_pool(&vk::DescriptorPoolCreateInfo::default().max_sets(1).pool_sizes(&pool_sizes), None)
+ .expect("plate_probe descriptor pool");
+ let layouts = [set_layout];
+ let set = device
+ .allocate_descriptor_sets(
+ &vk::DescriptorSetAllocateInfo::default().descriptor_pool(descriptor_pool).set_layouts(&layouts),
+ )
+ .expect("plate_probe descriptor set")[0];
+ let image_infos = [vk::DescriptorImageInfo::default()
+ .image_view(backdrop_view)
+ .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
+ let sampler_infos = [vk::DescriptorImageInfo::default().sampler(sampler)];
+ let info_infos = [vk::DescriptorBufferInfo::default().buffer(info.buffer).offset(0).range(WINDOW_INFO_BYTES)];
+ let feat_infos = [vk::DescriptorBufferInfo::default().buffer(feat.buffer).offset(0).range(feature_bytes)];
+ device.update_descriptor_sets(
+ &[
+ vk::WriteDescriptorSet::default()
+ .dst_set(set)
+ .dst_binding(0)
+ .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+ .image_info(&image_infos),
+ vk::WriteDescriptorSet::default()
+ .dst_set(set)
+ .dst_binding(1)
+ .descriptor_type(vk::DescriptorType::SAMPLER)
+ .image_info(&sampler_infos),
+ vk::WriteDescriptorSet::default()
+ .dst_set(set)
+ .dst_binding(2)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+ .buffer_info(&info_infos),
+ vk::WriteDescriptorSet::default()
+ .dst_set(set)
+ .dst_binding(3)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+ .buffer_info(&feat_infos),
+ ],
+ &[],
+ );
+
+ let cmd = device
+ .allocate_command_buffers(
+ &vk::CommandBufferAllocateInfo::default()
+ .command_pool(pool)
+ .level(vk::CommandBufferLevel::PRIMARY)
+ .command_buffer_count(1),
+ )
+ .expect("plate_probe command buffer")[0];
+ device
+ .begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT))
+ .unwrap();
+ let clear = [vk::ClearValue { color: vk::ClearColorValue { float32: [0.0, 0.0, 0.0, 1.0] } }];
+ let full = vk::Rect2D { offset: vk::Offset2D { x: 0, y: 0 }, extent };
+ device.cmd_begin_render_pass(
+ cmd,
+ &vk::RenderPassBeginInfo::default()
+ .render_pass(render_pass)
+ .framebuffer(framebuffer)
+ .render_area(full)
+ .clear_values(&clear),
+ vk::SubpassContents::INLINE,
+ );
+ device.cmd_set_viewport(cmd, 0, &[flipped_viewport(extent)]);
+ device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline);
+ device.cmd_bind_descriptor_sets(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline_layout, 0, &[set], &[]);
+ device.cmd_bind_vertex_buffers(cmd, 0, &[vbuf.buffer], &[0]);
+ for batch in &batches {
+ if batch.start >= batch.end {
+ continue;
+ }
+ let scissor = match batch.scissor {
+ Some((x, y, w, h)) => {
+ if x >= width || y >= height {
+ continue;
+ }
+ let (w, h) = (w.min(width - x), h.min(height - y));
+ if w == 0 || h == 0 {
+ continue;
+ }
+ vk::Rect2D { offset: vk::Offset2D { x: x as i32, y: y as i32 }, extent: vk::Extent2D { width: w, height: h } }
+ }
+ None => full,
+ };
+ device.cmd_set_scissor(cmd, 0, &[scissor]);
+ let pc = batch_push_constants(batch, clip_shape, 0);
+ device.cmd_push_constants(cmd, pipeline_layout, vk::ShaderStageFlags::FRAGMENT, 0, bytemuck::cast_slice(&pc));
+ device.cmd_draw(cmd, batch.end - batch.start, 1, batch.start, 0);
+ }
+ device.cmd_end_render_pass(cmd);
+ device.cmd_copy_image_to_buffer(
+ cmd,
+ target,
+ vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
+ readback.buffer,
+ &[vk::BufferImageCopy::default()
+ .image_subresource(
+ vk::ImageSubresourceLayers::default()
+ .aspect_mask(vk::ImageAspectFlags::COLOR)
+ .layer_count(1),
+ )
+ .image_extent(vk::Extent3D { width, height, depth: 1 })],
+ );
+ device.end_command_buffer(cmd).unwrap();
+ let fence = device.create_fence(&vk::FenceCreateInfo::default(), None).expect("plate_probe fence");
+ let cmds = [cmd];
+ device
+ .queue_submit(queue, &[vk::SubmitInfo::default().command_buffers(&cmds)], fence)
+ .expect("plate_probe submit");
+ device.wait_for_fences(&[fence], true, u64::MAX).expect("plate_probe wait");
+ let pixels = readback.allocation.as_ref().unwrap().mapped_slice().unwrap()[..readback_bytes as usize].to_vec();
+
+ device.destroy_fence(fence, None);
+ device.free_command_buffers(pool, &cmds);
+ device.destroy_descriptor_pool(descriptor_pool, None);
+ device.destroy_sampler(sampler, None);
+ for b in [&mut vbuf, &mut info, &mut feat, &mut readback] {
+ destroy_cpu_buffer(&device, allocator, b);
+ }
+ device.destroy_framebuffer(framebuffer, None);
+ device.destroy_pipeline(pipeline, None);
+ device.destroy_pipeline_layout(pipeline_layout, None);
+ device.destroy_shader_module(shader_module, None);
+ device.destroy_descriptor_set_layout(set_layout, None);
+ device.destroy_render_pass(render_pass, None);
+ for (image, mem, view) in [(target, target_mem, target_view), (backdrop, backdrop_mem, backdrop_view)] {
+ device.destroy_image_view(view, None);
+ device.destroy_image(image, None);
+ allocator.free(mem).expect("plate_probe free");
+ }
+ Some(Rendered { width, height, pixels })
+ }
+}
+
+fn color_range() -> vk::ImageSubresourceRange {
+ vk::ImageSubresourceRange::default()
+ .aspect_mask(vk::ImageAspectFlags::COLOR)
+ .level_count(1)
+ .layer_count(1)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::scene::layout::Rect;
+ use crate::scene::paint::PaintCtx;
+ use crate::scene::Material;
+
+ /// Two identical plates of recesses (at the parameter pane's sizes, a
+ /// boss among them): the left one's carves group into it as CSG
+ /// features, the right one's are each their own overlay, because a
+ /// transparent quad painted first closes the plate's grouping window.
+ fn two_plates(w: f32, h: f32) -> DisplayList {
+ let mut pc = PaintCtx::new();
+ let half = (w - 30.0) * 0.5;
+ let bevel = crate::layout::bevel_width();
+ for (i, x) in [10.0, 20.0 + half].into_iter().enumerate() {
+ let plate = Rect { x, y: 10.0, width: half, height: h - 20.0 };
+ pc.plate(plate, (16.0, 16.0, 16.0, 16.0), &Material::opaque([0.13, 0.13, 0.15, 1.0]), bevel);
+ if i == 1 {
+ pc.quad(Rect { x: x + 1.0, y: 11.0, width: 1.0, height: 1.0 }, [0.0; 4]);
+ }
+ let mut y = 40.0;
+ for (rh, rw) in [(20.0, 200.0), (20.0, 90.0), (28.0, 200.0), (40.0, 200.0)] {
+ let depth = bevel.min(rh * 0.2);
+ let r = (rh * 0.5f32).min(8.0);
+ pc.recess(Rect { x: x + 30.0, y, width: rw, height: rh }, (r, r, r, r), depth);
+ y += rh + 20.0;
+ }
+ pc.boss(Rect { x: x + 30.0, y, width: 120.0, height: 30.0 }, (8.0, 8.0, 8.0, 8.0), 6.0);
+ }
+ pc.finish()
+ }
+
+ /// A carve grouped into its plate shades exactly as the same carve drawn
+ /// as an overlay: the two plates are the same pixels. Until 2026-10-02 the
+ /// grouped one drew a doubled outline — the shade line taken from the
+ /// carves' slope fired twice down every wall — on every device, which
+ /// was first taken for an NVIDIA quirk.
+ #[test]
+ fn a_grouped_carve_is_drawn_as_its_overlay_is() {
+ // The style registry loads the config lazily; load it before the
+ // tessellation, so both plates are shaded under one configuration.
+ let _ = crate::layout::corner_shape();
+ let (w, h, scale) = (540.0f32, 280.0f32, 2.0f32);
+ let dl = two_plates(w, h);
+ // Not vacuous: the left plate's five carves really are grouped.
+ let (_, _, _, features) = crate::backend::window_runner::tessellate_display_list(&dl, w, h, scale);
+ assert_eq!(features.len(), 5, "the left plate's carves should group");
+ let Some(img) = render(&dl, w, h, scale) else {
+ eprintln!("skipping: no Vulkan device");
+ return;
+ };
+ let half = (w - 30.0) * 0.5;
+ let shift = ((10.0 + half) * scale) as u32;
+ let (x0, x1) = ((10.0 * scale) as u32, ((10.0 + half) * scale) as u32);
+ let face = img.rgba((60.0 * scale) as u32, (25.0 * scale) as u32);
+ let (mut differ, mut worst, mut carved) = (0usize, 0u8, 0usize);
+ for y in 0..img.height {
+ for x in x0..x1 {
+ let (a, b) = (img.rgba(x, y), img.rgba(x + shift, y));
+ let d = (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap();
+ if d > 1 {
+ differ += 1;
+ }
+ worst = worst.max(d);
+ if (0..3).any(|c| a[c].abs_diff(face[c]) > 8) {
+ carved += 1;
+ }
+ }
+ }
+ // The plates' own silhouettes and rolls are in the count too; what
+ // matters is that the carves made marks at all.
+ assert!(carved > 2000, "the carves left too little on the plate to compare ({carved} px)");
+ assert_eq!(differ, 0, "grouped and overlay plates differ in {differ} px (worst channel {worst})");
+ }
+}
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 6b0dbbe..84d7bff 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -71,6 +71,36 @@ const _: () = assert!(
before raising PUSH_CONSTANT_FLOATS"
);
+/// The fragment push-constant block for one batch: its rounded-rect clip,
+/// and its plate block when it is a plate cover quad. `feature_base` is the
+/// frame slot's first entry in the feature UBO, added to a plate's (or a
+/// union carve's) feature offset. Shared with the offscreen test harness
+/// (`vk::plate_probe`), so the two cannot push different blocks.
+pub(crate) fn batch_push_constants(batch: &Batch2D, clip_shape: f32, feature_base: usize) -> [f32; PUSH_CONSTANT_FLOATS] {
+ let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
+ let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
+ let mut pc = [0.0f32; PUSH_CONSTANT_FLOATS];
+ pc[..5].copy_from_slice(&rr);
+ pc[5] = enabled;
+ pc[7] = clip_shape;
+ if let Some(p) = &batch.plate {
+ pc[6] = p.mode;
+ pc[7] = p.shape;
+ pc[8..12].copy_from_slice(&p.rect);
+ pc[12..16].copy_from_slice(&p.radii);
+ pc[16..20].copy_from_slice(&p.light);
+ pc[20..24].copy_from_slice(&p.material);
+ pc[24..28].copy_from_slice(&p.host);
+ pc[28..32].copy_from_slice(&p.specular_tint);
+ if p.mode == 1.0 || p.mode == 14.0 {
+ // Rebase the feature offset onto this frame's UBO slot (a plate's
+ // CSG carves, or a union carve's boxes).
+ pc[24] += feature_base as f32;
+ }
+ }
+ pc
+}
+
/// Push-constant block for one SDF-lit plate batch (physical px throughout).
/// Mirrors the `p_*` fields of shader2d's `RRectClip`.
#[derive(Clone, Copy, PartialEq, Debug)]
@@ -180,18 +210,53 @@ fn rect_intersect(a: vk::Rect2D, b: vk::Rect2D) -> vk::Rect2D {
}
}
-const FRAMES_IN_FLIGHT: usize = 2;
+pub(crate) const FRAMES_IN_FLIGHT: usize = 2;
/// Max plate-carve features per frame; the shader's UBO holds one slot of this
/// size per frame in flight.
pub const MAX_PLATE_FEATURES: usize = 64;
-const PLATE_FEATURE_BYTES: usize = 48;
+pub(crate) const PLATE_FEATURE_BYTES: usize = 48;
/// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
/// [8 vec4 of profile slope samples].
// [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
// [size/clip vec4][carve profile meta][8 carve slopes][roll profile meta]
// [8 roll slopes][relief heights][backdrop meta] = 21 vec4. Grows only at the
// END — every offset above is addressed by index from both sides.
-const WINDOW_INFO_BYTES: vk::DeviceSize = 320;
+pub(crate) const WINDOW_INFO_BYTES: vk::DeviceSize = 320;
+
+/// The pinned relief heights (carve, roll) in physical px at `scale`, 0 =
+/// follow the width.
+pub(crate) fn relief_px_at(scale: f32) -> (f32, f32) {
+ let s = scale.max(0.001);
+ (
+ crate::layout::bevel_height().map_or(0.0, |h| h * s),
+ crate::layout::roll_height().map_or(0.0, |h| h * s),
+ )
+}
+
+/// shader2d's `WindowInfo` block for a target of `extent` whose corners clip
+/// at `clip_corner_radius` (physical px), with the pinned relief heights
+/// `relief` (carve, roll; physical px, 0 = unpinned) — the profiles and the
+/// corner shape from the live style. Shared with the offscreen test harness.
+pub(crate) fn window_info_data(extent: vk::Extent2D, clip_corner_radius: f32, relief: (f32, f32)) -> [f32; WINDOW_INFO_BYTES as usize / 4] {
+ let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
+ data[0] = extent.width as f32;
+ data[1] = extent.height as f32;
+ data[2] = clip_corner_radius;
+ data[3] = crate::layout::corner_shape();
+ if let Some(slopes) = crate::layout::bevel_profile_slopes() {
+ data[4] = 1.0;
+ data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+ data[8..8 + slopes.len()].copy_from_slice(&slopes);
+ }
+ if let Some(slopes) = crate::layout::roll_profile_slopes() {
+ data[40] = 1.0;
+ data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+ data[44..44 + slopes.len()].copy_from_slice(&slopes);
+ }
+ data[76] = relief.0;
+ data[77] = relief.1;
+ data
+}
pub(crate) struct AllocatedBuffer {
pub(crate) buffer: vk::Buffer,
@@ -353,6 +418,155 @@ pub struct VkRenderer {
/// (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.
+/// The 2D UI pipeline over `render_pass`: shader2d's descriptor set layout,
+/// its push-constant range, the vertex layout and the blend — what the
+/// renderer draws every frame with. Shared with the offscreen test harness
+/// (`vk::plate_probe`), so it draws with exactly the live pipeline.
+pub(crate) unsafe fn create_ui_pipeline(
+ device: &ash::Device,
+ render_pass: vk::RenderPass,
+) -> (vk::DescriptorSetLayout, vk::PipelineLayout, vk::ShaderModule, vk::Pipeline) {
+ // Descriptor set layout mirroring shader.wgsl @group(0): naga maps WGSL
+ // texture/sampler/uniform bindings 1:1 onto set 0 descriptor bindings.
+ let bindings = [
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(0)
+ .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+ .descriptor_count(1)
+ .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(1)
+ .descriptor_type(vk::DescriptorType::SAMPLER)
+ .descriptor_count(1)
+ .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(2)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+ .descriptor_count(1)
+ .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(3)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+ .descriptor_count(1)
+ .stage_flags(vk::ShaderStageFlags::FRAGMENT),
+ ];
+ let descriptor_set_layout = device
+ .create_descriptor_set_layout(
+ &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
+ None,
+ )
+ .expect("Failed to create descriptor set layout");
+
+ let set_layouts = [descriptor_set_layout];
+ // Push constants: the per-batch rounded-rect clip plus the SDF-lit
+ // plate block (eight vec4s, matching shader2d's `RRectClip`), read by
+ // shader2d's fragment stage. See `PUSH_CONSTANT_BYTES` — the block is
+ // exactly the Vulkan-guaranteed minimum and completely full.
+ let push_ranges = [vk::PushConstantRange::default()
+ .stage_flags(vk::ShaderStageFlags::FRAGMENT)
+ .offset(0)
+ .size(PUSH_CONSTANT_BYTES)];
+ let pipeline_layout = device
+ .create_pipeline_layout(
+ &vk::PipelineLayoutCreateInfo::default()
+ .set_layouts(&set_layouts)
+ .push_constant_ranges(&push_ranges),
+ None,
+ )
+ .expect("Failed to create pipeline layout");
+
+ // Pipeline from shader.wgsl (both entry points live in one SPIR-V module).
+ let spirv = shader2d_spirv();
+ let shader_module = device
+ .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(spirv), None)
+ .expect("Failed to create shader module");
+
+ let stages = [
+ vk::PipelineShaderStageCreateInfo::default()
+ .stage(vk::ShaderStageFlags::VERTEX)
+ .module(shader_module)
+ .name(c"vs_main"),
+ vk::PipelineShaderStageCreateInfo::default()
+ .stage(vk::ShaderStageFlags::FRAGMENT)
+ .module(shader_module)
+ .name(c"fs_main"),
+ ];
+
+ // Vertex layout = cce_ui::engine::Vertex: pos vec2f, color vec4f, clip vec3f.
+ let vertex_bindings = [vk::VertexInputBindingDescription::default()
+ .binding(0)
+ .stride(std::mem::size_of::<Vertex>() as u32)
+ .input_rate(vk::VertexInputRate::VERTEX)];
+ let vertex_attributes = [
+ vk::VertexInputAttributeDescription::default()
+ .location(0)
+ .binding(0)
+ .format(vk::Format::R32G32_SFLOAT)
+ .offset(0),
+ vk::VertexInputAttributeDescription::default()
+ .location(1)
+ .binding(0)
+ .format(vk::Format::R32G32B32A32_SFLOAT)
+ .offset(8),
+ vk::VertexInputAttributeDescription::default()
+ .location(2)
+ .binding(0)
+ .format(vk::Format::R32G32B32_SFLOAT)
+ .offset(24),
+ ];
+ let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
+ .vertex_binding_descriptions(&vertex_bindings)
+ .vertex_attribute_descriptions(&vertex_attributes);
+
+ let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
+ .topology(vk::PrimitiveTopology::TRIANGLE_LIST);
+ let viewport_state = vk::PipelineViewportStateCreateInfo::default()
+ .viewport_count(1)
+ .scissor_count(1);
+ let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
+ .polygon_mode(vk::PolygonMode::FILL)
+ .cull_mode(vk::CullModeFlags::NONE)
+ .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
+ .line_width(1.0);
+ let multisample = vk::PipelineMultisampleStateCreateInfo::default()
+ .rasterization_samples(vk::SampleCountFlags::TYPE_1);
+ // wgpu::BlendState::ALPHA_BLENDING.
+ let blend_attachments = [vk::PipelineColorBlendAttachmentState::default()
+ .blend_enable(true)
+ .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
+ .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+ .color_blend_op(vk::BlendOp::ADD)
+ .src_alpha_blend_factor(vk::BlendFactor::ONE)
+ .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+ .alpha_blend_op(vk::BlendOp::ADD)
+ .color_write_mask(vk::ColorComponentFlags::RGBA)];
+ let color_blend =
+ vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments);
+ let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
+ let dynamic_state =
+ vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
+
+ let pipeline = device
+ .create_graphics_pipelines(
+ vk::PipelineCache::null(),
+ &[vk::GraphicsPipelineCreateInfo::default()
+ .stages(&stages)
+ .vertex_input_state(&vertex_input)
+ .input_assembly_state(&input_assembly)
+ .viewport_state(&viewport_state)
+ .rasterization_state(&rasterization)
+ .multisample_state(&multisample)
+ .color_blend_state(&color_blend)
+ .dynamic_state(&dynamic_state)
+ .layout(pipeline_layout)
+ .render_pass(render_pass)
+ .subpass(0)],
+ None,
+ )
+ .expect("Failed to create graphics pipeline")[0];
+ (descriptor_set_layout, pipeline_layout, shader_module, pipeline)
+}
+
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(
@@ -645,144 +859,8 @@ impl VkRenderer {
)
.expect("Failed to create partial render pass");
- // Descriptor set layout mirroring shader.wgsl @group(0): naga maps WGSL
- // texture/sampler/uniform bindings 1:1 onto set 0 descriptor bindings.
- let bindings = [
- vk::DescriptorSetLayoutBinding::default()
- .binding(0)
- .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
- .descriptor_count(1)
- .stage_flags(vk::ShaderStageFlags::FRAGMENT),
- vk::DescriptorSetLayoutBinding::default()
- .binding(1)
- .descriptor_type(vk::DescriptorType::SAMPLER)
- .descriptor_count(1)
- .stage_flags(vk::ShaderStageFlags::FRAGMENT),
- vk::DescriptorSetLayoutBinding::default()
- .binding(2)
- .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
- .descriptor_count(1)
- .stage_flags(vk::ShaderStageFlags::FRAGMENT),
- vk::DescriptorSetLayoutBinding::default()
- .binding(3)
- .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
- .descriptor_count(1)
- .stage_flags(vk::ShaderStageFlags::FRAGMENT),
- ];
- let descriptor_set_layout = device
- .create_descriptor_set_layout(
- &vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings),
- None,
- )
- .expect("Failed to create descriptor set layout");
-
- let set_layouts = [descriptor_set_layout];
- // Push constants: the per-batch rounded-rect clip plus the SDF-lit
- // plate block (eight vec4s, matching shader2d's `RRectClip`), read by
- // shader2d's fragment stage. See `PUSH_CONSTANT_BYTES` — the block is
- // exactly the Vulkan-guaranteed minimum and completely full.
- let push_ranges = [vk::PushConstantRange::default()
- .stage_flags(vk::ShaderStageFlags::FRAGMENT)
- .offset(0)
- .size(PUSH_CONSTANT_BYTES)];
- let pipeline_layout = device
- .create_pipeline_layout(
- &vk::PipelineLayoutCreateInfo::default()
- .set_layouts(&set_layouts)
- .push_constant_ranges(&push_ranges),
- None,
- )
- .expect("Failed to create pipeline layout");
-
- // Pipeline from shader.wgsl (both entry points live in one SPIR-V module).
- let spirv = shader2d_spirv();
- let shader_module = device
- .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(spirv), None)
- .expect("Failed to create shader module");
-
- let stages = [
- vk::PipelineShaderStageCreateInfo::default()
- .stage(vk::ShaderStageFlags::VERTEX)
- .module(shader_module)
- .name(c"vs_main"),
- vk::PipelineShaderStageCreateInfo::default()
- .stage(vk::ShaderStageFlags::FRAGMENT)
- .module(shader_module)
- .name(c"fs_main"),
- ];
-
- // Vertex layout = cce_ui::engine::Vertex: pos vec2f, color vec4f, clip vec3f.
- let vertex_bindings = [vk::VertexInputBindingDescription::default()
- .binding(0)
- .stride(std::mem::size_of::<Vertex>() as u32)
- .input_rate(vk::VertexInputRate::VERTEX)];
- let vertex_attributes = [
- vk::VertexInputAttributeDescription::default()
- .location(0)
- .binding(0)
- .format(vk::Format::R32G32_SFLOAT)
- .offset(0),
- vk::VertexInputAttributeDescription::default()
- .location(1)
- .binding(0)
- .format(vk::Format::R32G32B32A32_SFLOAT)
- .offset(8),
- vk::VertexInputAttributeDescription::default()
- .location(2)
- .binding(0)
- .format(vk::Format::R32G32B32_SFLOAT)
- .offset(24),
- ];
- let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
- .vertex_binding_descriptions(&vertex_bindings)
- .vertex_attribute_descriptions(&vertex_attributes);
-
- let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default()
- .topology(vk::PrimitiveTopology::TRIANGLE_LIST);
- let viewport_state = vk::PipelineViewportStateCreateInfo::default()
- .viewport_count(1)
- .scissor_count(1);
- let rasterization = vk::PipelineRasterizationStateCreateInfo::default()
- .polygon_mode(vk::PolygonMode::FILL)
- .cull_mode(vk::CullModeFlags::NONE)
- .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
- .line_width(1.0);
- let multisample = vk::PipelineMultisampleStateCreateInfo::default()
- .rasterization_samples(vk::SampleCountFlags::TYPE_1);
- // wgpu::BlendState::ALPHA_BLENDING.
- let blend_attachments = [vk::PipelineColorBlendAttachmentState::default()
- .blend_enable(true)
- .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
- .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
- .color_blend_op(vk::BlendOp::ADD)
- .src_alpha_blend_factor(vk::BlendFactor::ONE)
- .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
- .alpha_blend_op(vk::BlendOp::ADD)
- .color_write_mask(vk::ColorComponentFlags::RGBA)];
- let color_blend =
- vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments);
- let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR];
- let dynamic_state =
- vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states);
-
- let pipeline = device
- .create_graphics_pipelines(
- vk::PipelineCache::null(),
- &[vk::GraphicsPipelineCreateInfo::default()
- .stages(&stages)
- .vertex_input_state(&vertex_input)
- .input_assembly_state(&input_assembly)
- .viewport_state(&viewport_state)
- .rasterization_state(&rasterization)
- .multisample_state(&multisample)
- .color_blend_state(&color_blend)
- .dynamic_state(&dynamic_state)
- .layout(pipeline_layout)
- .render_pass(render_pass)
- .subpass(0)],
- None,
- )
- .expect("Failed to create graphics pipeline")[0];
+ let (descriptor_set_layout, pipeline_layout, shader_module, pipeline) =
+ create_ui_pipeline(&device, render_pass);
// 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.
@@ -1047,11 +1125,7 @@ impl VkRenderer {
/// The pinned relief heights in physical px, 0 = follow the width.
fn relief_px(&self) -> (f32, f32) {
- let s = crate::scale::scale_factor().max(0.001);
- (
- crate::layout::bevel_height().map_or(0.0, |h| h * s),
- crate::layout::roll_height().map_or(0.0, |h| h * s),
- )
+ relief_px_at(crate::scale::scale_factor())
}
fn write_window_info(&mut self) {
@@ -1059,24 +1133,8 @@ impl VkRenderer {
// [roll profile meta vec4][8 vec4 roll slopes][relief heights vec4]
// — must stay in lockstep with shader2d's WindowInfo. (The frost
// recipe is per plate, in its push block, since RFC material step 3.)
- let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
- data[0] = self.extent.width as f32;
- data[1] = self.extent.height as f32;
- data[2] = self.clip_corner_radius();
- data[3] = crate::layout::corner_shape();
- if let Some(slopes) = crate::layout::bevel_profile_slopes() {
- data[4] = 1.0;
- data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
- data[8..8 + slopes.len()].copy_from_slice(&slopes);
- }
- if let Some(slopes) = crate::layout::roll_profile_slopes() {
- data[40] = 1.0;
- data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
- data[44..44 + slopes.len()].copy_from_slice(&slopes);
- }
let relief = self.relief_px();
- data[76] = relief.0;
- data[77] = relief.1;
+ let data = window_info_data(self.extent, self.clip_corner_radius(), relief);
self.relief_uploaded = relief;
self.profile_gen = crate::layout::bevel_profile_generation();
self.roll_profile_gen = crate::layout::roll_profile_generation();
@@ -2231,28 +2289,7 @@ impl VkRenderer {
// Per-batch rounded-rect clip (fragments outside
// discard) + the SDF-lit plate block when this
// batch is a plate cover quad.
- let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
- let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
- let mut pc = [0.0f32; PUSH_CONSTANT_FLOATS];
- pc[..5].copy_from_slice(&rr);
- pc[5] = enabled;
- pc[7] = clip_shape;
- if let Some(p) = &batch.plate {
- pc[6] = p.mode;
- pc[7] = p.shape;
- pc[8..12].copy_from_slice(&p.rect);
- pc[12..16].copy_from_slice(&p.radii);
- pc[16..20].copy_from_slice(&p.light);
- pc[20..24].copy_from_slice(&p.material);
- pc[24..28].copy_from_slice(&p.host);
- pc[28..32].copy_from_slice(&p.specular_tint);
- if p.mode == 1.0 || p.mode == 14.0 {
- // Rebase the feature offset onto this
- // frame's UBO slot (a plate's CSG carves,
- // or a union carve's boxes).
- pc[24] += (frame_index * MAX_PLATE_FEATURES) as f32;
- }
- }
+ let pc = batch_push_constants(batch, clip_shape, frame_index * MAX_PLATE_FEATURES);
self.core.device.cmd_push_constants(
cmd,
self.pipeline_layout,