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