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 MeshBuilderimport { 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
| Name | Type | Description |
|---|---|---|
triangleCountget | number | How much geometry is in here so far, without building it.MoreSo 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. |
vertexCountSoFarget | number | Vertices 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): thisMerge an existing mesh in at a position and uniform scale.
| Parameter | Type | Description |
|---|---|---|
mesh | MeshData | |
x | number | |
y | number | |
z | number | |
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): thisMerge an existing mesh under an orthonormal basis: rotated, then placed.
| Parameter | Type | Description |
|---|---|---|
mesh | MeshData | |
origin | Vec3 | |
right | Vec3 | |
up | Vec3 | |
forward | Vec3 | |
scale? | number | uniform, 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): thisBind every vertex added from here on to one joint, at full weight, or null to stop.
| Parameter | Type | Description |
|---|---|---|
index | number | 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| Parameter | Type | Description |
|---|---|---|
roughness | number | null |
setGrain
setGrain(grain: number): thisHow much visible mineral structure the following geometry has, 0–1.
| Parameter | Type | Description |
|---|---|---|
grain | number |
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): thisHow much microscopic relief the surfaces built after this have, 0 to 1.
| Parameter | Type | Description |
|---|---|---|
relief | number |
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| Parameter | Type | Description |
|---|---|---|
color | Vec3 | null |
addBox
addBox(center: Vec3, halfExtents: Vec3, color: Vec3, emissive?: number, specular?: number): this| Parameter | Type | Description |
|---|---|---|
center | Vec3 | |
halfExtents | Vec3 | |
color | Vec3 | |
emissive? | number | |
specular? | number |
addQuad
addQuad(a: Vec3, b: Vec3, c: Vec3, d: Vec3, color: Vec3, emissive?: number, specular?: number): thisA flat quad, whose normal comes from b - a crossed with d - a rather than c - a.
| Parameter | Type | Description |
|---|---|---|
a | Vec3 | |
b | Vec3 | |
c | Vec3 | |
d | Vec3 | |
color | Vec3 | |
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): thisA quad that faces up, whatever order its corners arrive in.
| Parameter | Type | Description |
|---|---|---|
a | Vec3 | |
b | Vec3 | |
c | Vec3 | |
d | Vec3 | |
color | Vec3 | |
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): thisA quad that faces away from a point, whatever order its corners arrive in.
| Parameter | Type | Description |
|---|---|---|
a | Vec3 | |
b | Vec3 | |
c | Vec3 | |
d | Vec3 | |
awayFrom | Vec3 | |
color | Vec3 | |
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): thisA box turned to lie along a direction, rather than square to the world axes.
| Parameter | Type | Description |
|---|---|---|
center | Vec3 | |
halfExtents | Vec3 | |
forward | Vec3 | |
color | Vec3 | |
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): thisA capsule aligned to Y: a cylindrical shaft with a hemispherical cap at each end.
| Parameter | Type | Description |
|---|---|---|
center | Vec3 | |
radius | number | |
halfLength | number | |
color | Vec3 | |
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): thisA smooth irregular solid: a sphere whose radius and colour are both asked for per point.
| Parameter | Type | Description |
|---|---|---|
center | Vec3 | |
radiusAt | (u: number, v: number) => number | |
color | Vec3 | ((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): thisA sphere, which is a capsule with no shaft.
| Parameter | Type | Description |
|---|---|---|
center | Vec3 | |
radius | number | |
color | Vec3 | |
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): thisAdd a faceted cylinder aligned to one principal axis.
| Parameter | Type | Description |
|---|---|---|
center | Vec3 | |
radius | number | |
halfLength | number | |
axis | 'x' | 'y' | 'z' | |
color | Vec3 | |
emissive? | number | |
segments? | number | |
specular? | number |
addTube
addTube(path: readonly number[], radii: readonly number[], color: Vec3, emissive?: number, sides?: number): thisSweep a round tube of varying radius along an arbitrary path.
| Parameter | Type | Description |
|---|---|---|
path | readonly number[] | Centreline as flattened x, y, z triples. At least two points. |
radii | readonly number[] | One radius per path point, so a sweep can taper. |
color | Vec3 | |
emissive? | number | |
sides? | number | Vertices 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| Parameter | Type | Description |
|---|---|---|
options? | MeshBuildOptions |