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 }