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

commit3a5d9af50d1c27cb1f2ea4b5c44d236e6900fac9
parent714aee1f98
authorLucas Galante <[email protected]>
date2026-08-13 21:25
fix: describe vertex location 4 on the image pipeline

The image pipeline reuses the glyph shader, whose vertex entry point declares
locations 0..=4, but its VkVertexInputAttributeDescription list stopped at 3 —
ImageVertex was missing GlyphVertex's trailing clip_extents. The validation
layer flagged it (VUID-VkGraphicsPipelineCreateInfo-Input-07904) and, without
vertexAttributeRobustness, location 4 read undefined data.

Inert in practice today: images pass clip_circle = [0;3], so the shader's clip
branch never runs and the garbage is never consumed. It would have become a
real bug the moment image clipping was switched on.

ImageVertex now matches GlyphVertex field-for-field (52 bytes, offsets
0/8/16/32/44) with clip_extents = [0;2], the shader's documented
plain-circle degenerate case.

Verified: validation error gone, and the demo gallery's gradient panels --
which go through this pipeline -- are pixel-identical to the pre-fix capture.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/vk/image.rs | 25 +++++++++++++++++++++----
 1 file changed, 21 insertions(+), 4 deletions(-)

diff --git a/src/vk/image.rs b/src/vk/image.rs
index cbb2264..08ab1b5 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -58,11 +58,17 @@ pub fn free_image(id: u32) {
 
 #[repr(C)]
 #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+/// Must match `GlyphVertex` field-for-field: both pipelines are fed by the same
+/// glyph shader, whose vertex entry point declares locations 0..=4. Omitting
+/// `clip_extents` here left location 4 with no `VkVertexInputAttributeDescription`,
+/// which the validation layer flags (VUID-VkGraphicsPipelineCreateInfo-Input-07904)
+/// and which reads undefined data without `vertexAttributeRobustness`.
 struct ImageVertex {
     position: [f32; 2],
     uv: [f32; 2],
     color: [f32; 4],
     clip_circle: [f32; 3],
+    clip_extents: [f32; 2],
 }
 
 struct GpuImage {
@@ -160,6 +166,13 @@ impl ImageStage {
                     .binding(0)
                     .format(vk::Format::R32G32B32_SFLOAT)
                     .offset(32),
+                // Location 4 is declared by the shared glyph shader; without this
+                // entry the pipeline is invalid and the attribute reads undefined.
+                vk::VertexInputAttributeDescription::default()
+                    .location(4)
+                    .binding(0)
+                    .format(vk::Format::R32G32_SFLOAT)
+                    .offset(44),
             ];
             let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
                 .vertex_binding_descriptions(&vertex_bindings)
@@ -495,10 +508,14 @@ impl ImageStage {
             let ndc = |px: f32, py: f32| [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0];
             let color = [1.0, 1.0, 1.0, q.alpha];
             let clip_circle = [0.0; 3];
-            let tl = ImageVertex { position: ndc(x, y), uv: [0.0, 0.0], color, clip_circle };
-            let tr = ImageVertex { position: ndc(x + w, y), uv: [1.0, 0.0], color, clip_circle };
-            let bl = ImageVertex { position: ndc(x, y + h), uv: [0.0, 1.0], color, clip_circle };
-            let br = ImageVertex { position: ndc(x + w, y + h), uv: [1.0, 1.0], color, clip_circle };
+            // Zero extents = the shader's plain-circle clip degenerate case. Inert
+            // while clip_circle.z is 0 (the clip branch never runs), but it must be
+            // a defined value, not whatever the missing attribute used to read.
+            let clip_extents = [0.0; 2];
+            let tl = ImageVertex { position: ndc(x, y), uv: [0.0, 0.0], color, clip_circle, clip_extents };
+            let tr = ImageVertex { position: ndc(x + w, y), uv: [1.0, 0.0], color, clip_circle, clip_extents };
+            let bl = ImageVertex { position: ndc(x, y + h), uv: [0.0, 1.0], color, clip_circle, clip_extents };
+            let br = ImageVertex { position: ndc(x + w, y + h), uv: [1.0, 1.0], color, clip_circle, clip_extents };
             verts.extend([tl, tr, bl, tr, br, bl]);
         }
         let bytes: &[u8] = bytemuck::cast_slice(&verts);