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

commit38bcad1d1ae95dc2ab7f4c9164ee2f778ae8506b
parentab04747b75
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 10:21
fix(scene): a screen-space draw is a per-draw flag, not z = 9.99

scene3d.wgsl took ANY vertex within 0.01 of z = 9.99 for a corner of the
screen-space background quad and placed it at its own xy in NDC, skipping
the mvp. That ran on every mesh, so real geometry spanning z = 9.99 in its
own units had a ring of its points flung across the pane as spikes: a
25 mm STL sphere in cce-model, or a designer Transform at z = 9.99.

The draw now says so: SceneDraw::screen_space, carried in the uniform
block's former _pad slot, which both renderers fill through
scene_uniforms. Callers building a background quad set it; its corners'
z is ignored.

The probe gains the regression: a sphere modelled around z = 9.99 and
scaled into place by its mvp. With the old test reinstated it shreds into
a fan over the pane; with this one it draws whole (Vulkan, scale-2
cce-shadow). The WebGPU half shares the shader and scene_uniforms and was
not built: no wasm32 target here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                 | 12 ++++++++++
 examples/probe3d/scene.rs | 41 +++++++++++++++++++++++++++++-----
 src/draw/scene.rs         | 57 ++++++++++++++++++++++++++++++++++++++++++-----
 src/draw/scene3d.wgsl     | 11 ++++++---
 src/vk/scene.rs           |  4 ++--
 5 files changed, 109 insertions(+), 16 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 697831c..7d74848 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -220,6 +220,18 @@ a row of instanced cubes, and Vulkan draws it (2026-10-06, a shadow); the WebGPU
 toolchain has no wasm32 target — so `scripts/web-probe/probe3d` is the first thing to run
 on one that has.
 
+**A screen-space draw says so** (since 2026-10-07, `SceneDraw::screen_space`). A pane's
+background quad is a mesh whose vertices are NDC corners; the draw sets `screen_space`, the
+uniform block carries it in what was `_pad`, and `scene3d.wgsl` places those vertices at
+their own xy on the far plane, unlit, without the mvp. Until then the signal was IN the
+vertex data: any vertex of any mesh within 0.01 of z = 9.99 became a background corner, so
+real geometry spanning that plane in its own units (cce-model's 25 mm STL sphere; a
+designer Transform at z = 9.99) tore into spikes across the pane. Never key behaviour off
+vertex values again. The probe has a regression for it: a sphere modelled around z = 9.99
+and scaled into place by its mvp, which the old shader shredded and this one draws whole
+(Vulkan, scale-2 shadow; the WebGPU half shares the shader and `scene_uniforms` and was
+not built — still no wasm32 target here).
+
 **And so does the path tracer** (since 2026-10-05). `Stage3D` carries the tracer's half
 too — `set_rt_scene` / `set_rt_scene_with_image`, `set_rt_environment`,
 `set_rt_background`, `stage_rt`, `rt_accumulating` — and what it traces from moved to
diff --git a/examples/probe3d/scene.rs b/examples/probe3d/scene.rs
index b3cc62c..7728c4c 100644
--- a/examples/probe3d/scene.rs
+++ b/examples/probe3d/scene.rs
@@ -4,13 +4,16 @@
 //! WebGPU one (`probe3d_web`) and the two can be compared pixel for pixel.
 //!
 //! It reaches every path the scene pass has: the screen-space background
-//! quad (the z = 9.99 sentinel), flat-shaded fills (the derivative normal),
+//! quad (`SceneDraw::screen_space`), flat-shaded fills (the derivative normal),
 //! a prelit fill, a fill carrying a wire overlay (the depth-biased fill and
 //! the line pipeline, wires tinted), a see-through translucent fill and the
 //! wires riding it, an image standing in the scene before the translucent
 //! draw, an INSTANCED draw (one white cube drawn for a row of coloured
 //! instances), a light the host sets, and a frosted plate over the pane whose
-//! blur samples the backdrop the scene left.
+//! blur samples the backdrop the scene left. And one regression: a small
+//! sphere modelled in its own units around z = 9.99 and scaled into place,
+//! which the old in-band background sentinel (any vertex within 0.01 of
+//! z = 9.99) tore into spikes across the pane.
 
 use cce_ui::engine::{
     AppSender, Application, LogicalPosition, LogicalSize, MeshId, RtCamera, RtImage, RtMaterial, RtTriangle, SceneDraw,
@@ -43,6 +46,18 @@ pub type Traced = Probe3d<true>;
 /// The image's corners in the scene, as both passes place it.
 const IMAGE_CORNERS: [[f32; 3]; 4] = [[-2.6, 1.6, -1.6], [-0.6, 1.6, -1.6], [-0.6, 0.35, -1.6], [-2.6, 0.35, -1.6]];
 
+/// The far-z sphere, in its own units: centred ON z = 9.99, so its meridians
+/// at longitude 0 and 180 degrees sit exactly on that plane.
+const FAR_Z_CENTER: Vec3 = Vec3::new(0.0, 0.0, 9.99);
+const FAR_Z_RADIUS: f32 = 4.0;
+
+/// Where the far-z sphere's units land in the scene: a tenth of their size,
+/// its centre at (-0.3, -0.3, 1.1), so it reads as a 0.4-radius ball in front
+/// of the big sphere.
+fn far_z_model() -> Mat4 {
+    Mat4::from_translation(Vec3::new(-0.3, -0.3, 1.1)) * Mat4::from_scale(Vec3::splat(0.1)) * Mat4::from_translation(-FAR_Z_CENTER)
+}
+
 struct Meshes {
     background: MeshId,
     cube: MeshId,
@@ -54,6 +69,8 @@ struct Meshes {
     /// A small white cube, and the row of instances it is drawn for.
     marker: MeshId,
     marker_instances: MeshId,
+    /// The sphere spanning z = 9.99 in its own units (`far_z_model`).
+    far_z: MeshId,
 }
 
 fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
@@ -143,6 +160,9 @@ fn traced_scene() -> (Vec<RtTriangle>, Vec<RtMaterial>) {
     add(cuboid(Vec3::new(-1.6, 0.0, 0.2), Vec3::splat(0.6), [[0.0; 3]; 6]), [0.8, 0.35, 0.25], [0.0; 3]);
     add(sphere(Vec3::new(0.4, 0.3, -0.4), 0.9, 12, 20).0, [0.45, 0.55, 0.75], [0.0; 3]);
     add(cuboid(Vec3::new(1.5, 0.1, 1.2), Vec3::splat(0.55), [[0.0; 3]; 6]), [0.3, 0.7, 0.9], [0.4, 0.9, 1.1]);
+    let model = far_z_model();
+    let far_z = sphere(FAR_Z_CENTER, FAR_Z_RADIUS, 10, 16).0.into_iter().map(|p| v(model.transform_point3(Vec3::from(p.position)), p.color)).collect();
+    add(far_z, [0.9, 0.75, 0.3], [0.0; 3]);
     (tris, mats)
 }
 
@@ -198,7 +218,8 @@ impl<const TRACE: bool> Application for Probe3d<TRACE> {
     }
 
     fn init_3d(&mut self, stage: &mut dyn Stage3D) {
-        let bg = |x: f32, y: f32, c: [f32; 3]| Vertex3D { position: [x, y, 9.99], color: c };
+        // NDC corners: the draw is `screen_space`, so z is ignored.
+        let bg = |x: f32, y: f32, c: [f32; 3]| Vertex3D { position: [x, y, 0.0], color: c };
         let (top, bottom) = ([0.10, 0.12, 0.20], [0.30, 0.26, 0.22]);
         let background = stage.create_mesh(&[
             bg(-1.0, -1.0, bottom),
@@ -217,6 +238,11 @@ impl<const TRACE: bool> Application for Probe3d<TRACE> {
             p.color = [p.color[0] * k, p.color[1] * k, p.color[2] * k];
         }
         let prelit = stage.create_mesh(&prelit_verts);
+        let mut far_z_verts = sphere(FAR_Z_CENTER, FAR_Z_RADIUS, 10, 16).0;
+        for p in &mut far_z_verts {
+            p.color = [0.9, 0.75, 0.3];
+        }
+        let far_z = stage.create_mesh(&far_z_verts);
         let (tris, lines) = sphere(Vec3::new(0.4, 0.3, -0.4), 0.9, 12, 20);
         let sphere = stage.create_mesh(&tris);
         let sphere_wires = stage.create_mesh(&lines);
@@ -232,7 +258,7 @@ impl<const TRACE: bool> Application for Probe3d<TRACE> {
             .collect();
         let marker_instances = stage.create_mesh(&row);
         stage.set_scene_light([0.6, 0.7, 0.4]);
-        self.meshes = Some(Meshes { background, cube, prelit, sphere, sphere_wires, glass, glass_wires, marker, marker_instances });
+        self.meshes = Some(Meshes { background, cube, prelit, sphere, sphere_wires, glass, glass_wires, marker, marker_instances, far_z });
         if TRACE {
             let (tris, mats) = traced_scene();
             stage.set_rt_scene_with_image(&tris, &mats, Some(RtImage { image: self.image, corners: IMAGE_CORNERS, opacity: 0.9 }));
@@ -265,12 +291,15 @@ impl<const TRACE: bool> Application for Probe3d<TRACE> {
             prelit: false,
             see_through: false,
             instances: None,
+            screen_space: false,
         };
+        let far_z_mvp = (Mat4::from_cols_array_2d(&mvp) * far_z_model()).to_cols_array_2d();
         let draws = vec![
-            draw(m.background),
+            SceneDraw { screen_space: true, ..draw(m.background) },
             SceneDraw { prelit: true, ..draw(m.prelit) },
             draw(m.cube),
             SceneDraw { instances: Some(m.marker_instances), ..draw(m.marker) },
+            SceneDraw { mvp: far_z_mvp, ..draw(m.far_z) },
             SceneDraw { wire_base_width: 1.0, ..draw(m.sphere) },
             SceneDraw { wireframe: true, wire_tint: [1.0, 1.0, 1.0, 0.6], ..draw(m.sphere_wires) },
             SceneDraw { see_through: true, opacity: 0.45, ..draw(m.glass) },
@@ -282,7 +311,7 @@ impl<const TRACE: bool> Application for Probe3d<TRACE> {
             corners: IMAGE_CORNERS,
             mvp,
             opacity: 0.9,
-            before: 6,
+            before: 7,
         }]);
         false
     }
diff --git a/src/draw/scene.rs b/src/draw/scene.rs
index c38c4b2..c84bb08 100644
--- a/src/draw/scene.rs
+++ b/src/draw/scene.rs
@@ -83,6 +83,17 @@ pub struct SceneDraw {
     /// nothing; `None` draws `mesh` once, as it is (the renderer binds one
     /// instance at the origin in white, which changes no vertex).
     pub instances: Option<MeshId>,
+    /// The mesh is already in clip space: its vertices' x and y are NDC
+    /// (-1..1, y up) and their z is ignored — each is drawn at the far plane
+    /// (depth 0.9999), unlit, without the mvp. What a pane's background quad
+    /// is: two triangles over (-1, -1)..(1, 1), drawn first, behind
+    /// everything. False for an ordinary draw.
+    ///
+    /// This was an in-band signal until 2026-10-07: any vertex of ANY mesh
+    /// within 0.01 of z = 9.99 was taken for a background corner, so real
+    /// geometry spanning that plane in its own units (a 25 mm STL sphere in
+    /// cce-model) had a ring of its points flung across the pane as spikes.
+    pub screen_space: bool,
 }
 
 /// A user image standing in the 3D scene: a textured quad, unlit, depth
@@ -137,7 +148,9 @@ pub(crate) struct SceneUniforms {
     is_wire: f32,
     /// 1.0 on `SceneDraw::prelit` draws: the flat shading is skipped too.
     prelit: f32,
-    _pad: [f32; 1],
+    /// 1.0 on `SceneDraw::screen_space` draws: the vertex stage places the
+    /// vertex at its own xy in NDC, at the far plane, and skips the mvp.
+    screen_space: f32,
     /// xyz: toward the light, world space, unit length; w unused.
     light: [f32; 4],
 }
@@ -202,7 +215,7 @@ pub(crate) fn scene_uniforms(
 ) -> Vec<SceneUniforms> {
     let corner_shape = crate::layout::corner_shape();
     let light = [light[0], light[1], light[2], 0.0];
-    let block = |mvp, wire_tint, opacity, is_wire, prelit| SceneUniforms {
+    let block = |mvp, wire_tint, opacity, is_wire, prelit, screen_space| SceneUniforms {
         mvp,
         window_size,
         window_radius: corner_radius_px,
@@ -211,15 +224,16 @@ pub(crate) fn scene_uniforms(
         opacity,
         is_wire,
         prelit,
-        _pad: [0.0; 1],
+        screen_space,
         light,
     };
+    let flag = |b: bool| if b { 1.0 } else { 0.0 };
     let mut out = Vec::with_capacity(draws.len() + images.len());
     for d in draws {
-        out.push(block(d.mvp, d.wire_tint, d.opacity, if d.wireframe { 1.0 } else { 0.0 }, if d.prelit { 1.0 } else { 0.0 }));
+        out.push(block(d.mvp, d.wire_tint, d.opacity, flag(d.wireframe), flag(d.prelit), flag(d.screen_space)));
     }
     for i in images {
-        out.push(block(i.mvp, [0.0; 4], i.opacity, 0.0, 1.0));
+        out.push(block(i.mvp, [0.0; 4], i.opacity, 0.0, 1.0, 0.0));
     }
     out
 }
@@ -251,3 +265,36 @@ pub(crate) fn image_quads_3d(images: &[SceneImage]) -> Vec<ImageVertex3D> {
 pub(crate) fn wire_base_bias(w: f32) -> (f32, f32) {
     (2.0, 1.5 + w)
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn draw(mesh: usize, screen_space: bool) -> SceneDraw {
+        SceneDraw {
+            mesh: MeshId(mesh),
+            mvp: [[0.0; 4]; 4],
+            wireframe: false,
+            wire_tint: [0.0; 4],
+            opacity: 1.0,
+            line_width: 1.0,
+            wire_base_width: 0.0,
+            prelit: false,
+            see_through: false,
+            instances: None,
+            screen_space,
+        }
+    }
+
+    /// Only the draw that asks to be screen-space is: the shader reads the
+    /// flag per draw, never off the vertex data (the old z = 9.99 sentinel).
+    #[test]
+    fn screen_space_is_set_per_draw() {
+        let blocks = scene_uniforms(&[draw(0, true), draw(1, false)], &[], [100.0, 100.0], 0.0, DEFAULT_SCENE_LIGHT);
+        assert_eq!(blocks[0].screen_space, 1.0);
+        assert_eq!(blocks[1].screen_space, 0.0);
+        let shader = include_str!("scene3d.wgsl");
+        assert!(shader.contains("uniforms.screen_space"));
+        assert!(!shader.contains("position.z -"), "no vertex-data sentinel");
+    }
+}
diff --git a/src/draw/scene3d.wgsl b/src/draw/scene3d.wgsl
index 1d2bdf7..71068db 100644
--- a/src/draw/scene3d.wgsl
+++ b/src/draw/scene3d.wgsl
@@ -19,6 +19,11 @@ struct Uniforms {
     // smooth shading from vertex normals against the same world light):
     // skip the flat shading below so it is not applied twice.
     prelit: f32,
+    // 1 on a `SceneDraw::screen_space` draw (a pane's background quad): the
+    // vertex's xy are already NDC, so it skips the mvp and sits at the far
+    // plane, unlit. A per-draw flag, never read off the vertex data: the
+    // z = 9.99 sentinel it replaced caught real geometry on that plane.
+    screen_space: f32,
     // xyz: the direction TOWARD the light, in world space
     // (`VkRenderer::set_scene_light`). Unit length.
     light: vec4<f32>,
@@ -58,8 +63,8 @@ fn window_corner_distance(pos: vec2<f32>) -> f32 {
 struct VertexOutput {
     @builtin(position) position: vec4f,
     @location(0) color: vec3f,
-    // World-space position, for the flat-shading normal; `lit` is 0 on the
-    // screen-space background quad (the z=9.99 sentinel), 1 on scene geometry.
+    // World-space position, for the flat-shading normal; `lit` is 0 on a
+    // screen-space draw (the background quad), 1 on scene geometry.
     @location(1) world: vec3f,
     @location(2) lit: f32,
 };
@@ -77,7 +82,7 @@ fn vs_main(
 ) -> VertexOutput {
     var out: VertexOutput;
     let placed = position + instance_offset;
-    if (abs(position.z - 9.99) < 0.01) {
+    if (uniforms.screen_space > 0.5) {
         out.position = vec4f(position.xy, 0.9999, 1.0);
         out.lit = 0.0;
     } else {
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
index ee910d2..25a7c68 100644
--- a/src/vk/scene.rs
+++ b/src/vk/scene.rs
@@ -1,6 +1,6 @@
 //! 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
+//! Vertex3D meshes (scene3d.wgsl: mvp transform, `SceneDraw::screen_space`
+//! background quads, 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