Instanced trees
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.
; 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])examples/samples/16_instanced_trees.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 hand-authored mesh
Section titled “A hand-authored mesh”(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).
400 instances from a compute pass
Section titled “400 instances from a compute pass”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.
Wind and depth
Section titled “Wind and depth”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 rest
Section titled “The rest”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.
In the specifications
Section titled “In the specifications”| 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 |
Related
Section titled “Related”- Multiple triangles is the same instancing pattern in 2D; Multiple objects adds lighting and culling.
- Forms:
(data …),(init …),(texture …),(render-pipeline …).