Skip to content

Multiple objects

Download PNG

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.

examples/samples/09_multiple_objects.sjon
; 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])

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.

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 f32
read as float32x3 :0 f32 :12 f32 :16 float32x3 :20

The 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.

(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.

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.

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