Skip to content

Multiple triangles

Download PNG

One triangle, drawn 64 times in a single draw call. The mesh is three vertices in one buffer; a second buffer holds one record per instance (position, scale, rotation, colour), and the pipeline declares that its attributes advance per instance rather than per vertex. That second buffer is never written from the document: a compute shader fills it once, before the first frame, so the layout logic lives on the GPU too. Each triangle spins at a rate tied to its size.

examples/samples/06_multiple_triangles.sjon
; Multiple triangles: 64 instanced triangles on a jittered 8×8 grid, each with
; its own scale, rotation and rainbow colour, spinning at a rate tied to its
; size. A compute `(init …)` seeds the instance buffer once; the render pass
; draws it instanced from a per-vertex triangle buffer plus a per-instance
; buffer (`:step-mode instance`), animated by a pngine-inputs uniform.
; No `(primitive …)` form: the defaults are what it needs.
(define :name NUM_TRIANGLES :value 64)
(shader-module :name initShader :code """
struct Triangle {
pos: vec2f,
scale: f32,
rotation: f32,
color: vec3f,
pad: f32,
}
struct Triangles { data: array<Triangle> }
@binding(0) @group(0) var<storage, read_write> triangles: Triangles;
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 NUM: u32 = 64u;
const PI: f32 = 3.14159265359;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= NUM) { return; }
// Grid layout with jitter
let cols = 8u;
let row = i / cols;
let col = i % cols;
let baseX = (f32(col) / f32(cols - 1u)) * 1.6 - 0.8;
let baseY = (f32(row) / f32(cols - 1u)) * 1.6 - 0.8;
triangles.data[i].pos = vec2f(
baseX + (hash(i * 7u) - 0.5) * 0.1,
baseY + (hash(i * 11u) - 0.5) * 0.1
);
triangles.data[i].scale = 0.05 + hash(i * 13u) * 0.05;
triangles.data[i].rotation = hash(i * 17u) * PI * 2.0;
// Rainbow colors
let hue = f32(i) / f32(NUM);
let h = hue * 6.0;
let c = 0.8;
let x = c * (1.0 - abs(fract(h / 2.0) * 2.0 - 1.0));
var rgb = vec3f(0.0);
let hi = u32(h) % 6u;
if (hi == 0u) { rgb = vec3f(c, x, 0.0); }
else if (hi == 1u) { rgb = vec3f(x, c, 0.0); }
else if (hi == 2u) { rgb = vec3f(0.0, c, x); }
else if (hi == 3u) { rgb = vec3f(0.0, x, c); }
else if (hi == 4u) { rgb = vec3f(x, 0.0, c); }
else { rgb = vec3f(c, 0.0, x); }
triangles.data[i].color = rgb + 0.2;
triangles.data[i].pad = 0.0;
}
""")
(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) color: vec3f,
}
@vertex
fn vs(
@location(0) vertex: vec2f,
@location(1) instPos: vec2f,
@location(2) instScale: f32,
@location(3) instRotation: f32,
@location(4) instColor: vec3f,
@location(5) pad: f32
) -> VertexOutput {
// Animate rotation
let angle = instRotation + u.time * (0.5 + instScale * 2.0);
let c = cos(angle);
let s = sin(angle);
var pos = vertex * instScale;
pos = vec2f(pos.x * c - pos.y * s, pos.x * s + pos.y * c);
pos += instPos;
// Aspect ratio correction
pos.x /= u.aspect;
var out: VertexOutput;
out.pos = vec4f(pos, 0.0, 1.0);
out.color = instColor;
return out;
}
@fragment
fn fs(in: VertexOutput) -> @location(0) vec4f {
return vec4f(in.color, 1.0);
}
""")
; Triangle vertices (one equilateral triangle, drawn per instance).
(data :name vertexData :float32 [
0.0 1.0
-0.866 -0.5
0.866 -0.5
])
(buffer :name vertexBuffer :usage [vertex]
:data vertexData)
; Instance data: pos(2) scale(1) rotation(1) color(3) pad(1) = 8 floats (32
; bytes) × 64 triangles. Seeded by the init compute shader (storage), consumed
; as an instance-step vertex buffer.
(buffer :name instanceBuffer :size (* NUM_TRIANGLES 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 initTriangles :buffer instanceBuffer :module initShader :workgroups [1])
(render-pipeline :name renderPipeline
:layout auto
(vertex :module renderShader :entry vs
(vertex-buffer :array-stride 8 :step-mode vertex
(attribute :shader-location 0 :offset 0 :format float32x2))
(vertex-buffer :array-stride 32 :step-mode instance
(attribute :shader-location 1 :offset 0 :format float32x2)
(attribute :shader-location 2 :offset 8 :format float32)
(attribute :shader-location 3 :offset 12 :format float32)
(attribute :shader-location 4 :offset 16 :format float32x3)
(attribute :shader-location 5 :offset 28 :format float32)))
(fragment :module renderShader :entry fs
(target :format preferred-canvas-format)))
(bind-group :name uniformsBindGroup :layout renderPipeline :group 0
(entry :binding 0 :buffer uniforms))
(render-pass :name mainPass
(color-attachment :view context-current-texture :clear-value [0.08 0.08 0.12 1] :load-op clear :store-op store)
:pipeline renderPipeline
:vertex-buffers [vertexBuffer instanceBuffer]
:bind-groups [uniformsBindGroup]
(draw :vertex-count 3 :instance-count NUM_TRIANGLES))
(frame :name main :init [initTriangles] :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.

The (vertex …) stage lists two (vertex-buffer …) layouts. The first, (vertex-buffer :array-stride 8 :step-mode vertex …), is the triangle: float32x2 positions, one per vertex. The second, (vertex-buffer :array-stride 32 :step-mode instance …), is the instance record, five attributes at offsets 0, 8, 12, 16 and 28 filling @location(1) to @location(5). With :step-mode instance, WebGPU advances that buffer once per instance instead of once per vertex, so all three vertices of triangle i see instance i’s record. (draw :vertex-count 3 :instance-count NUM_TRIANGLES) then draws 3 × 64 vertices; NUM_TRIANGLES is a (define …) constant, usable bare in any numeric slot.

(buffer :name instanceBuffer :size (* NUM_TRIANGLES 8 4) :usage [vertex storage]) has both usages: STORAGE so a compute shader can write it, VERTEX so the render pass can read it. (init :name initTriangles :buffer instanceBuffer :module initShader :workgroups [1]) is sugar for a compute pipeline, bind group and pass that run once; (frame :name main :init [initTriangles] …) places them before the first frame. initShader writes a Triangle struct per thread: an 8×8 grid position with a hashed jitter, a hashed scale and rotation, and a rainbow colour from the index. One workgroup of 64 threads covers all 64 records.

The WGSL struct Triangle { pos: vec2f, scale: f32, rotation: f32, color: vec3f, pad: f32 } lays out to 32 bytes (vec3f aligns to 16), which is why the vertex layout says :array-stride 32 and puts color at offset 16: the two descriptions of the same bytes have to agree.

vs rotates the vertex by instRotation + time * (0.5 + 2 · instScale), so big triangles spin faster, scales and offsets it, and divides x by the aspect ratio from pngine-inputs (the built-in time/width/height/aspect source, written to the uniform buffer each frame) so the triangles stay equilateral on the wide canvas.

What the sample uses WebGPU WGSL
Instanced drawing draw(vertexCount, instanceCount), GPUVertexStepMode, "instance" @location inputs
Two vertex buffer layouts vertex state, GPUVertexBufferLayout, GPUVertexAttribute structure member layout, alignment and size
A buffer that is both storage and vertex buffer usage, STORAGE, VERTEX storage address space, runtime-sized arrays
The one-shot compute fill compute pipelines, dispatchWorkgroups() @compute, @workgroup_size, global_invocation_id
Hashing and colour bit expressions (>>, ^), integer types, fract, abs