Skip to content

Instanced trees

Download PNG

A forest from one draw call. The tree is twelve vertices written by hand in the document, a brown trunk quad and two green triangles; 400 instance records (position, scale, rotation, tint) are seeded once on the GPU; the vertex shader places each copy, tints its foliage from the record, and sways the upper vertices with a wind term. A depth buffer sorts near trees in front of far ones against a sky-blue clear.

examples/samples/16_instanced_trees.sjon
; Instanced trees: 400 low-poly trees (a brown trunk quad plus two foliage
; triangles) scattered over a ground plane, swaying in the wind, depth-tested.
; A compute `(init …)` fills the per-instance buffer once (position, scale,
; rotation, green tint), read by the render pass at a 32-byte instance stride.
; The tree mesh is a hand-authored 12-vertex float array uploaded
; a `:data` fill; the pass draws it with a two-buffer vertex layout
; (`:step-mode instance`) and an expression-valued `:instance-count`. The
; depth24plus texture is sized `canvas` so it always matches the colour target.
; No `(primitive …)` form: the triangle-list defaults are what it needs.
(define :name NUM_TREES :value 400)
; Tree mesh: trunk quad (6 verts, brown) + two foliage triangles (6 verts,
; zeroed color → the vertex shader substitutes the per-instance tint).
; 12 vertices × (localPos: vec3 + localColor: vec3), arrayStride 24.
(data :name treeMesh :float32 [
-0.05 0.0 0.0 0.6 0.3 0.1
0.05 0.0 0.0 0.6 0.3 0.1
0.05 0.4 0.0 0.5 0.25 0.1
-0.05 0.0 0.0 0.6 0.3 0.1
0.05 0.4 0.0 0.5 0.25 0.1
-0.05 0.4 0.0 0.5 0.25 0.1
-0.2 0.3 0.0 0.0 0.0 0.0
0.2 0.3 0.0 0.0 0.0 0.0
0.0 0.8 0.0 0.0 0.0 0.0
-0.15 0.45 0.0 0.0 0.0 0.0
0.15 0.45 0.0 0.0 0.0 0.0
0.0 0.95 0.0 0.0 0.0 0.0
])
(buffer :name treeBuffer :usage [vertex]
:data treeMesh)
(buffer :name instanceBuffer :size (* NUM_TREES 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 initTrees :buffer instanceBuffer :module initShader :workgroups [7])
(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) color: vec3f,
}
@vertex
fn vs(
@location(0) localPos: vec3f,
@location(1) localColor: vec3f,
@location(2) instPos: vec3f,
@location(3) instScale: f32,
@location(4) instRotation: f32,
@location(5) instColorTint: vec3f
) -> VertexOutput {
// Apply instance transform
let c = cos(instRotation);
let s = sin(instRotation);
var pos = localPos * instScale;
// Rotate around Y axis
let rx = pos.x * c - pos.z * s;
let rz = pos.x * s + pos.z * c;
pos = vec3f(rx, pos.y, rz);
pos += instPos;
// Wind sway for foliage (vertices above y=0.3)
if (localPos.y > 0.25) {
let sway = sin(u.time * 2.0 + instPos.x * 3.0) * 0.02 * (localPos.y - 0.25);
pos.x += sway;
}
// Simple perspective projection
let z = pos.z + 3.0;
var out: VertexOutput;
// A manual divide has no near plane: a vertex at or just in front of
// the camera would project to a huge or inverted triangle. Send those
// outside the clip volume (z > w) so the rasterizer drops them.
if (z < 0.1) {
out.pos = vec4f(0.0, 0.0, 2.0, 1.0);
out.color = vec3f(0.0);
return out;
}
let projX = pos.x / z / u.aspect;
let projY = pos.y / z;
let depth = 1.0 - (z / 6.0);
out.pos = vec4f(projX, projY, depth, 1.0);
// Use local color for trunk, instance tint for foliage
if (length(localColor) > 0.1) {
out.color = localColor; // Trunk
} else {
out.color = instColorTint; // Foliage
}
return out;
}
@fragment
fn fs(in: VertexOutput) -> @location(0) vec4f {
return vec4f(in.color, 1.0);
}
""")
(shader-module :name initShader :code """
// 32 bytes, matching the render pipeline's instance layout (offsets 0,
// 12, 16, 20). The tint is three scalars on purpose: a `vec3f` member
// here would align to 16 and push the struct to 48 bytes, so the vertex
// stage would read padding as the tint and every third record as garbage.
struct Tree {
pos: vec3f,
scale: f32,
rotation: f32,
tintR: f32,
tintG: f32,
tintB: f32,
}
struct Trees { data: array<Tree> }
@binding(0) @group(0) var<storage, read_write> trees: Trees;
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 = 400u;
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; }
// Random position in a large area
let x = (hash(i * 7u) - 0.5) * 4.0;
let z = (hash(i * 11u) - 0.5) * 4.0 - 2.0; // Offset back
let y = -0.5; // Ground level
trees.data[i].pos = vec3f(x, y, z);
trees.data[i].scale = 0.1 + hash(i * 13u) * 0.15;
trees.data[i].rotation = hash(i * 17u) * PI * 2.0;
// Green tint variations
trees.data[i].tintR = 0.2 + hash(i * 19u) * 0.2;
trees.data[i].tintG = 0.5 + hash(i * 23u) * 0.3;
trees.data[i].tintB = 0.1 + hash(i * 29u) * 0.15;
}
""")
(render-pipeline :name renderPipeline
: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))
(depth-stencil :format depth24plus :depth-write-enabled true :depth-compare less))
(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.5 0.7 0.9 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 renderPipeline
:vertex-buffers [treeBuffer instanceBuffer]
:bind-groups [uniformsBindGroup]
(draw :vertex-count 12 :instance-count NUM_TREES))
(frame :name main :init [initTrees] :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 treeMesh :float32 [ … ]) is twelve rows of x y z r g b: six vertices for the trunk quad (two triangles, brown) and six for two foliage triangles whose colour is zeroed. That zero is a signal: in vs, if (length(localColor) > 0.1) keeps the trunk’s own colour and otherwise substitutes the instance’s green tint, so one mesh serves 400 differently coloured trees. treeBuffer is filled at creation from it with :data, which also gives the buffer its size, and the pipeline’s first (vertex-buffer …) reads it at :array-stride 24 into @location(0) and @location(1).

instanceBuffer is NUM_TREES × 8 floats with [vertex storage] usage. (init :name initTrees :buffer instanceBuffer :module initShader :workgroups [7]) runs initShader once, before the first frame: 7 workgroups × 64 threads = 448 slots, and the shader returns early past index 399. Each record is a hashed position on the ground plane (y = -0.5, pushed back), a scale, a rotation and a green tint with hashed variation. The pipeline’s second (vertex-buffer …) is :step-mode instance at a 32-byte stride, @location(2) to @location(5) at offsets 0, 12, 16 and 20, and the WGSL Tree struct lays out to exactly those bytes: pos: vec3f, scale, rotation, then the tint as three scalars. That last choice is deliberate and the comment in the shader says why: a vec3f tint would align to 16, pad the struct to 48 bytes, and the vertex stage would read padding as the colour and every third record as garbage. When a compute shader writes what a vertex layout reads, the two descriptions of the bytes have to agree. (draw :vertex-count 12 :instance-count NUM_TREES) draws all 4800 vertices at once.

vs scales, rotates about y and translates each vertex by its instance record, then, for vertices with localPos.y > 0.25 (the foliage), adds a sideways sway sin(2t + 3x) · 0.02 · (y - 0.25) that grows with height, so the crowns wave and the trunks stand still. It projects with a manual perspective divide (z + 3, x / z / aspect) and writes 1 - z / 6 as the depth. A hand-rolled divide has no near plane, and about a quarter of the trees stand at or behind the camera; a vertex there would project to a huge or inverted triangle, so vs sends any vertex with z < 0.1 to (0, 0, 2, 1), outside the clip volume, and the rasterizer drops it. (depth-stencil :format depth24plus :depth-write-enabled true :depth-compare less) and the canvas-sized depthTexture do the sorting; no culling is set, since a triangle seen from behind should still count as a tree.

The uniform buffer refilled every frame from pngine-inputs (the built-in time/width/height/aspect source), the bind group and the frame with its :init list follow the pattern of Multiple triangles.

What the sample uses WebGPU WGSL
Instanced draw from two buffers draw(), GPUVertexStepMode, vertex state @location inputs
Depth testing without culling depth/stencil state, depth/stencil attachments, GPUCullMode
One-shot compute seeding compute pipelines, dispatchWorkgroups() @workgroup_size, global_invocation_id, structure member layout
Buffer with two usages buffer usage, mappedAtCreation storage address space
Near-plane guard primitive clipping, clip space coordinates return statement
Wind and colour selection sin, length, if statement