Worlds from a kit
A street built from copies of three pieces, as placements and expanded, with rules in a script.
Starts examples/kit in this page, on WebGPU where your browser has it.
Worlds from a kit is the chapter that walks through it.
From a checkout of the engine, npm run examples serves it at /kit/.
Source
main.ts
examples/kit/main.ts
/**
* A street of buildings made of three pieces: a box, a column and a sphere, each placed thousands
* of times at its own size and in its own paint.
*
* The street is a `DrftAssembly`, the form a `.drft` carries a mesh built from a kit in: one entry
* a copy, naming its piece, its paint and the matrix that places it. `expandAssembly` turns that
* into the mesh it describes, by arithmetic. The figures say what the street weighs both ways.
*
* How tall each building is, how wide, and whether it has an arcade or a dome are rules in
* `street.drs`; save a change to them and the street is built again from the same kit.
*/
import { MeshBuilder, createEnvironment } from '@driftengine/core';
import type { MeshData, MeshHandle, Vec3 } from '@driftengine/core';
import {
COPY_MATRIX_FLOATS,
COPY_UV_FLOATS,
SURFACE,
SURFACE_FLOATS,
expandAssembly,
} from '@driftengine/drft';
import type { DrftAssembly } from '@driftengine/drft';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as streetScript from './street.drs';
const stage = await openStage({ outputTransform: 'aces', sceneSamples: 4 });
const { renderer, camera } = stage;
// #region kit
/** The kit: three unit pieces. Their own colours are ignored; a copy's surface paints it. */
const WHITE: Vec3 = [1, 1, 1];
const PIECES: MeshData[] = [
new MeshBuilder().addBox([0, 0, 0], [0.5, 0.5, 0.5], WHITE).build(),
new MeshBuilder().addCylinder([0, 0, 0], 0.5, 0.5, 'y', WHITE, 0, 12).build(),
new MeshBuilder().addSphere([0, 0, 0], 0.5, WHITE, 0, 16, 8).build(),
];
const BOX = 0;
const COLUMN = 1;
const SPHERE = 2;
/** The surfaces a copy can wear: four wall paints, glass, stone, copper and the road. */
const PAINTS: [Vec3, number][] = [
[[0.78, 0.66, 0.52], 0],
[[0.7, 0.52, 0.42], 0],
[[0.82, 0.78, 0.68], 0],
[[0.58, 0.6, 0.62], 0],
[[0.12, 0.15, 0.2], 0.7],
[[0.86, 0.84, 0.8], 0.1],
[[0.36, 0.6, 0.5], 0.4],
[[0.22, 0.22, 0.24], 0],
/* And one per piece, for painting the street by which piece each copy is. */
[[1, 0.5, 0.25], 0],
[[1, 0.85, 0.3], 0],
[[0.35, 0.6, 1], 0],
];
const GLASS = 4;
const STONE = 5;
const COPPER = 6;
const ROAD = 7;
const BY_PIECE = 8;
const surfaces = new Float32Array(PAINTS.length * SURFACE_FLOATS);
PAINTS.forEach(([colour, specular], i) => {
surfaces.set(colour, i * SURFACE_FLOATS + SURFACE.color);
surfaces[i * SURFACE_FLOATS + SURFACE.specular] = specular;
});
// #endregion
/** The street's rules, hosted, and the building they describe for one lot. */
const street = hostScript(streetScript);
const rule = <T>(name: string): ((lot: number) => T) => exported<(lot: number) => T>(street, name);
// #region copies
/** Copies collected for one street, before they become an assembly. */
const pieces: number[] = [];
const surfaceOf: number[] = [];
const transforms: number[] = [];
let paintByPiece = flag('paint', 'surfaces') === 'pieces';
/** One copy: a piece, a surface, and a box it fills, from its centre and its size on each axis. */
function place(piece: number, surface: number, centre: Vec3, size: Vec3): void {
pieces.push(piece);
surfaceOf.push(paintByPiece ? BY_PIECE + piece : surface);
/* Three columns of the linear part, then the translation: a scale and a move. */
transforms.push(size[0], 0, 0, 0, size[1], 0, 0, 0, size[2], centre[0], centre[1], centre[2]);
}
/** Lots down both sides of the street, each a building the rules describe. */
function buildStreet(lots: number): DrftAssembly {
pieces.length = 0;
surfaceOf.length = 0;
transforms.length = 0;
const storeys = rule<number>('storeys');
const bays = rule<number>('bays');
const arcaded = rule<boolean>('arcaded');
const domed = rule<boolean>('domed');
const paint = rule<number>('paint');
const along = [0, 0];
for (let lot = 0; lot < lots; lot += 1) {
const side = lot % 2 === 0 ? -1 : 1;
const width = bays(lot) * 3.2 + 1.6;
const height = (storeys(lot) + 1) * 3.4;
const z = (along[lot % 2] ?? 0) + width / 2;
along[lot % 2] = z + width / 2 + 0.6;
const front = side * 7;
const middle = side * 13;
const wall = paint(lot);
place(BOX, wall, [middle, height / 2, z], [12, height, width]);
place(BOX, STONE, [middle, height + 0.25, z], [12.6, 0.5, width + 0.3]);
for (let floor = 1; floor <= storeys(lot); floor += 1) {
for (let bay = 0; bay < bays(lot); bay += 1) {
const across = z - width / 2 + 2.4 + bay * 3.2;
place(BOX, GLASS, [front - side * 0.05, floor * 3.4 + 1.7, across], [0.2, 1.8, 1.4]);
}
}
if (arcaded(lot)) {
for (let column = 0; column <= bays(lot); column += 1) {
const across = z - width / 2 + 0.8 + column * 3.2;
place(COLUMN, STONE, [front - side * 1.6, 1.7, across], [0.5, 3.4, 0.5]);
}
place(BOX, STONE, [front - side * 1.2, 3.6, z], [2.8, 0.4, width]);
}
if (domed(lot)) place(SPHERE, COPPER, [middle, height + 0.5, z], [7, 7, 7]);
}
const length = Math.max(along[0] ?? 0, along[1] ?? 0);
place(BOX, ROAD, [0, -0.1, length / 2], [60, 0.2, length + 40]);
const copies = pieces.length;
/* A stretch of one along each piece axis and no offset: untextured, so it changes nothing. */
const uv = new Float32Array(copies * COPY_UV_FLOATS);
for (let c = 0; c < copies; c += 1) uv.set([1, 1, 1, 1, 1, 1, 0, 0], c * COPY_UV_FLOATS);
return {
attributes: 0,
surfaces,
pieces: Uint32Array.from(pieces),
surfaceOf: Uint32Array.from(surfaceOf),
transforms: Float32Array.from(transforms),
uv,
};
}
// #endregion
// #region expand
/** The street as a mesh, built again whenever the rules, the length or the paint change. */
let mesh: MeshHandle | null = null;
const readout = createReadout(renderer, 2);
let lots = Number(flag('lots', '128'));
function rebuild(): void {
const assembly = buildStreet(lots);
const expanded = expandAssembly(assembly, (ordinal) => PIECES[ordinal] as MeshData);
const next = renderer.createMesh(expanded);
if (mesh !== null) renderer.disposeMesh(mesh);
mesh = next;
const copies = assembly.pieces.length;
/* What the file would carry: a surface table, then 88 bytes a copy. */
const placed =
12 + surfaces.byteLength + copies * (8 + (COPY_MATRIX_FLOATS + COPY_UV_FLOATS) * 4);
const vertices =
expanded.positions.byteLength +
expanded.normals.byteLength +
expanded.colors.byteLength +
expanded.emissive.byteLength +
(expanded.specular?.byteLength ?? 0) +
expanded.indices.byteLength;
readout.set(0, `${copies} COPIES OF ${PIECES.length} PIECES`);
readout.set(
1,
`PLACEMENTS ${(placed / 1e6).toFixed(2)} MB EXPANDED ${(vertices / 1e6).toFixed(2)} MB`,
);
}
// #endregion
rebuild();
if (import.meta.hot) {
import.meta.hot.accept('./street.drs', (next) => {
if (next === undefined) return;
patchModule(street, next as Record<string, unknown>, {});
rebuild();
});
}
controls([
{
key: 'lots',
label: 'lots',
value: String(lots),
options: ['16', '128', '512'].map((n) => ({ text: n, value: n })),
change: (value) => {
lots = Number(value);
rebuild();
},
},
{
key: 'paint',
label: 'paint',
value: paintByPiece ? 'pieces' : 'surfaces',
options: ['surfaces', 'pieces'].map((p) => ({ text: p, value: p })),
change: (value) => {
paintByPiece = value === 'pieces';
rebuild();
},
},
]);
const HAZE: Vec3 = [0.7, 0.74, 0.8];
const env = createEnvironment({
directionalDir: [0.5, 0.7, -0.3],
directionalColor: [1.9, 1.8, 1.6],
ambient: [0.34, 0.37, 0.44],
ambientGround: [0.16, 0.15, 0.14],
fogColor: HAZE,
fogDensity: 0.004,
});
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
let time = 0;
stage.run({
simulate(dt) {
time += dt;
},
render() {
camera.fovYDeg = 55;
camera.far = 1500;
camera.position[0] = Math.sin(time * 0.15) * 3;
camera.position[1] = 9;
camera.position[2] = -18;
camera.lookAt(0, 6, 120);
renderer.beginFrame(HAZE);
renderer.bindMeshPass(camera, env);
if (mesh !== null) renderer.drawMesh(mesh, IDENTITY);
readout.draw(time);
renderer.endFrame();
},
});street.drs
examples/kit/street.drs
// The street's rules: how tall each building is, how many bays wide, whether it has an arcade at
// its foot and a dome on its roof. The page builds every building from three pieces of a kit, a
// box, a column and a sphere, placed and painted as these rules say.
//
// Under `npm run examples`, change a rule and save: the page rebuilds the street from the same
// kit. Try taller buildings, or an arcade on every one.
import { index, unit } from "drift/random"
// #region rules
// Floors above the ground, from two to seven.
fn storeys(lot: u32) -> u32 {
return 2 + random.index(lot, 6)
}
// Windows across the front, from three to five.
fn bays(lot: u32) -> u32 {
return 3 + random.index(lot + 101, 3)
}
// Two buildings in five stand on an arcade of columns.
fn arcaded(lot: u32) -> bool {
return random.unit(lot + 7) < 0.4
}
// One in seven carries a dome.
fn domed(lot: u32) -> bool {
return random.unit(lot + 13) < 0.15
}
// Which of the four wall paints a building wears.
fn paint(lot: u32) -> u32 {
return random.index(lot + 29, 4)
}
// #endregion