Multiple objects
Eight cubes from one mesh and one draw call, each with its own position,
size, spin offset and colour, lit by a fixed directional light and sorted by
a depth buffer so nearer cubes hide farther ones. The mesh comes from
pngine’s built-in cube generator rather than a hand-typed array; the
per-instance records are seeded on the GPU by a one-shot compute pass.
; Multiple 3D objects: eight lit cubes of varying size arranged in a ring,; each spinning while the ring as a whole orbits; depth-tested with back-face; culling. A compute `(init …)` fills the per-instance buffer once (position,; scale, rotation offset, rainbow colour: 8 floats / 32 bytes each). Exercises; the `cube` shape generator (position3 normal3), a two-buffer vertex layout; with `:step-mode instance`, an expression-valued `:instance-count`, and a; depth24plus texture sized `canvas` so it always matches the colour target.
(define :name NUM_OBJECTS :value 8)
(data :name cubeVertices (cube :format [position3 normal3]))
(buffer :name vertexBuffer :usage [vertex] :data cubeVertices)
(buffer :name instanceBuffer :size (* NUM_OBJECTS 8 4) :usage [vertex storage])
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(init :name initObjects :buffer instanceBuffer :module initShader :workgroups [1])
(texture :name depthTexture :format depth24plus :size canvas :usage [render-attachment])
(shader-module :name renderShader :code """ struct Uniforms { time: f32, width: f32, height: f32, aspect: f32, } @group(0) @binding(0) var<uniform> u: Uniforms;
struct VertexOutput { @builtin(position) pos: vec4f, @location(0) normal: vec3f, @location(1) color: vec3f, }
fn rotateY(p: vec3f, angle: f32) -> vec3f { let c = cos(angle); let s = sin(angle); return vec3f(p.x * c + p.z * s, p.y, -p.x * s + p.z * c); }
fn rotateX(p: vec3f, angle: f32) -> vec3f { let c = cos(angle); let s = sin(angle); return vec3f(p.x, p.y * c - p.z * s, p.y * s + p.z * c); }
@vertex fn vs( @location(0) position: vec3f, @location(1) normal: vec3f, @location(2) instPos: vec3f, @location(3) instScale: f32, @location(4) rotOffset: f32, @location(5) color: vec3f ) -> VertexOutput { let t = u.time;
// Apply instance transform var pos = position * instScale; var norm = normal;
// Rotate each cube let rotAngle = t * 0.5 + rotOffset; pos = rotateY(pos, rotAngle); pos = rotateX(pos, rotAngle * 0.7); norm = rotateY(norm, rotAngle); norm = rotateX(norm, rotAngle * 0.7);
// Add instance position with orbiting motion let orbitAngle = t * 0.3; var worldPos = pos + instPos; worldPos = rotateY(worldPos, orbitAngle);
// Simple perspective let z = worldPos.z + 3.0; let projX = worldPos.x / z / u.aspect; let projY = worldPos.y / z; let depth = 1.0 - (z / 6.0);
var out: VertexOutput; out.pos = vec4f(projX, projY, depth, 1.0); out.normal = norm; out.color = color; return out; }
@fragment fn fs(in: VertexOutput) -> @location(0) vec4f { // Simple lighting let lightDir = normalize(vec3f(0.5, 1.0, 0.3)); let diffuse = max(dot(normalize(in.normal), lightDir), 0.0); let ambient = 0.2;
let lit = in.color * (ambient + diffuse * 0.8); return vec4f(lit, 1.0); } """)
(shader-module :name initShader :code """ struct Instance { position: vec3f, scale: f32, rotationOffset: f32, colorR: f32, colorG: f32, colorB: f32, } struct Instances { data: array<Instance> }
@binding(0) @group(0) var<storage, read_write> instances: Instances;
fn hash(n: u32) -> f32 { var x = n; x = ((x >> 16u) ^ x) * 0x45d9f3bu; x = ((x >> 16u) ^ x) * 0x45d9f3bu; x = (x >> 16u) ^ x; return f32(x) / f32(0xffffffffu); }
const PI: f32 = 3.14159265359; const NUM: u32 = 8u;
@compute @workgroup_size(8) fn main(@builtin(global_invocation_id) id: vec3u) { let i = id.x; if (i >= NUM) { return; }
let fi = f32(i);
// Arrange in a circle let angle = fi * PI * 2.0 / f32(NUM); let radius = 0.8;
instances.data[i].position = vec3f( cos(angle) * radius, sin(angle * 2.0) * 0.2, sin(angle) * radius - 1.5 ); instances.data[i].scale = 0.15 + hash(i * 7u) * 0.1; instances.data[i].rotationOffset = fi * 0.5;
// Rainbow colors let hue = fi / f32(NUM); instances.data[i].colorR = 0.5 + 0.5 * cos(hue * 6.28); instances.data[i].colorG = 0.5 + 0.5 * cos(hue * 6.28 + 2.09); instances.data[i].colorB = 0.5 + 0.5 * cos(hue * 6.28 + 4.19); } """)
(render-pipeline :name pipeline :layout auto (vertex :module renderShader :entry vs (vertex-buffer :array-stride 24 :step-mode vertex (attribute :shader-location 0 :offset 0 :format float32x3) (attribute :shader-location 1 :offset 12 :format float32x3)) (vertex-buffer :array-stride 32 :step-mode instance (attribute :shader-location 2 :offset 0 :format float32x3) (attribute :shader-location 3 :offset 12 :format float32) (attribute :shader-location 4 :offset 16 :format float32) (attribute :shader-location 5 :offset 20 :format float32x3))) (fragment :module renderShader :entry fs (target :format preferred-canvas-format)) (primitive :cull-mode back) (depth-stencil :format depth24plus :depth-write-enabled true :depth-compare less))
(bind-group :name bindings :layout pipeline :group 0 (entry :binding 0 :buffer uniforms))
(render-pass :name mainPass (color-attachment :view context-current-texture :clear-value [0.05 0.05 0.1 1] :load-op clear :store-op store) (depth-stencil-attachment :view depthTexture :depth-clear-value 1.0 :depth-load-op clear :depth-store-op store) :pipeline pipeline :vertex-buffers [vertexBuffer instanceBuffer] :bind-groups [bindings] (draw :vertex-count 36 :instance-count NUM_OBJECTS))
(frame :name main :init [initObjects] :perform [writeUniforms mainPass])examples/samples/09_multiple_objects.sjon in the pngine repository.
How it works
Section titled “How it works”A generated mesh
Section titled “A generated mesh”One form of the document’s SJON (the S-expression format pngine compiles;
each form is one WebGPU resource or operation) asks the compiler for
geometry instead of typing it out:
(data :name cubeVertices (cube :format [position3 normal3])) generates a
unit cube at build time: 36 vertices (12 triangles),
each a vec3f position followed by a vec3f normal, 24 bytes per vertex.
vertexBuffer is created from it with :data, which fills the buffer at
creation and sizes it, and the pipeline’s first (vertex-buffer …) reads it
at :array-stride 24 into @location(0) and @location(1).
(draw :vertex-count 36 :instance-count NUM_OBJECTS) matches the generator’s
count.
Per-instance records from a compute pass
Section titled “Per-instance records from a compute pass”instanceBuffer is NUM_OBJECTS × 8 floats with [vertex storage]
usage; (init :name initObjects :buffer instanceBuffer :module initShader :workgroups [1])
runs initShader (a compute shader in WGSL, WebGPU’s shading language)
once, before the first frame, writing an Instance struct per cube: a position on a ring of radius 0.8 pushed back by 1.5, a hashed
scale, a rotation offset and a rainbow colour. The pipeline’s second
(vertex-buffer …) is :step-mode instance at a 32-byte stride, so
@location(2) to @location(5) change once per cube.
The WGSL Instance struct packs a vec3f position and five scalars into
exactly 32 bytes, and the two sides describe those bytes differently on
purpose:
one Instance record, 32 bytes (:array-stride 32)
byte 0 4 8 12 16 20 24 28 32 +------+------+------+------+------+------+------+------+ |pos.x |pos.y |pos.z |scale | rot | r | g | b | +------+------+------+------+------+------+------+------+written as '---- vec3f ------' f32 f32 f32 f32 f32read as float32x3 :0 f32 :12 f32 :16 float32x3 :20The compute side writes the colour as three scalars because a vec3f
member would align to 16 and pad the struct to 48 bytes; the vertex side
reads the same twelve bytes back as one float32x3, because attributes
have offsets, not alignments.
Depth and culling
Section titled “Depth and culling”(texture :name depthTexture :format depth24plus :size canvas :usage [render-attachment])
is the depth buffer; :size canvas keeps it the same size as the colour
target when the canvas resizes. The pipeline’s
(depth-stencil :format depth24plus :depth-write-enabled true :depth-compare less)
turns on the test and the write, and (primitive :cull-mode back) skips
faces whose winding says they point away from the camera, roughly halving
the fragment work on closed cubes. The pass attaches the texture with
(depth-stencil-attachment :view depthTexture :depth-clear-value 1.0 :depth-load-op clear :depth-store-op store).
vs writes depth = 1 - z / 6 into position.z: with less as the
compare function, smaller means nearer, and this maps the visible range
into [0, 1].
An exercise: change :depth-compare less to always and recompile. Every
fragment now wins, so the cube drawn last paints over nearer ones, and the
overlaps flicker between right and wrong as the ring orbits: the depth
test’s job, shown by taking it away.
Transform and lighting
Section titled “Transform and lighting”vs scales the cube, spins it about y and x by t · 0.5 + rotOffset
(rotating the normal the same way), adds the instance position, orbits the
whole ring about y by t · 0.3, and projects with a manual perspective
divide (z + 3, x / z / aspect). fs does Lambert shading:
ambient + 0.8 · max(dot(N, L), 0) with a fixed light direction, times the
instance colour.
In the specifications
Section titled “In the specifications”| What the sample uses | WebGPU | WGSL |
|---|---|---|
| Depth testing | depth/stencil state, depthCompare, GPUCompareFunction, depth/stencil attachments |
|
| The depth texture | texture creation, depth formats, "depth24plus" |
|
| Back-face culling | primitive state, cullMode, "back" |
|
| Instanced draw from two buffers | draw(), GPUVertexStepMode, vertex state |
@location inputs |
| One-shot compute seeding | compute pipelines | @compute, storage address space, structure member layout |
| Lighting | normalize, dot, max |
Related
Section titled “Related”- Simple lighting is one cube with the fuller Blinn-Phong model; Instanced trees instances a hand-authored mesh 400 times.
- Upstream: the WebGPU Samples rotatingCube and instancedCube samples cover the same ground with matrices from JavaScript.
- Forms:
(data …)(shape generators),(texture …)(:size canvas),(render-pipeline …)((depth-stencil …),(primitive …)),(render-pass …)((depth-stencil-attachment …)).