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

src/vk/plate_probe.rs (19.4K)

  1 //! An offscreen 2D render for tests: a `DisplayList` tessellated by the
  2 //! runner's own `tessellate_display_list`, drawn through the live UI pipeline
  3 //! (`renderer::create_ui_pipeline`, the same push constants and `WindowInfo`)
  4 //! into an image, and read back. What it does not do is what a plate test does
  5 //! not need: no text, no images, no 3D backdrop (a 1x1 clear one is bound),
  6 //! and no blur-behind — a frosted plate is refused, since it samples a
  7 //! snapshot of the frame-so-far that only the swapchain path takes.
  8 //!
  9 //! It exists so the 2D shader can be TESTED rather than read: the first test
 10 //! holds a carve grouped into its plate to the same carve drawn as an overlay,
 11 //! pixel for pixel (see cce-ui/CLAUDE.md, "A grouped carve shades as its
 12 //! overlay does"). `render` returns `None` where there is no Vulkan device,
 13 //! and the tests skip with a note, as the GPU cross-checks elsewhere do.
 14 
 15 use ash::vk;
 16 use gpu_allocator::vulkan::{AllocationCreateDesc, AllocationScheme};
 17 use gpu_allocator::MemoryLocation;
 18 
 19 use super::core::VkCore;
 20 use super::renderer::{
 21     batch_push_constants, clear_image_to_shader_read, create_cpu_buffer, create_ui_pipeline,
 22     destroy_cpu_buffer, flipped_viewport, relief_px_at, window_info_data, FRAMES_IN_FLIGHT,
 23     MAX_PLATE_FEATURES, PLATE_FEATURE_BYTES, WINDOW_INFO_BYTES,
 24 };
 25 use crate::scene::paint::DisplayList;
 26 
 27 /// The format the renderer prefers for its swapchain, so blending happens in
 28 /// the same (linear) space.
 29 const FORMAT: vk::Format = vk::Format::R8G8B8A8_SRGB;
 30 
 31 /// A rendered frame: `width * height` RGBA8 pixels (sRGB-encoded), rows top
 32 /// to bottom.
 33 pub(crate) struct Rendered {
 34     pub width: u32,
 35     pub height: u32,
 36     pub pixels: Vec<u8>,
 37 }
 38 
 39 impl Rendered {
 40     pub fn rgba(&self, x: u32, y: u32) -> [u8; 4] {
 41         let i = ((y * self.width + x) * 4) as usize;
 42         [self.pixels[i], self.pixels[i + 1], self.pixels[i + 2], self.pixels[i + 3]]
 43     }
 44 }
 45 
 46 /// Render `dl` at `logical_w` x `logical_h` logical px and `scale`, over an
 47 /// opaque black clear. `None` when no Vulkan device can be opened.
 48 pub(crate) fn render(dl: &DisplayList, logical_w: f32, logical_h: f32, scale: f32) -> Option<Rendered> {
 49     let mut core = std::panic::catch_unwind(VkCore::new_headless).ok()?;
 50     let (verts, dl_batches, _images, features) =
 51         crate::backend::tessellate::tessellate_display_list(dl, logical_w, logical_h, scale);
 52     let batches = crate::backend::tessellate::dl_batches_2d(&dl_batches, scale);
 53     assert!(
 54         batches.iter().all(|b| !b.blur_behind),
 55         "plate_probe draws no blur-behind batch: give the plates an unfrosted material"
 56     );
 57     assert!(features.len() <= MAX_PLATE_FEATURES, "more carves than one frame's feature slot");
 58     let width = (logical_w * scale).round() as u32;
 59     let height = (logical_h * scale).round() as u32;
 60     let extent = vk::Extent2D { width, height };
 61     let clip_shape = crate::layout::corner_shape();
 62 
 63     unsafe {
 64         let device = core.device.clone();
 65         let queue = core.queue;
 66         let pool = core.command_pool;
 67         let allocator = core.allocator.as_mut().expect("allocator");
 68 
 69         // Target: the colour attachment, then the copy source.
 70         let mut image_of = |w: u32, h: u32, usage: vk::ImageUsageFlags, name: &str| {
 71             let image = device
 72                 .create_image(
 73                     &vk::ImageCreateInfo::default()
 74                         .image_type(vk::ImageType::TYPE_2D)
 75                         .format(FORMAT)
 76                         .extent(vk::Extent3D { width: w, height: h, depth: 1 })
 77                         .mip_levels(1)
 78                         .array_layers(1)
 79                         .samples(vk::SampleCountFlags::TYPE_1)
 80                         .tiling(vk::ImageTiling::OPTIMAL)
 81                         .usage(usage)
 82                         .initial_layout(vk::ImageLayout::UNDEFINED),
 83                     None,
 84                 )
 85                 .expect("plate_probe image");
 86             let allocation = allocator
 87                 .allocate(&AllocationCreateDesc {
 88                     name,
 89                     requirements: device.get_image_memory_requirements(image),
 90                     location: MemoryLocation::GpuOnly,
 91                     linear: false,
 92                     allocation_scheme: AllocationScheme::GpuAllocatorManaged,
 93                 })
 94                 .expect("plate_probe image memory");
 95             device
 96                 .bind_image_memory(image, allocation.memory(), allocation.offset())
 97                 .expect("plate_probe bind image");
 98             let view = device
 99                 .create_image_view(
100                     &vk::ImageViewCreateInfo::default()
101                         .image(image)
102                         .view_type(vk::ImageViewType::TYPE_2D)
103                         .format(FORMAT)
104                         .subresource_range(color_range()),
105                     None,
106                 )
107                 .expect("plate_probe view");
108             (image, allocation, view)
109         };
110         let (target, target_mem, target_view) = image_of(
111             width,
112             height,
113             vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_SRC,
114             "plate-probe-target",
115         );
116         // The backdrop binding: nothing samples it without blur-behind.
117         let (backdrop, backdrop_mem, backdrop_view) = image_of(
118             1,
119             1,
120             vk::ImageUsageFlags::SAMPLED | vk::ImageUsageFlags::TRANSFER_DST,
121             "plate-probe-backdrop",
122         );
123         clear_image_to_shader_read(&device, queue, pool, backdrop);
124 
125         let attachments = [vk::AttachmentDescription::default()
126             .format(FORMAT)
127             .samples(vk::SampleCountFlags::TYPE_1)
128             .load_op(vk::AttachmentLoadOp::CLEAR)
129             .store_op(vk::AttachmentStoreOp::STORE)
130             .stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
131             .stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
132             .initial_layout(vk::ImageLayout::UNDEFINED)
133             .final_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)];
134         let color_refs = [vk::AttachmentReference::default()
135             .attachment(0)
136             .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)];
137         let subpasses = [vk::SubpassDescription::default()
138             .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
139             .color_attachments(&color_refs)];
140         let render_pass = device
141             .create_render_pass(
142                 &vk::RenderPassCreateInfo::default().attachments(&attachments).subpasses(&subpasses),
143                 None,
144             )
145             .expect("plate_probe render pass");
146         let (set_layout, pipeline_layout, shader_module, pipeline) = create_ui_pipeline(&device, render_pass);
147         let framebuffer_views = [target_view];
148         let framebuffer = device
149             .create_framebuffer(
150                 &vk::FramebufferCreateInfo::default()
151                     .render_pass(render_pass)
152                     .attachments(&framebuffer_views)
153                     .width(width)
154                     .height(height)
155                     .layers(1),
156                 None,
157             )
158             .expect("plate_probe framebuffer");
159 
160         // Buffers: vertices, WindowInfo, the feature UBO (slot 0 of the
161         // renderer's two), the readback.
162         // All the renderer's frame slots, as the shader declares them; the
163         // features go in slot 0, so their offsets need no rebase.
164         let feature_bytes = (FRAMES_IN_FLIGHT * MAX_PLATE_FEATURES * PLATE_FEATURE_BYTES) as vk::DeviceSize;
165         let vert_bytes: &[u8] = bytemuck::cast_slice(&verts);
166         let mut vbuf = create_cpu_buffer(
167             &device,
168             allocator,
169             (vert_bytes.len() as vk::DeviceSize).max(64),
170             vk::BufferUsageFlags::VERTEX_BUFFER,
171             "plate-probe-verts",
172         );
173         let mut info = create_cpu_buffer(&device, allocator, WINDOW_INFO_BYTES, vk::BufferUsageFlags::UNIFORM_BUFFER, "plate-probe-info");
174         let mut feat = create_cpu_buffer(&device, allocator, feature_bytes, vk::BufferUsageFlags::UNIFORM_BUFFER, "plate-probe-features");
175         let readback_bytes = (width as vk::DeviceSize) * (height as vk::DeviceSize) * 4;
176         let mut readback = create_cpu_buffer(&device, allocator, readback_bytes, vk::BufferUsageFlags::TRANSFER_DST, "plate-probe-readback");
177         vbuf.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..vert_bytes.len()].copy_from_slice(vert_bytes);
178         let info_data = window_info_data(extent, 0.0, relief_px_at(scale));
179         info.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..WINDOW_INFO_BYTES as usize]
180             .copy_from_slice(bytemuck::cast_slice(&info_data));
181         let feat_bytes: &[u8] = bytemuck::cast_slice(&features);
182         feat.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..feat_bytes.len()].copy_from_slice(feat_bytes);
183 
184         let sampler = device
185             .create_sampler(&vk::SamplerCreateInfo::default(), None)
186             .expect("plate_probe sampler");
187         let pool_sizes = [
188             vk::DescriptorPoolSize::default().ty(vk::DescriptorType::SAMPLED_IMAGE).descriptor_count(1),
189             vk::DescriptorPoolSize::default().ty(vk::DescriptorType::SAMPLER).descriptor_count(1),
190             vk::DescriptorPoolSize::default().ty(vk::DescriptorType::UNIFORM_BUFFER).descriptor_count(2),
191         ];
192         let descriptor_pool = device
193             .create_descriptor_pool(&vk::DescriptorPoolCreateInfo::default().max_sets(1).pool_sizes(&pool_sizes), None)
194             .expect("plate_probe descriptor pool");
195         let layouts = [set_layout];
196         let set = device
197             .allocate_descriptor_sets(
198                 &vk::DescriptorSetAllocateInfo::default().descriptor_pool(descriptor_pool).set_layouts(&layouts),
199             )
200             .expect("plate_probe descriptor set")[0];
201         let image_infos = [vk::DescriptorImageInfo::default()
202             .image_view(backdrop_view)
203             .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
204         let sampler_infos = [vk::DescriptorImageInfo::default().sampler(sampler)];
205         let info_infos = [vk::DescriptorBufferInfo::default().buffer(info.buffer).offset(0).range(WINDOW_INFO_BYTES)];
206         let feat_infos = [vk::DescriptorBufferInfo::default().buffer(feat.buffer).offset(0).range(feature_bytes)];
207         device.update_descriptor_sets(
208             &[
209                 vk::WriteDescriptorSet::default()
210                     .dst_set(set)
211                     .dst_binding(0)
212                     .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
213                     .image_info(&image_infos),
214                 vk::WriteDescriptorSet::default()
215                     .dst_set(set)
216                     .dst_binding(1)
217                     .descriptor_type(vk::DescriptorType::SAMPLER)
218                     .image_info(&sampler_infos),
219                 vk::WriteDescriptorSet::default()
220                     .dst_set(set)
221                     .dst_binding(2)
222                     .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
223                     .buffer_info(&info_infos),
224                 vk::WriteDescriptorSet::default()
225                     .dst_set(set)
226                     .dst_binding(3)
227                     .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
228                     .buffer_info(&feat_infos),
229             ],
230             &[],
231         );
232 
233         let cmd = device
234             .allocate_command_buffers(
235                 &vk::CommandBufferAllocateInfo::default()
236                     .command_pool(pool)
237                     .level(vk::CommandBufferLevel::PRIMARY)
238                     .command_buffer_count(1),
239             )
240             .expect("plate_probe command buffer")[0];
241         device
242             .begin_command_buffer(cmd, &vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT))
243             .unwrap();
244         let clear = [vk::ClearValue { color: vk::ClearColorValue { float32: [0.0, 0.0, 0.0, 1.0] } }];
245         let full = vk::Rect2D { offset: vk::Offset2D { x: 0, y: 0 }, extent };
246         device.cmd_begin_render_pass(
247             cmd,
248             &vk::RenderPassBeginInfo::default()
249                 .render_pass(render_pass)
250                 .framebuffer(framebuffer)
251                 .render_area(full)
252                 .clear_values(&clear),
253             vk::SubpassContents::INLINE,
254         );
255         device.cmd_set_viewport(cmd, 0, &[flipped_viewport(extent)]);
256         device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline);
257         device.cmd_bind_descriptor_sets(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline_layout, 0, &[set], &[]);
258         device.cmd_bind_vertex_buffers(cmd, 0, &[vbuf.buffer], &[0]);
259         for batch in &batches {
260             if batch.start >= batch.end {
261                 continue;
262             }
263             let scissor = match batch.scissor {
264                 Some((x, y, w, h)) => {
265                     if x >= width || y >= height {
266                         continue;
267                     }
268                     let (w, h) = (w.min(width - x), h.min(height - y));
269                     if w == 0 || h == 0 {
270                         continue;
271                     }
272                     vk::Rect2D { offset: vk::Offset2D { x: x as i32, y: y as i32 }, extent: vk::Extent2D { width: w, height: h } }
273                 }
274                 None => full,
275             };
276             device.cmd_set_scissor(cmd, 0, &[scissor]);
277             let pc = batch_push_constants(batch, clip_shape, 0);
278             device.cmd_push_constants(cmd, pipeline_layout, vk::ShaderStageFlags::FRAGMENT, 0, bytemuck::cast_slice(&pc));
279             device.cmd_draw(cmd, batch.end - batch.start, 1, batch.start, 0);
280         }
281         device.cmd_end_render_pass(cmd);
282         device.cmd_copy_image_to_buffer(
283             cmd,
284             target,
285             vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
286             readback.buffer,
287             &[vk::BufferImageCopy::default()
288                 .image_subresource(
289                     vk::ImageSubresourceLayers::default()
290                         .aspect_mask(vk::ImageAspectFlags::COLOR)
291                         .layer_count(1),
292                 )
293                 .image_extent(vk::Extent3D { width, height, depth: 1 })],
294         );
295         device.end_command_buffer(cmd).unwrap();
296         let fence = device.create_fence(&vk::FenceCreateInfo::default(), None).expect("plate_probe fence");
297         let cmds = [cmd];
298         device
299             .queue_submit(queue, &[vk::SubmitInfo::default().command_buffers(&cmds)], fence)
300             .expect("plate_probe submit");
301         device.wait_for_fences(&[fence], true, u64::MAX).expect("plate_probe wait");
302         let pixels = readback.allocation.as_ref().unwrap().mapped_slice().unwrap()[..readback_bytes as usize].to_vec();
303 
304         device.destroy_fence(fence, None);
305         device.free_command_buffers(pool, &cmds);
306         device.destroy_descriptor_pool(descriptor_pool, None);
307         device.destroy_sampler(sampler, None);
308         for b in [&mut vbuf, &mut info, &mut feat, &mut readback] {
309             destroy_cpu_buffer(&device, allocator, b);
310         }
311         device.destroy_framebuffer(framebuffer, None);
312         device.destroy_pipeline(pipeline, None);
313         device.destroy_pipeline_layout(pipeline_layout, None);
314         device.destroy_shader_module(shader_module, None);
315         device.destroy_descriptor_set_layout(set_layout, None);
316         device.destroy_render_pass(render_pass, None);
317         for (image, mem, view) in [(target, target_mem, target_view), (backdrop, backdrop_mem, backdrop_view)] {
318             device.destroy_image_view(view, None);
319             device.destroy_image(image, None);
320             allocator.free(mem).expect("plate_probe free");
321         }
322         Some(Rendered { width, height, pixels })
323     }
324 }
325 
326 fn color_range() -> vk::ImageSubresourceRange {
327     vk::ImageSubresourceRange::default()
328         .aspect_mask(vk::ImageAspectFlags::COLOR)
329         .level_count(1)
330         .layer_count(1)
331 }
332 
333 #[cfg(test)]
334 mod tests {
335     use super::*;
336     use crate::scene::layout::Rect;
337     use crate::scene::paint::PaintCtx;
338     use crate::scene::Material;
339 
340     /// Two identical plates of recesses (at the parameter pane's sizes, a
341     /// boss among them): the left one's carves group into it as CSG
342     /// features, the right one's are each their own overlay, because a
343     /// transparent quad painted first closes the plate's grouping window.
344     fn two_plates(w: f32, h: f32) -> DisplayList {
345         let mut pc = PaintCtx::new();
346         let half = (w - 30.0) * 0.5;
347         let bevel = crate::layout::bevel_width();
348         for (i, x) in [10.0, 20.0 + half].into_iter().enumerate() {
349             let plate = Rect { x, y: 10.0, width: half, height: h - 20.0 };
350             pc.plate(plate, (16.0, 16.0, 16.0, 16.0), &Material::opaque([0.13, 0.13, 0.15, 1.0]), bevel);
351             if i == 1 {
352                 pc.quad(Rect { x: x + 1.0, y: 11.0, width: 1.0, height: 1.0 }, [0.0; 4]);
353             }
354             let mut y = 40.0;
355             for (rh, rw) in [(20.0, 200.0), (20.0, 90.0), (28.0, 200.0), (40.0, 200.0)] {
356                 let depth = bevel.min(rh * 0.2);
357                 let r = (rh * 0.5f32).min(8.0);
358                 pc.recess(Rect { x: x + 30.0, y, width: rw, height: rh }, (r, r, r, r), depth);
359                 y += rh + 20.0;
360             }
361             pc.boss(Rect { x: x + 30.0, y, width: 120.0, height: 30.0 }, (8.0, 8.0, 8.0, 8.0), 6.0);
362         }
363         pc.finish()
364     }
365 
366     /// A carve grouped into its plate shades exactly as the same carve drawn
367     /// as an overlay: the two plates are the same pixels. Until 2026-10-02 the
368     /// grouped one drew a doubled outline — the shade line taken from the
369     /// carves' slope fired twice down every wall — on every device, which
370     /// was first taken for an NVIDIA quirk.
371     #[test]
372     fn a_grouped_carve_is_drawn_as_its_overlay_is() {
373         // The style registry loads the config lazily; load it before the
374         // tessellation, so both plates are shaded under one configuration.
375         let _ = crate::layout::corner_shape();
376         let (w, h, scale) = (540.0f32, 280.0f32, 2.0f32);
377         let dl = two_plates(w, h);
378         // Not vacuous: the left plate's five carves really are grouped.
379         let (_, _, _, features) = crate::backend::tessellate::tessellate_display_list(&dl, w, h, scale);
380         assert_eq!(features.len(), 5, "the left plate's carves should group");
381         let Some(img) = render(&dl, w, h, scale) else {
382             eprintln!("skipping: no Vulkan device");
383             return;
384         };
385         let half = (w - 30.0) * 0.5;
386         let shift = ((10.0 + half) * scale) as u32;
387         let (x0, x1) = ((10.0 * scale) as u32, ((10.0 + half) * scale) as u32);
388         let face = img.rgba((60.0 * scale) as u32, (25.0 * scale) as u32);
389         let (mut differ, mut worst, mut carved) = (0usize, 0u8, 0usize);
390         for y in 0..img.height {
391             for x in x0..x1 {
392                 let (a, b) = (img.rgba(x, y), img.rgba(x + shift, y));
393                 let d = (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap();
394                 if d > 1 {
395                     differ += 1;
396                 }
397                 worst = worst.max(d);
398                 if (0..3).any(|c| a[c].abs_diff(face[c]) > 8) {
399                     carved += 1;
400                 }
401             }
402         }
403         // The plates' own silhouettes and rolls are in the count too; what
404         // matters is that the carves made marks at all.
405         assert!(carved > 2000, "the carves left too little on the plate to compare ({carved} px)");
406         assert_eq!(differ, 0, "grouped and overlay plates differ in {differ} px (worst channel {worst})");
407     }
408 }