Geometry · class

MeshBuilder

CPU-side accumulator that merges axis-aligned boxes into one static mesh — the entire grey-box world becomes a single draw call.

Explained in Hello world, Meshes and geometry.

class MeshBuilder
import { MeshBuilder } from '@driftengine/core';

In depth

Faces are generated per-axis with cyclic tangents (a,b,c) so that U×V = N, guaranteeing outward CCW winding for back-face culling.

Long on purpose, and this was measured rather than assumed (2026-08-24). The file is 1,056 lines and the guideline is around 600, so it was examined for a seam and has none worth taking: it is a fluent accumulator whose every method returns this and reads class state — 146 reads of this across the class — and the genuinely pure code in it comes to 44 lines at the tail (assertOrthonormal, dot, and the three constants they are made of). Extracting 44 lines would leave the class exactly as long and add a file to follow.

Carving the class itself is the alternative and it is worse: addBox, addCapsule, addBlob and addCylinder all end in the same addFace and addVertex, so any cut runs straight through the shared tail and produces modules that only make sense read together. An invented seam costs more readability than the length it relieved, which is the rule the render-graph design states — and this file is the case that rule was written about, named in it by name.

What would make this wrong: a second builder wanting the same face generation, which would turn the shared tail from an internal detail into an interface. Nothing wants it today.

Accessors

NameTypeDescription
triangleCountgetnumberHow much geometry is in here so far, without building it.
More

So a caller batching by size can ask instead of counting. A consumer streaming a world caps a batch at a triangle count and opens another when it is passed; without this the only way to know was to tally what it had handed over, which means every geometry verb's triangle count duplicated on the caller's side, and a batch that overshoots by whatever the last object was — measured at 47,040 triangles against a cap of 32,000.

Both are counters, not scans: asking costs nothing and may be asked between every object.

vertexCountSoFargetnumberVertices written so far. The other half of the same question, for a memory budget.

Methods

addMesh

addMesh(mesh: MeshData, x: number, y: number, z: number, scale?: number): this

Merge an existing mesh in at a position and uniform scale.

ParameterTypeDescription
meshMeshData
xnumber
ynumber
znumber
scale?number
More

For geometry that is built once and then stamped around the world — a tree trunk, say. Merging keeps it inside the single static draw call rather than adding one per copy, which is the whole reason the world is one mesh.

Uniform scale only: a non-uniform one would need normals transformed by the inverse transpose, and nothing here has wanted that yet.

addOrientedMesh

addOrientedMesh(mesh: MeshData, origin: Vec3, right: Vec3, up: Vec3, forward: Vec3, scale?: number): this

Merge an existing mesh under an orthonormal basis: rotated, then placed.

ParameterTypeDescription
meshMeshData
originVec3
rightVec3
upVec3
forwardVec3
scale?numberuniform, applied in the local frame before the basis.
More

addMesh translates and scales, which is enough for anything whose own axes are the world's — a tree, a rock. It is not enough for anything that has to agree with a surface: a marking on a banked deck, a sign facing back along a curve, a letter standing on a slope. Until now the only way to follow a surface was to be a ribbon built from the same curve, which works beautifully for bands across a deck and cannot draw a glyph.

The basis is assumed orthonormal and is checked, because that assumption is what lets normals be rotated by the same three vectors as the positions: a non-uniform or skewed basis needs the inverse transpose, and silently getting that wrong produces geometry that is lit as if it were facing somewhere else — which looks like a shading bug, not a maths one.

setJoint

setJoint(index: number | null): this

Bind every vertex added from here on to one joint, at full weight, or null to stop.

ParameterTypeDescription
indexnumber | null
More

Rigid binding: one influence. A mesh assembled from solid pieces — a rig of boxes, a chandelier, a tank's wheels — needs exactly this, and smooth skinning across a joint is a different job belonging to a baker that reads a rigged source.

Stopping binds what follows to joint 0 rather than to nothing, for the reason expandedJoints gives: a zero-weight vertex collapses onto the origin.

setRoughness

setRoughness(roughness: number | null): this
ParameterTypeDescription
roughnessnumber | null

setGrain

setGrain(grain: number): this

How much visible mineral structure the following geometry has, 0–1.

ParameterTypeDescription
grainnumber
More

State beside setRoughness, and deliberately independent of it. Roughness is how widely a surface scatters a highlight; grain is whether it has structure you can see at arm's length. Painted plaster is rough with no grain and polished granite is smooth with a great deal, so neither can be derived from the other — which is exactly what the two previous versions of this feature tried, first through specular and then through roughness, and both were wrong in both directions.

Zero is the default and means none. A scene says which of its surfaces are stone while it is building them, and everything it does not mention stays smooth.

setRelief

setRelief(relief: number): this

How much microscopic relief the surfaces built after this have, 0 to 1.

ParameterTypeDescription
reliefnumber
More

What it is for. A road is not flat and neither is cast concrete, a plaster wall or a hammered tray: each is covered in structure far too small to model and far too large to ignore, and it is what makes them read as material rather than as coloured planes. This is the general capability for that, so a scene states an amount here and the material decides how coarse it is and how deep, through Renderer.setSurfaceRelief.

Distinct from setGrain, and both may apply. Grain varies how much light a point takes, so it mottles a face that stays flat. Relief varies which way the point faces, so it catches a light from one side and shades on the other, and it survives being seen from a shallow angle where a brightness mottle washes out. Stone wants both. Asphalt wants mostly this.

Zero is the default and means none, so every surface a scene does not mention is smooth and no world built before this existed moves.

setEmissiveColor

setEmissiveColor(color: Vec3 | null): this
ParameterTypeDescription
colorVec3 | null

addBox

addBox(center: Vec3, halfExtents: Vec3, color: Vec3, emissive?: number, specular?: number): this
ParameterTypeDescription
centerVec3
halfExtentsVec3
colorVec3
emissive?number
specular?number

addQuad

addQuad(a: Vec3, b: Vec3, c: Vec3, d: Vec3, color: Vec3, emissive?: number, specular?: number): this

A flat quad, whose normal comes from b - a crossed with d - a rather than c - a.

ParameterTypeDescription
aVec3
bVec3
cVec3
dVec3
colorVec3
emissive?number
specular?number
More

That is not what anybody assumes, and it decides which side the face lights from. Every other winding convention in graphics walks consecutive vertices, so a horizontal quad wound the obvious way round, (x0,z0) (x1,z0) (x1,z1) (x0,z1), gives +X crossed with +Z, which points at the floor. Reported from outside after every ground in an application faced downward for four scenarios: they were lit by lamps near the floor, where a flipped normal is a dimmer surface rather than an absent one, and it only became visible on the fifth world when a directional lit it and the ground took nothing from the sun at all.

Reverse the last two corners to flip it, or wind anticlockwise seen from the side the face should light from.

The normal is per face, so anything assembled from these is flat-shaded by construction. A surface that has to be both curved and varied wants addBlob.

addGroundQuad

addGroundQuad(a: Vec3, b: Vec3, c: Vec3, d: Vec3, color: Vec3, emissive?: number, specular?: number): this

A quad that faces up, whatever order its corners arrive in.

ParameterTypeDescription
aVec3
bVec3
cVec3
dVec3
colorVec3
emissive?number
specular?number
More

addQuad's winding rule is the single most expensive trap in this class, and its own note says why: the normal is (b − a) × (d − a), which is not the order a person walks around a rectangle. Reading that note is not the same as remembering it at four in the morning. It cost five scenes in one application before anybody saw it, and then four separate bugs in one afternoon in another — one of which was every building in a village lit inside out, because a wall is a quad too.

So: a ground, a floor, a road, a tabletop, a roof plane seen from above. Pass the corners in whichever order reads naturally and the face lights from the sky. What it costs is one cross product and one comparison, at build time, once.

A quad standing exactly vertical has no up and is refused rather than guessed at — a wall passed to this by mistake is a caller error worth hearing about, and addWallQuad is the one that wanted it.

addWallQuad

addWallQuad(a: Vec3, b: Vec3, c: Vec3, d: Vec3, awayFrom: Vec3, color: Vec3, emissive?: number, specular?: number): this

A quad that faces away from a point, whatever order its corners arrive in.

ParameterTypeDescription
aVec3
bVec3
cVec3
dVec3
awayFromVec3
colorVec3
emissive?number
specular?number
More

The other nine-tenths of what a consumer wants from addQuad: the outside of a wall, of a chimney, of a crate, of anything hollow built from surfaces. awayFrom is usually the centre of the room or the solid the face belongs to, and it need not be exact — only on the correct side, which is a thing a caller knows without thinking. That is the whole difference from winding, which is a thing a caller has to look up.

A face whose centre is the reference point has no outward side, and that is refused: it means the two were mixed up, and picking one silently is how a building ends up lighting its interior while the outside takes nothing from the sun.

addOrientedBox

addOrientedBox(center: Vec3, halfExtents: Vec3, forward: Vec3, color: Vec3, emissive?: number, specular?: number): this

A box turned to lie along a direction, rather than square to the world axes.

ParameterTypeDescription
centerVec3
halfExtentsVec3
forwardVec3
colorVec3
emissive?number
specular?number
More

addBox is world-axis only, and that is visible the moment anything follows a boundary. A hedge along a property line built from addBox is a row of detached slabs each facing a different way; a window sill is square in plan and pokes out of three walls it does not belong to. Both of those shipped in a consumer's world and both were reported by eye, and the wrapper they wrote to fix it is this method.

halfExtents are measured along the box's own axes: x across, y up, z along forward. The other two axes are derived from world up, which is what makes this one argument rather than three: a box that also has to tilt or roll wants addOrientedMesh, which takes the full basis and is the reason this does not.

Every face is emitted through addWallQuad against the box's own centre, so the six outward normals are one argument rather than six windings to get right.

addCapsule

addCapsule(center: Vec3, radius: number, halfLength: number, color: Vec3, emissive?: number, segments?: number, rings?: number, specular?: number): this

A capsule aligned to Y: a cylindrical shaft with a hemispherical cap at each end.

ParameterTypeDescription
centerVec3
radiusnumber
halfLengthnumber
colorVec3
emissive?number
segments?number
rings?number
specular?number
More

The shape almost every organic silhouette is roughed out with — a limb, a torso, a creature seen through fog. A stack of boxes stands in for it badly: the corners read as a machine at any distance, which is the wrong instinct entirely for something meant to look alive. Reported as an entity looking "SQUARED".

halfLength measures the cylindrical part only, so total height is 2 * (halfLength + radius) and a capsule with halfLength 0 is a sphere.

addBlob

addBlob(center: Vec3, radiusAt: (u: number, v: number) => number, color: Vec3 | ((u: number, v: number) => Vec3), emissive?: number, segments?: number, rings?: number, specular?: number): this

A smooth irregular solid: a sphere whose radius and colour are both asked for per point.

ParameterTypeDescription
centerVec3
radiusAt(u: number, v: number) => number
colorVec3 | ((u: number, v: number) => Vec3)
emissive?number
segments?number
rings?number
specular?number
More

The gap this fills is "curved and varied", which nothing here could express. Every other generator takes one colour for the whole call, and the one primitive that could vary it, addQuad, computes a face normal from its winding, so anything assembled from quads is flat by construction. A consumer wanting a planet with latitude bands therefore had a choice between a texture, in an engine whose whole argument is not needing images, and geometry stuck onto a sphere. And a consumer wanting a rock went through four attempts, each failing on a property of the primitive rather than on the shape: boxes gave right angles, overlapping spheres gave cusps at every intersection and read as foam, a quad grid gave a correct silhouette made of eighty flat plates, and a tube gave a water-worn pebble because its cross-section is always a circle.

radiusAt and color both take the surface parameters, u around and v from pole to pole, each 0 to 1. A constant radius is addSphere; a few low-frequency waves is a boulder; a signed step is a fracture; a constant radius with a varying colour is a banded planet.

A radiusAt built as a min of constraints has to place them inside the base radius. A cut plane at or above it removes nothing, so a set of them scattered around the radius leaves a shape that is nearly round more often than not. Reported from outside, where nine planes between 0.34 and 0.50 of a 0.5 radius gave a sphere about one seed in three.

The normal is derived from the surface rather than from the radius, which is the part that matters and the part a radial normal gets wrong. Where the radius varies, the surface no longer faces along its own radius, so a radial normal lights a boulder as though it were a sphere: the silhouette is lumpy and the shading is not. Each vertex takes the cross product of the two parametric tangents, measured from the same callback, so a smooth radius gives smooth shading and an abrupt one gives a genuine hard edge. That is what lets one primitive be both a pebble and a fracture.

addSphere

addSphere(center: Vec3, radius: number, color: Vec3, emissive?: number, segments?: number, rings?: number, specular?: number): this

A sphere, which is a capsule with no shaft.

ParameterTypeDescription
centerVec3
radiusnumber
colorVec3
emissive?number
segments?number
rings?number
specular?number

addCylinder

addCylinder(center: Vec3, radius: number, halfLength: number, axis: 'x' | 'y' | 'z', color: Vec3, emissive?: number, segments?: number, specular?: number): this

Add a faceted cylinder aligned to one principal axis.

ParameterTypeDescription
centerVec3
radiusnumber
halfLengthnumber
axis'x' | 'y' | 'z'
colorVec3
emissive?number
segments?number
specular?number

addTube

addTube(path: readonly number[], radii: readonly number[], color: Vec3, emissive?: number, sides?: number): this

Sweep a round tube of varying radius along an arbitrary path.

ParameterTypeDescription
pathreadonly number[]Centreline as flattened x, y, z triples. At least two points.
radiireadonly number[]One radius per path point, so a sweep can taper.
colorVec3
emissive?number
sides?numberVertices per ring. Eight is round enough to read as a cylinder at the distance a fixture is seen from, and cheap enough to put one on every gate.
More

The primitive a curve needs. addCylinder is axis-aligned and straight, and a curve chained out of boxes — which is how every beam in this engine's consumers has been built — has hard corners and only six distinct normals, so it reads as a staircase however finely it is stepped. A tube carries a normal per ring vertex, so the flat shader's interpolated vNormal shades it as the round thing it is.

The frame is parallel-transported rather than built from a fixed world up. A frame derived from world up flips through 180 degrees wherever the path turns vertical, which puts a visible twist in the tube at exactly the apex of an arch; carrying the previous ring's frame forward and re-orthogonalising it against the new tangent has no such singularity. It also has no preferred orientation to disagree with, which matters when the path is planar and the plane is arbitrary.

build

build(options?: MeshBuildOptions): MeshData
ParameterTypeDescription
options?MeshBuildOptions