Instancing
Ten thousand rocks in culled batches, and a batch rewritten every frame.
Starts examples/instancing in this page, on WebGPU where your browser has it.
Instancing is the chapter that walks through it.
From a checkout of the engine, npm run examples serves it at /instancing/.
Source
main.ts
examples/instancing/main.ts
/**
* Ten thousand rocks in sixteen batches, each culled as a whole and then rock by rock, and a ring of
* crystals whose batch is rewritten every frame.
*
* Instancing draws many copies of one mesh in one call with one material. The rocks never move, so
* they are uploaded once; the crystals move, so theirs are uploaded each frame, which allocates
* nothing because the arrays were sized once.
*/
import {
MeshBuilder,
SceneNode,
createEnvironment,
createMeshInstances,
hashToUnit,
mulberry32,
} from '@driftengine/core';
import type { InstancedHandle, MeshInstances } from '@driftengine/core';
import { openStage } from '../common/stage';
const stage = await openStage({
screenEffects: true,
sceneSamples: 4,
outputTransform: 'aces',
outputExposure: 1.3,
});
const { renderer, camera } = stage;
const ENV = createEnvironment({
directionalDir: [0.4, 0.75, 0.3],
directionalColor: [1.3, 1.2, 1.05],
ambient: [0.3, 0.34, 0.42],
ambientGround: [0.16, 0.13, 0.1],
nightFactor: 1,
emissiveGain: 1.5,
fogColor: [0.6, 0.66, 0.74],
fogDensity: 0.012,
});
// #region matrix
/** Write a model matrix that scales, turns about Y and moves, into slot `i` of a batch. */
function place(
out: Float32Array,
i: number,
x: number,
y: number,
z: number,
yaw: number,
s: number,
): void {
const o = i * 16;
const c = Math.cos(yaw) * s;
const n = Math.sin(yaw) * s;
out[o] = c;
out[o + 1] = 0;
out[o + 2] = -n;
out[o + 3] = 0;
out[o + 4] = 0;
out[o + 5] = s;
out[o + 6] = 0;
out[o + 7] = 0;
out[o + 8] = n;
out[o + 9] = 0;
out[o + 10] = c;
out[o + 11] = 0;
out[o + 12] = x;
out[o + 13] = y;
out[o + 14] = z;
out[o + 15] = 1;
}
// #endregion
// #region rocks
/** A lumpy rock: a sphere whose radius varies by a hash of where on it you are. */
const rock = renderer.createMesh(
new MeshBuilder()
.addBlob(
[0, 0.35, 0],
(u, v) => 0.5 + hashToUnit(Math.floor(u * 7) * 31 + Math.floor(v * 5)) * 0.18,
[0.55, 0.52, 0.48],
)
.build(),
);
const REGIONS = 4;
const PER_REGION = 625;
const SPAN = 120;
const random = mulberry32(7);
/** One batch per region, so a region out of view is skipped in one test. */
const fields: { batch: InstancedHandle; data: MeshInstances }[] = [];
for (let rx = 0; rx < REGIONS; rx += 1) {
for (let rz = 0; rz < REGIONS; rz += 1) {
const data = createMeshInstances(PER_REGION);
for (let i = 0; i < PER_REGION; i += 1) {
const x = (rx + random()) * (SPAN / REGIONS) - SPAN / 2;
const z = (rz + random()) * (SPAN / REGIONS) - SPAN / 2;
place(data.models, i, x, 0, z, random() * Math.PI * 2, 0.2 + random() * 0.8);
const shade = 0.42 + random() * 0.3;
data.tints.set([shade, shade * (0.95 + random() * 0.1), shade * 0.95], i * 3);
}
data.count = PER_REGION;
const batch = renderer.createInstanced(rock, PER_REGION, { cull: true });
renderer.uploadInstanced(batch, data);
fields.push({ batch, data });
}
}
// #endregion
// #region crystals
/** Crystals circling the middle: the same batch, rewritten in place every frame. */
const CRYSTALS = 240;
const crystal = renderer.createMesh(
new MeshBuilder().addCylinder([0, 0.6, 0], 0.18, 0.6, 'y', [0.55, 0.85, 1], 1.2, 6).build(),
);
const ring = createMeshInstances(CRYSTALS);
ring.count = CRYSTALS;
for (let i = 0; i < CRYSTALS; i += 1) ring.tints.set([1, 1, 1], i * 3);
const ringBatch = renderer.createInstanced(crystal, CRYSTALS);
function moveCrystals(time: number): void {
for (let i = 0; i < CRYSTALS; i += 1) {
const a = (i / CRYSTALS) * Math.PI * 2 + time * 0.2;
const r = 14 + Math.sin(i * 1.7 + time) * 2;
place(
ring.models,
i,
Math.cos(a) * r,
1 + Math.sin(time * 1.5 + i) * 0.8,
Math.sin(a) * r,
time + i,
1,
);
}
renderer.uploadInstanced(ringBatch, ring);
}
// #endregion
const ground = renderer.createMesh(
new MeshBuilder()
.addBox([0, -0.1, 0], [SPAN / 2 + 10, 0.1, SPAN / 2 + 10], [0.42, 0.45, 0.36])
.build(),
);
const still = new SceneNode();
still.updateWorld();
camera.fovYDeg = 55;
camera.far = 400;
let time = 0;
stage.run({
simulate(dt) {
time += dt;
},
// #region draw
render() {
camera.position[0] = Math.sin(time * 0.05) * 40;
camera.position[1] = 9;
camera.position[2] = Math.cos(time * 0.05) * 40;
camera.lookAt(0, 1, 0);
moveCrystals(time);
renderer.beginFrame([0.6, 0.66, 0.74]);
renderer.bindMeshPass(camera, ENV);
renderer.drawMesh(ground, still.worldMatrix);
for (let i = 0; i < fields.length; i += 1)
renderer.drawInstanced(fields[i].batch, fields[i].data);
renderer.drawInstanced(ringBatch, ring);
renderer.endFrame();
},
// #endregion
});