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”The document is SJON, the S-expression format pngine compiles: each form is
one WebGPU resource or operation, and the shader text inside
(shader-module …) is plain WGSL, WebGPU’s shading language.
A generated mesh
Section titled “A generated mesh”(data :name cubeVertices (cube :format [position3 normal3])) asks the
compiler to generate 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 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; the colour is three scalars rather than a second vec3f,
which would have forced 16-byte alignment padding and a different stride.
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].
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 …)).