Procedural solids
A gatehouse built from closed solids, with windows and a doorway cut by booleans.
Starts examples/solids in this page, on WebGPU where your browser has it.
Procedural solids is the chapter that walks through it.
From a checkout of the engine, npm run examples serves it at /solids/.
Source
main.ts
examples/solids/main.ts
/**
* A gatehouse built in code from closed solids and booleans: a wall with windows cut out of it, a
* round tower with a doorway carved into its foot, a domed pavilion on a star-shaped plinth, an urn
* turned on a lathe and a rail swept along a curve.
*
* Everything here is shape until the last step, when each solid is painted one colour and handed to
* the renderer. The wall can be cut two ways, and the figures say what each costs: the box grid,
* exact for boxes on the axes, or the general boolean. How many windows there are and how big each
* row's are is decided in `wall.drs`.
*/
import {
MeshBuilder,
createEnvironment,
computeLightMatrix,
mergeSolids,
smoothSolidNormals,
solidBox,
solidBoxBoolean,
solidCapsule,
solidCone,
solidCylinder,
solidExtrude,
solidHemisphere,
solidLathe,
solidSubtract,
solidSweep,
solidToMesh,
solidTorus,
solidTube,
solidVolume,
transformSolid,
} from '@driftengine/core';
import type { BoxOperation, MeshHandle, ShadowCasters, Solid, Vec3 } from '@driftengine/core';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as wallScript from './wall.drs';
const stage = await openStage({
directionalShadows: true,
outputTransform: 'aces',
sceneSamples: 4,
});
const { renderer, camera } = stage;
/** A matrix that moves a solid to `x, y, z`. */
const at = (x: number, y: number, z: number): number[] => [
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
1,
0,
x,
y,
z,
1,
];
// #region primitives
/** A plinth: an eight-pointed star in plan, extruded up 0.6 metres. */
const star: number[] = [];
for (let i = 0; i < 16; i += 1) {
const a = (i / 16) * Math.PI * 2;
const r = i % 2 === 0 ? 6 : 4.6;
star.push(Math.cos(a) * r, Math.sin(a) * r);
}
const plinth = solidExtrude(star, 0.6);
/** A pavilion on it: a ring of wall, a dome, and a torus where the two meet. */
const drum = transformSolid(solidTube(4, 0.4, 3, 32), at(0, 2.1, 0));
const dome = transformSolid(solidHemisphere(4.1, 32), at(0, 3.6, 0));
const band = transformSolid(solidTorus(4.15, 0.22, 48, 10), at(0, 3.6, 0));
const pavilion = transformSolid(mergeSolids([plinth, drum, band]), at(-17, 0, 2));
const roof = transformSolid(dome, at(-17, 0, 2));
/** An urn turned on a lathe from a profile of radius and height pairs, bottom to top. */
const urn = transformSolid(
solidLathe([0.5, 0, 0.7, 0.2, 0.9, 0.9, 0.6, 1.5, 0.45, 1.8, 0.65, 2.1], 24),
at(-9, 0, 6),
);
/** A rail swept along a curve in front of the wall: a small square section, both ends capped. */
const path: number[] = [];
for (let i = 0; i <= 24; i += 1) {
const x = -12 + i;
path.push(x, 1 + Math.sin(i * 0.25) * 0.15, 4 + Math.cos(i * 0.2) * 0.8);
}
const rail = solidSweep([-0.08, -0.08, 0.08, -0.08, 0.08, 0.08, -0.08, 0.08], path, true);
// #endregion
// #region doorway
/** A round tower with a doorway carved into its foot: a capsule taken out of a cylinder. */
const shaft = transformSolid(solidCylinder(3, 14, 32), at(16, 7, 0));
const arch = transformSolid(solidCapsule(1.1, 1.8, 24), at(16, 1.4, 3));
const tower = mergeSolids([
solidSubtract(shaft, arch),
transformSolid(solidCone(3.6, 4.5, 32), at(16, 16.25, 0)),
]);
// #endregion
// #region wall
/** The windows, as the script lays them out, cut out of a wall 24 metres long and 10.5 high. */
const layout = hostScript(wallScript);
type Rule<T> = (value: number) => T;
function openings(count: number): [number, number, number, number, number, number][] {
const perRow = exported<Rule<number>>(layout, 'perRow')(count);
const height = exported<Rule<number>>(layout, 'windowHeight');
const width = exported<Rule<number>>(layout, 'windowWidth');
const boxes: [number, number, number, number, number, number][] = [];
let y = 1;
for (let i = 0; i < count; i += 1) {
const row = Math.floor(i / perRow);
const column = i % perRow;
if (column === 0 && row > 0) y += height(row - 1) + 0.5;
const x = -12 + (24 / perRow) * (column + 0.5);
const w = width(row) / 2;
boxes.push([x - w, y, -1, x + w, y + height(row), 1]);
}
return boxes;
}
/** Boxes on the three axes: exact over the grid of their coordinates, faces merged. */
function cutByGrid(holes: [number, number, number, number, number, number][]): Solid {
const operations: BoxOperation[] = [{ box: [-12, 0, -0.3, 12, 10.5, 0.3], op: 'union' }];
for (const box of holes) operations.push({ box, op: 'subtract' });
return solidBoxBoolean(operations);
}
/** The general boolean: any closed solid from any other, one subtraction at a time. */
function cutByBsp(holes: [number, number, number, number, number, number][]): Solid {
let wall = transformSolid(solidBox(24, 10.5, 0.6), at(0, 5.25, 0));
for (const [x0, y0, z0, x1, y1, z1] of holes) {
const hole = solidBox(x1 - x0, y1 - y0, z1 - z0);
wall = solidSubtract(
wall,
transformSolid(hole, at((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2)),
);
}
return wall;
}
// #endregion
/** The switches: how many windows, which boolean cuts them, and how the round solids shade. */
let count = Number(flag('windows', '12'));
let method = flag('boolean', 'grid');
let smooth = flag('normals', 'smooth') === 'smooth';
const readout = createReadout(renderer, 2);
const STONE: Vec3 = [0.8, 0.66, 0.5];
const meshes: MeshHandle[] = [];
// #region paint
/** Every solid painted once and handed to the renderer; built again when a switch changes. */
function build(): void {
for (const mesh of meshes) renderer.disposeMesh(mesh);
meshes.length = 0;
const holes = openings(count);
const started = performance.now();
const wall = method === 'grid' ? cutByGrid(holes) : cutByBsp(holes);
const took = performance.now() - started;
const shade = (solid: Solid): Solid => (smooth ? smoothSolidNormals(solid, 40) : solid);
const paint = (solid: Solid, colour: Vec3): void => {
meshes.push(renderer.createMesh(solidToMesh(shade(solid), colour)));
};
paint(wall, STONE);
paint(tower, [0.66, 0.4, 0.32]);
paint(pavilion, [0.86, 0.82, 0.74]);
paint(roof, [0.32, 0.58, 0.5]);
paint(urn, [0.5, 0.3, 0.2]);
paint(rail, [0.2, 0.2, 0.24]);
readout.set(0, `WALL ${wall.indices.length / 3} TRIANGLES IN ${took.toFixed(1)} MS`);
readout.set(1, `VOLUME ${solidVolume(wall).toFixed(2)} CUBIC METRES`);
}
// #endregion
build();
if (import.meta.hot) {
import.meta.hot.accept('./wall.drs', (next) => {
if (next === undefined) return;
patchModule(layout, next as Record<string, unknown>, {});
build();
});
}
controls([
{
key: 'windows',
label: 'windows',
value: String(count),
options: ['4', '12', '40'].map((n) => ({ text: n, value: n })),
change: (value) => {
count = Number(value);
build();
},
},
{
key: 'boolean',
label: 'boolean',
value: method,
options: [
{ text: 'box grid', value: 'grid' },
{ text: 'general', value: 'bsp' },
],
change: (value) => {
method = value;
build();
},
},
{
key: 'normals',
label: 'normals',
value: smooth ? 'smooth' : 'faceted',
options: ['smooth', 'faceted'].map((n) => ({ text: n, value: n })),
change: (value) => {
smooth = value === 'smooth';
build();
},
},
]);
const ground = renderer.createMesh(
new MeshBuilder().addBox([0, -0.1, 0], [60, 0.1, 60], [0.36, 0.42, 0.3]).build(),
);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const env = createEnvironment({
directionalDir: [-0.5, 0.65, 0.55],
directionalColor: [2, 1.88, 1.7],
ambient: [0.32, 0.36, 0.44],
ambientGround: [0.16, 0.15, 0.13],
fogColor: [0.68, 0.74, 0.82],
fogDensity: 0.004,
});
const casters: ShadowCasters = (sink) => {
sink.mesh(ground, IDENTITY);
for (const mesh of meshes) sink.mesh(mesh, IDENTITY);
};
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.8;
env.shadowDepthSpan = computeLightMatrix(
env.directionalDir,
0,
4,
2,
30,
renderer.shadowMapSize,
lightMatrix,
);
let time = 0;
stage.run({
simulate(dt) {
time += dt;
},
render() {
const angle = 0.35 + Math.sin(time * 0.12) * 0.45;
camera.fovYDeg = 50;
camera.position[0] = Math.sin(angle) * 40;
camera.position[1] = 11;
camera.position[2] = Math.cos(angle) * 40;
camera.lookAt(0, 4, 0);
renderer.beginShadowPass(lightMatrix, 'static');
renderer.drawShadowCasters(casters);
renderer.endShadowPass();
renderer.beginFrame([0.68, 0.74, 0.82]);
renderer.bindMeshPass(camera, env);
renderer.drawSceneCasters(casters);
readout.draw(time);
renderer.endFrame();
},
});wall.drs
examples/solids/wall.drs
// How the wall's windows are laid out: how many to a row, and how big each row's are. The page cuts
// that many openings out of the wall with a boolean, row by row.
//
// Under `npm run examples`, change a rule and save: the page cuts the wall again. Try wider
// windows, or more of them to a row.
import { max, min } from "std/math"
// #region windows
// Openings across one row: all of them while there are few, ten at most.
fn perRow(openings: u32) -> u32 {
if openings < 10 {
return openings
}
return 10
}
// Tall windows at the bottom, squarer toward the top.
fn windowHeight(row: u32) -> f32 {
return math.max(2.2 - 0.3 * f32.nearest(row), 1.0)
}
fn windowWidth(row: u32) -> f32 {
return math.min(1.0 + 0.1 * f32.nearest(row), 1.4)
}
// #endregion