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

commit9771c92bc295ccc161e68a493251e7096b765b05
parentf9b0fa85de
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:54
feat(scene): a scene draw can be instanced

SceneDraw::instances names a mesh of instances: the draw's mesh is drawn
once per instance vertex, its position added and its colour multiplied.
Every mesh pipeline on both renderers takes a per-instance binding; a
draw without instances is drawn for one unit instance (origin, white),
which leaves its vertices bit for bit as they were. The probe draws a
row of instanced cubes. The WebGPU half is written but not built here
(no wasm32 target on this machine).

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

 CLAUDE.md                 | 18 ++++++++++++++
 examples/probe3d/scene.rs | 20 +++++++++++++---
 src/draw/scene.rs         | 17 ++++++++++++++
 src/draw/scene3d.wgsl     | 13 +++++++---
 src/vk/scene.rs           | 60 +++++++++++++++++++++++++++++++++++++++++------
 src/web/scene.rs          | 40 +++++++++++++++++++++++++++----
 6 files changed, 150 insertions(+), 18 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2968720..e57df27 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -202,6 +202,24 @@ image in the scene before the translucent draw, a host light, frost over the pan
 195 px differ by more than 8 levels, all on 1 px wires (where along its length a line
 steps a row is the rasterizer's), everything else within 2.
 
+**A scene draw can be instanced** (since 2026-10-06, `SceneDraw::instances`). A draw
+naming an instance mesh draws its `mesh` once per vertex of that mesh: an instance is a
+`Vertex3D` read as an offset added to every vertex and a colour multiplying theirs, so a
+white mesh takes each instance's colour. `scene3d.wgsl`'s vertex stage takes the instance
+at locations 2 and 3, and every mesh pipeline on both renderers has a second, per-instance
+vertex binding. A draw with `instances: None` is drawn for ONE instance at the origin in
+white (`draw::scene::UNIT_INSTANCE`, a 24-byte buffer each stage keeps), which leaves its
+vertices bit for bit as they were (`x + 0.0`, `c * 1.0`), so nothing that does not ask
+for instancing changed; the pipelines and their count are the same. An instance mesh with
+no vertices draws nothing. It exists for the designer's point markers, which were a
+240-vertex sphere copied to every point and uploaded whole each frame of a playing
+simulation — 46 MB at ten thousand points, where instanced they are 240 KB. The probe has
+a row of instanced cubes, and Vulkan draws it (2026-10-06, a shadow); the WebGPU half
+(`web/scene.rs`: the instance buffer layout with `GpuVertexStepMode::Instance`, slot 1,
+`draw_with_instance_count`) was written alongside and NOT built — this machine's
+toolchain has no wasm32 target — so `scripts/web-probe/probe3d` is the first thing to run
+on one that has.
+
 **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 0f460d8..b3cc62c 100644
--- a/examples/probe3d/scene.rs
+++ b/examples/probe3d/scene.rs
@@ -8,7 +8,8 @@
 //! 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, a light the host sets, and a frosted plate over the pane whose
+//! 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.
 
 use cce_ui::engine::{
@@ -50,6 +51,9 @@ struct Meshes {
     sphere_wires: MeshId,
     glass: MeshId,
     glass_wires: MeshId,
+    /// A small white cube, and the row of instances it is drawn for.
+    marker: MeshId,
+    marker_instances: MeshId,
 }
 
 fn r(x: f32, y: f32, w: f32, h: f32) -> Rect {
@@ -219,8 +223,16 @@ impl<const TRACE: bool> Application for Probe3d<TRACE> {
         let glass_c = Vec3::new(1.5, 0.1, 1.2);
         let glass = stage.create_mesh(&cuboid(glass_c, Vec3::splat(0.55), [[0.3, 0.7, 0.9]; 6]));
         let glass_wires = stage.create_mesh(&cuboid_edges(glass_c, Vec3::splat(0.55), [0.9, 0.95, 1.0]));
+        let marker = stage.create_mesh(&cuboid(Vec3::ZERO, Vec3::splat(0.12), [[1.0; 3]; 6]));
+        let row: Vec<Vertex3D> = (0..8)
+            .map(|i| {
+                let t = i as f32 / 7.0;
+                v(Vec3::new(-2.4 + 0.55 * i as f32, -0.58, 1.9), [0.9 - 0.6 * t, 0.4 + 0.4 * t, 0.3 + 0.6 * t])
+            })
+            .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 });
+        self.meshes = Some(Meshes { background, cube, prelit, sphere, sphere_wires, glass, glass_wires, marker, marker_instances });
         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 }));
@@ -252,11 +264,13 @@ impl<const TRACE: bool> Application for Probe3d<TRACE> {
             wire_base_width: 0.0,
             prelit: false,
             see_through: false,
+            instances: None,
         };
         let draws = vec![
             draw(m.background),
             SceneDraw { prelit: true, ..draw(m.prelit) },
             draw(m.cube),
+            SceneDraw { instances: Some(m.marker_instances), ..draw(m.marker) },
             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) },
@@ -268,7 +282,7 @@ impl<const TRACE: bool> Application for Probe3d<TRACE> {
             corners: IMAGE_CORNERS,
             mvp,
             opacity: 0.9,
-            before: 5,
+            before: 6,
         }]);
         false
     }
diff --git a/src/draw/scene.rs b/src/draw/scene.rs
index 7e7ff1a..c38c4b2 100644
--- a/src/draw/scene.rs
+++ b/src/draw/scene.rs
@@ -13,6 +13,8 @@
 //! backdrop, staged through the same trait.
 
 /// Layout-identical to the app's `geometry::Vertex3D` (bytemuck-castable at cutover).
+/// Also the layout of an INSTANCE (`SceneDraw::instances`): an offset and a
+/// colour.
 #[repr(C)]
 #[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
 pub struct Vertex3D {
@@ -71,6 +73,16 @@ pub struct SceneDraw {
     /// then order-dependent: the host should submit the triangles back to
     /// front for the current eye. False for an ordinary fill.
     pub see_through: bool,
+    /// Draw `mesh` once per vertex of this mesh — INSTANCED. An instance is
+    /// a [`Vertex3D`] read as where to put the mesh and what colour to give
+    /// it: its position is added to every vertex of `mesh` and its colour
+    /// multiplies theirs, so a white mesh takes each instance's colour. A
+    /// thousand markers are then one small mesh and a thousand instances,
+    /// where they were a thousand copies of the mesh's vertices uploaded
+    /// whole whenever one moved. An instance mesh with no vertices draws
+    /// 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>,
 }
 
 /// A user image standing in the 3D scene: a textured quad, unlit, depth
@@ -104,6 +116,11 @@ pub(crate) struct ImageVertex3D {
     uv: [f32; 2],
 }
 
+/// The instance a draw without instances is drawn with: at the origin, in
+/// white, which leaves every vertex as it is (`x + 0.0` and `c * 1.0` are
+/// exact).
+pub(crate) const UNIT_INSTANCE: Vertex3D = Vertex3D { position: [0.0; 3], color: [1.0; 3] };
+
 /// `scene3d.wgsl`'s uniform block (`scene3d_image.wgsl` reads its head).
 #[repr(C)]
 #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
diff --git a/src/draw/scene3d.wgsl b/src/draw/scene3d.wgsl
index eec9dd8..1d2bdf7 100644
--- a/src/draw/scene3d.wgsl
+++ b/src/draw/scene3d.wgsl
@@ -64,21 +64,28 @@ struct VertexOutput {
     @location(2) lit: f32,
 };
 
+// The vertex, and the instance it is drawn for (`SceneDraw::instances`):
+// the instance's offset is added to the vertex and its colour multiplies the
+// vertex's. A draw without instances is drawn for one at the origin in white,
+// which changes nothing.
 @vertex
 fn vs_main(
     @location(0) position: vec3f,
     @location(1) color: vec3f,
+    @location(2) instance_offset: vec3f,
+    @location(3) instance_color: vec3f,
 ) -> VertexOutput {
     var out: VertexOutput;
+    let placed = position + instance_offset;
     if (abs(position.z - 9.99) < 0.01) {
         out.position = vec4f(position.xy, 0.9999, 1.0);
         out.lit = 0.0;
     } else {
-        out.position = uniforms.mvp * vec4f(position, 1.0);
+        out.position = uniforms.mvp * vec4f(placed, 1.0);
         out.lit = 1.0;
     }
-    out.color = color;
-    out.world = position;
+    out.color = color * instance_color;
+    out.world = placed;
     return out;
 }
 
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
index ac41f32..ee910d2 100644
--- a/src/vk/scene.rs
+++ b/src/vk/scene.rs
@@ -17,7 +17,7 @@ use gpu_allocator::MemoryLocation;
 use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
 
 pub use crate::draw::scene::{MeshId, SceneDraw, SceneImage, Vertex3D};
-use crate::draw::scene::{image_quads_3d, scene_uniforms, wire_base_bias, ImageVertex3D, SceneUniforms, DEFAULT_SCENE_LIGHT};
+use crate::draw::scene::{image_quads_3d, scene_uniforms, wire_base_bias, ImageVertex3D, SceneUniforms, DEFAULT_SCENE_LIGHT, UNIT_INSTANCE};
 
 const UNIFORM_SIZE: vk::DeviceSize = std::mem::size_of::<SceneUniforms>() as vk::DeviceSize;
 
@@ -77,6 +77,9 @@ pub(crate) struct SceneStage {
     framebuffer: vk::Framebuffer,
 
     meshes: Vec<Mesh>,
+    /// The one instance a draw without instances is drawn with
+    /// ([`UNIT_INSTANCE`]), bound in binding 1 in place of an instance mesh.
+    unit_instance: AllocatedBuffer,
     frames: Vec<SceneFrame>,
     staged: Option<StagedScene>,
     /// True once the backdrop holds rendered content worth copying to screen.
@@ -204,10 +207,19 @@ impl SceneStage {
                     .module(shader_module)
                     .name(c"fs_main"),
             ];
-            let vertex_bindings = [vk::VertexInputBindingDescription::default()
-                .binding(0)
-                .stride(std::mem::size_of::<Vertex3D>() as u32)
-                .input_rate(vk::VertexInputRate::VERTEX)];
+            // Binding 0 the mesh's vertices, binding 1 the instances it is
+            // drawn for (`SceneDraw::instances`), both `Vertex3D`s: the
+            // instance's position is the offset, its colour the multiplier.
+            let vertex_bindings = [
+                vk::VertexInputBindingDescription::default()
+                    .binding(0)
+                    .stride(std::mem::size_of::<Vertex3D>() as u32)
+                    .input_rate(vk::VertexInputRate::VERTEX),
+                vk::VertexInputBindingDescription::default()
+                    .binding(1)
+                    .stride(std::mem::size_of::<Vertex3D>() as u32)
+                    .input_rate(vk::VertexInputRate::INSTANCE),
+            ];
             let vertex_attributes = [
                 vk::VertexInputAttributeDescription::default()
                     .location(0)
@@ -219,6 +231,16 @@ impl SceneStage {
                     .binding(0)
                     .format(vk::Format::R32G32B32_SFLOAT)
                     .offset(12),
+                vk::VertexInputAttributeDescription::default()
+                    .location(2)
+                    .binding(1)
+                    .format(vk::Format::R32G32B32_SFLOAT)
+                    .offset(0),
+                vk::VertexInputAttributeDescription::default()
+                    .location(3)
+                    .binding(1)
+                    .format(vk::Format::R32G32B32_SFLOAT)
+                    .offset(12),
             ];
             let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
                 .vertex_binding_descriptions(&vertex_bindings)
@@ -523,6 +545,15 @@ impl SceneStage {
             for frame in &frames {
                 Self::write_descriptor(device, frame);
             }
+            let mut unit_instance = create_cpu_buffer(
+                device,
+                allocator,
+                64,
+                vk::BufferUsageFlags::VERTEX_BUFFER,
+                "scene-unit-instance",
+            );
+            let unit: &[u8] = bytemuck::bytes_of(&UNIT_INSTANCE);
+            unit_instance.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()[..unit.len()].copy_from_slice(unit);
 
             let mut stage = SceneStage {
                 render_pass,
@@ -550,6 +581,7 @@ impl SceneStage {
                 depth_allocation: None,
                 framebuffer: vk::Framebuffer::null(),
                 meshes: Vec::new(),
+                unit_instance,
                 frames,
                 staged: None,
                 backdrop_valid: false,
@@ -995,6 +1027,18 @@ impl SceneStage {
                 if mesh.count == 0 {
                     continue;
                 }
+                // What it is drawn for: an instance mesh, or the one unit
+                // instance that leaves it as it is.
+                let (instance_buffer, instance_count) = match draw.instances {
+                    Some(id) => {
+                        let instances = &self.meshes[id.0];
+                        (instances.buffer.buffer, instances.count)
+                    }
+                    None => (self.unit_instance.buffer, 1),
+                };
+                if instance_count == 0 {
+                    continue;
+                }
                 let wanted = if draw.wireframe && draw.see_through {
                     self.wireframe_see_through_pipeline
                 } else if draw.wireframe {
@@ -1028,8 +1072,8 @@ impl SceneStage {
                     &[frame.descriptor_set],
                     &[(self.uniform_stride as u32) * i as u32],
                 );
-                device.cmd_bind_vertex_buffers(cmd, 0, &[mesh.buffer.buffer], &[0]);
-                device.cmd_draw(cmd, mesh.count, 1, 0, 0);
+                device.cmd_bind_vertex_buffers(cmd, 0, &[mesh.buffer.buffer, instance_buffer], &[0, 0]);
+                device.cmd_draw(cmd, mesh.count, instance_count, 0, 0);
             }
             draw_images(staged.draws.len(), &mut bound);
             device.cmd_end_render_pass(cmd);
@@ -1051,6 +1095,8 @@ impl SceneStage {
                 let mut buffer = std::mem::replace(&mut mesh.buffer, AllocatedBuffer::null());
                 destroy_cpu_buffer(device, allocator, &mut buffer);
             }
+            let mut unit = std::mem::replace(&mut self.unit_instance, AllocatedBuffer::null());
+            destroy_cpu_buffer(device, allocator, &mut unit);
             device.destroy_descriptor_pool(self.descriptor_pool, None);
             device.destroy_descriptor_set_layout(self.descriptor_set_layout, None);
             if let Some(p) = self.wireframe_pipeline.take() {
diff --git a/src/web/scene.rs b/src/web/scene.rs
index 2b38b2b..d516a21 100644
--- a/src/web/scene.rs
+++ b/src/web/scene.rs
@@ -27,13 +27,13 @@ use web_sys::{
     GpuCullMode, GpuDepthStencilState, GpuDevice, GpuFragmentState, GpuFrontFace, GpuLoadOp, GpuPipelineLayoutDescriptor,
     GpuPrimitiveState, GpuPrimitiveTopology, GpuQueue, GpuRenderPassColorAttachment, GpuRenderPassDepthStencilAttachment,
     GpuRenderPassDescriptor, GpuRenderPipeline, GpuRenderPipelineDescriptor, GpuStoreOp, GpuTexture, GpuTextureFormat,
-    GpuTextureView, GpuVertexAttribute, GpuVertexBufferLayout, GpuVertexFormat, GpuVertexState,
+    GpuTextureView, GpuVertexAttribute, GpuVertexBufferLayout, GpuVertexFormat, GpuVertexState, GpuVertexStepMode,
 };
 
 use super::renderer::{alpha_blending, shader_module, texture, whole_view, Growable};
 use crate::draw::scene::{
     image_quads_3d, scene_uniforms, wire_base_bias, ImageVertex3D, MeshId, SceneDraw, SceneImage, SceneUniforms, Vertex3D,
-    DEFAULT_SCENE_LIGHT,
+    DEFAULT_SCENE_LIGHT, UNIT_INSTANCE,
 };
 use crate::draw::shaders::{SCENE3D, SCENE3D_IMAGE};
 
@@ -74,6 +74,10 @@ pub(crate) struct WebScene {
     uniforms: Growable,
     uniform_group: GpuBindGroup,
     image_verts: Growable,
+    /// The one instance a draw without instances is drawn with
+    /// ([`UNIT_INSTANCE`]), in slot 1 in place of an instance mesh; written
+    /// at every record, being 24 bytes and `new` having no queue.
+    unit_instance: Growable,
     meshes: Vec<Mesh>,
     staged: Option<Staged>,
     pub(crate) light: [f32; 3],
@@ -112,8 +116,18 @@ impl WebScene {
             GpuVertexAttribute::new(GpuVertexFormat::Float32x3, 0, 0),
             GpuVertexAttribute::new(GpuVertexFormat::Float32x3, 12, 1),
         ];
-        let mesh_buffers =
-            [js_sys::JsOption::wrap(GpuVertexBufferLayout::new(std::mem::size_of::<Vertex3D>() as u32, &mesh_attrs))];
+        // Slot 1: the instances a mesh is drawn for (`SceneDraw::instances`),
+        // `Vertex3D`s read as an offset and a colour multiplier.
+        let instance_attrs = [
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x3, 0, 2),
+            GpuVertexAttribute::new(GpuVertexFormat::Float32x3, 12, 3),
+        ];
+        let instance_layout = GpuVertexBufferLayout::new(std::mem::size_of::<Vertex3D>() as u32, &instance_attrs);
+        instance_layout.set_step_mode(GpuVertexStepMode::Instance);
+        let mesh_buffers = [
+            js_sys::JsOption::wrap(GpuVertexBufferLayout::new(std::mem::size_of::<Vertex3D>() as u32, &mesh_attrs)),
+            js_sys::JsOption::wrap(instance_layout),
+        ];
         let target = GpuColorTargetState::new(format);
         target.set_blend(&alpha_blending());
         let targets = [js_sys::JsOption::wrap(target)];
@@ -177,6 +191,7 @@ impl WebScene {
         let uniforms = Growable::new(device, STRIDE * 16, buffer_usage::UNIFORM, "scene-uniforms")?;
         let uniform_group = uniform_group(device, &uniform_layout, &uniforms);
         let image_verts = Growable::new(device, 1024, buffer_usage::VERTEX, "scene-image-quads")?;
+        let unit_instance = Growable::new(device, 64, buffer_usage::VERTEX, "scene-unit-instance")?;
         let l = glam::Vec3::from_array(DEFAULT_SCENE_LIGHT).normalize().to_array();
         Ok(Self {
             format,
@@ -187,6 +202,7 @@ impl WebScene {
             uniforms,
             uniform_group,
             image_verts,
+            unit_instance,
             meshes: Vec::new(),
             staged: None,
             light: l,
@@ -278,6 +294,7 @@ impl WebScene {
         let quad_bytes: &[u8] = bytemuck::cast_slice(&quads);
         self.image_verts.ensure(device, quad_bytes.len() as u32)?;
         self.image_verts.write(queue, quad_bytes)?;
+        self.unit_instance.write(queue, bytemuck::bytes_of(&UNIT_INSTANCE))?;
 
         let color = GpuRenderPassColorAttachment::new_with_gpu_texture_view(GpuLoadOp::Clear, GpuStoreOp::Store, &target.view);
         color.set_clear_value(&[0.0, 0.0, 0.0, 0.0].map(js_sys::Number::from));
@@ -321,6 +338,18 @@ impl WebScene {
             if mesh.count == 0 {
                 continue;
             }
+            // What it is drawn for: an instance mesh, or the one unit
+            // instance that leaves it as it is.
+            let (instances, instance_count) = match draw.instances {
+                Some(id) => match self.meshes.get(id.0) {
+                    Some(m) => (&m.buffer.buffer, m.count),
+                    None => continue,
+                },
+                None => (&self.unit_instance.buffer, 1),
+            };
+            if instance_count == 0 {
+                continue;
+            }
             let pipeline = if draw.wireframe {
                 &self.lines[draw.see_through as usize]
             } else {
@@ -329,7 +358,8 @@ impl WebScene {
             pass.set_pipeline(pipeline);
             pass.set_bind_group_with_u32_slice_and_u32_and_dynamic_offsets_data_length(0, Some(&self.uniform_group), &offset(i), 0, 1)?;
             pass.set_vertex_buffer_with_u32(0, Some(&mesh.buffer.buffer), 0);
-            pass.draw(mesh.count);
+            pass.set_vertex_buffer_with_u32(1, Some(instances), 0);
+            pass.draw_with_instance_count(mesh.count, instance_count);
         }
         draw_images(staged.draws.len())?;
         pass.end();