GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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