Geometry
Raw mesh data — vertex, normal, index and UV buffers — plus the primitive builders behind
Mesh.box() and friends. It is not a node: it is drawn through Mesh.from() (or many
times by an InstancedMesh).
- move a primitive's pivot to its base before making a mesh
- build a procedural mesh from your own vertices and triangles
- load a model's triangles from a GLB as one geometry
- tile a texture across a stretched surface
- colour a mesh per vertex
- draw lines or points instead of triangles
Platforms: every host; vertex colours (getColor() in a material) on iOS, Android, desktop,
the web player and the le.codes viewer app — not on the web (Web Lite), and headless tests ignore
them.
At a glance
const scene = new Scene()
scene.add(Light.sun())
// a box whose pivot is at its BOTTOM face (so y = 0 puts it on the floor)
const geo = Geometry.box().scale(1, 2, 1).translate(0, 1, 0)
const pillar = Mesh.from(geo, { material: Material.lit({ color: '#ccc' }) })
scene.add(pillar)
scene.open()Primitive shapes: Geometry.box, .sphere, .cylinder, .capsule, .plane
Geometry.box(size?: Vec3Like | number): Geometry // full edge lengths; default 1 (unit cube)
Geometry.sphere(options?: {
radius?: number // default 0.5
widthSegments?: number // default 32
heightSegments?: number // default 32
}): Geometry
Geometry.cylinder(options?: {
radius?: number // default 0.5 (both caps)
radiusTop?: number // default = radius (0 makes a cone)
radiusBottom?: number // default = radius
edges?: number // default 32
smooth?: boolean // default true — smooth side normals; false = faceted
}): Geometry
Geometry.capsule(options?: {
radius?: number // default 0.5
length?: number // between the two cap centres; default 1 (0 = a sphere)
widthSegments?: number // around the axis; default 32
capSegments?: number // rings per hemisphere; default 8
}): Geometry
Geometry.plane(options?: {
normal?: Vec3Like // facing direction; default [0, 0, 1]
}): GeometryAll primitives are centred on the origin with normals and UVs filled in, unit-sized unless told
otherwise (1 world unit ≈ 1 m across; the cylinder is 1 tall, the capsule runs along Y). The plane
is a 1×1 quad perpendicular to normal. The box and the cylinder also fill uv1 (below).
Build a mesh from your own vertices: new Geometry
geo.vertices // Float32Array — x,y,z triplets
geo.normals // Float32Array — x,y,z triplets (per vertex)
geo.indices // Uint16Array — triangle (or edge/point) indices
geo.uv // Float32Array — u,v pairs (per vertex)
geo.uv1 // Float32Array | undefined — the lightmap UV set (non-overlapping; absent = uv is reused)
geo.colors // Uint8Array | undefined — r,g,b,a per vertex (below)
new Geometry(vertices, normals, indices, uv)Build a procedural mesh by filling the buffers yourself and constructing directly; a triangle whose indices run counter-clockwise as seen from its normal's side is the front face:
const scene = new Scene()
const tri = new Geometry(
new Float32Array([ -1, 0, 0, 1, 0, 0, 0, 1.5, 0 ]), // x,y,z per vertex
new Float32Array([ 0, 0, 1, 0, 0, 1, 0, 0, 1 ]), // normals, facing +Z
new Uint16Array([ 0, 1, 2 ]), // counter-clockwise seen from +Z = front
new Float32Array([ 0, 0, 1, 0, 0.5, 1 ]), // u,v per vertex
)
scene.add(Light.sun(), Mesh.from(tri, { material: Material.lit({ color: '#4a8' }) }))
scene.open()uv1 is what the Lightmap atlas samples a Mesh through.
indices are Uint16Array, so a single geometry tops out at 65 535 vertices.
Load a model's triangles from a GLB: Geometry.load
Geometry.load(source: string | GeometryData): Promise<Geometry>
Geometry.fromData(data: GeometryData): Geometrylecodes assets geometry casing.glb writes casing.geometry.json: the GLB's triangles as ONE
buffer, world transforms baked in (--origin bottom, --scale to re-origin / rescale). Load it
with Geometry.load(asset('./casing.geometry.json')) — a .json project file is inlined by the
compiler, so that call reads the data directly (no fetch); a URL string is fetched. It is the mesh
for mesh particles (shell casings, rubble, coins) and for Mesh.from. Materials,
skins and clips are dropped; a file over 65 535 vertices is refused.
const main = async () => {
const casing = await Geometry.load(asset('./casing.geometry.json'))
const scene = new Scene()
scene.add(Light.sun(), Mesh.from(casing, { material: Material.lit({ color: '#c90', metallic: 1 }) }))
scene.open()
}
main()Colour each vertex: geo.colors
const g = Geometry.box()
g.colors = new Uint8Array(g.vertices.length / 3 * 4) // r, g, b, a per vertex, 0..255A material that declares requires: [color] reads them with getColor() — one mesh in many colours
instead of a material per shade, and a gradient can run along a strip (an editor's route coloured by
speed, a debug drawer). The values reach the shader as 0..1 exactly as packed, so a shader fed sRGB
hex should linearise them (pow(c, vec3(2.2))); baseColor is linear. Absent = every vertex white,
which is what the built-in materials expect. Optional on the host: one built before this draws white.
The lite engine carries them into its geometry, but its custom materials have no getColor() yet.
Move the pivot, resize, tile the texture: translate, scale, scaleUV
Move a shape's pivot (a box's to its bottom face), resize it or tile its texture by changing the
vertex data itself — Geometry.box().translate(0, 0.5, 0) puts the pivot at the base:
geo.translate(x, y, z): this // shift every vertex — moves the pivot
geo.scale(x, y, z): this // scale every vertex
geo.scaleUV(x, y): this // tile/stretch the texture coordinatesThese mutate the buffers in place (chainable). Baking a transform into the geometry is different
from node.scale: physics auto-shapes measure the baked size, a node's scale also scales its
children while baked data does not, and normals are not recomputed — a non-uniform scale skews
lighting slightly.
Draw lines or points: geo.kind
geo.kind = 'triangles' | 'edges' | 'vertices' // default 'triangles' (write-only)'edges' renders the index pairs as lines, 'vertices' as points — for wireframe/debug looks. The
index buffer is read as it is: each PAIR of indices is one line, so a primitive's triangle list does
not turn into its wireframe — give the lines their own indices:
const scene = new Scene()
const v = new Float32Array([ 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1 ])
const axes = new Geometry(v, new Float32Array(v.length), new Uint16Array([ 0, 1, 2, 3, 4, 5 ]), new Float32Array(12))
axes.kind = 'edges' // each index PAIR is one line
scene.add(Mesh.from(axes, { material: Material.unlit({ color: '#ff0' }) }))
scene.open()Pitfalls
// ✗ editing a Geometry after a Mesh was built from it
mesh.geometry.translate(0, 1, 0) // buffers already uploaded — the mesh doesn't change
// ✓ finish the geometry first, then Mesh.from(geo) — or re-upload it
mesh.setGeometry(mesh.geometry.translate(0, 1, 0))See also
- Mesh —
Mesh.from(geometry),setGeometryand the primitive factory shortcuts - Material — what the surface looks like
- 3D physics & shapes — auto-shapes read the node's geometry
- Particles — mesh particles from a geometry file