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])

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.

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

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.

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

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