Log inGet started

mesh

Updated 25 August 2026

New to how a mesh exists as an asset, as CPU-side vertex data, and as a live GPU resource (and how renderer.mesh.* moves between them)? Read core/resource-model first.

Geometry is stored in data.zmsh; the GPU resource is keyed by the asset's guid.

Create

asset.create("mesh", name, opts) writes the geometry and registers the mesh. opts is raw geometry — flat arrays; positions and indices are required, normals and uvs optional:

asset.create("mesh", "tri", {
    positions = { 0,0,0,  1,0,0,  0,1,0 },   -- [x,y,z, ...]   (required)
    indices   = { 0, 1, 2 },                 -- [i0,i1,i2, ...] (required, 0-based)
    normals   = { 0,0,1,  0,0,1,  0,0,1 },   -- [x,y,z, ...]   (optional)
    uvs       = { 0,0,  1,0,  0,1 },         -- [u,v, ...]     (optional)
})

The full authorable geometry, all flat arrays unless noted:

FieldShapeNotes
positions[x,y,z, ...]required
indices[i0,i1,i2, ...]required; 0-based, counter-clockwise for the front face (see Conventions)
normals[x,y,z, ...]optional; the surface's outward direction
uvs[u,v, ...]texture UVs, optional; (0,0) is the image's top-left
colors[r,g,b,a, ...]optional
tangents[x,y,z,w, ...]optional (w = handedness)
uvs1[u,v, ...]the lightmap UV set, optional
unwrapUvsbooleangenerate uvs1 when it is absent
skinning{ joints, weights }per-vertex skin binding (four each per vertex)
skinsarray of skin tablesthe skeleton (see renderer.mesh.decode for the shape)
morphTargetsarray of { name?, positions, normals? }the shapes the mesh blends towards, each a per-vertex displacement

encode / decode round-trip every one of these, so a skinned mesh can be built in engine, not only imported. Pass unwrapUvs = true to generate a lightmap UV set at creation time.

Conventions

Geometry is read against these whether it arrived through asset.create, renderer.mesh.create, or an importer.

ConventionHow the engine reads the geometry
Index baseindices count vertices from 0 — index 0 is the first positions triple. The Luau array carrying them is 1-based like any other, so indices[1] is the first triangle's first index, and vertex v has its x at positions[v * 3 + 1].
Triangle windingA triangle's front face is the one whose three vertices turn counter-clockwise as the viewer sees them. Put another way, cross(v1 - v0, v2 - v0) points out of the front face.
Back facesA material culls its back faces by default, so a triangle turned away from the camera draws nothing where it stands and whatever is behind it shows through.
Normalsnormals give the surface its outward direction and shade the face. Which side of a triangle draws comes from the index order alone, so a normal pointing away from the side the winding draws leaves that face standing and shades it as if the light were behind it.
UVs(0, 0) samples the first texel of an image — its top-left corner — and v grows downward through it.
AxesModel space carries the world's basis: +X right, +Y up, and -Z the direction an entity's transform.forward points.

Winding is what the author states: the inward faces of a room and a sky shell seen from inside are geometry meant to be wound the other way, and asset.create writes the index order it is handed. Two levers turn a face around:

indices = { 0, 2, 1 }                          -- the same three vertices, reversed

asset.create("material", "two_sided", {        -- or keep the order and draw both sides
    shader = "@builtin::shaders.pbr", render = { cull = "none" },
})

cull = "front" draws the far side by itself, which is what an inverted-hull outline is built on, and "back" is the default. .shader documents the whole render block a material's mat.yaml carries.

A mesh states which way its own triangles face, so the question is answerable off the geometry — every triangle whose cross product agrees with the normal it carries is wound to draw the side that normal points at:

local cpu = meshRef:load()
local g = cpu:geometry()

local function facesOutward(t: number): boolean       -- t counts triangles from 0
    local i = { g.indices[t * 3 + 1], g.indices[t * 3 + 2], g.indices[t * 3 + 3] }
    local function p(k: number, c: number): number return g.positions[i[k] * 3 + c] end
    local ax, ay, az = p(2,1) - p(1,1), p(2,2) - p(1,2), p(2,3) - p(1,3)
    local bx, by, bz = p(3,1) - p(1,1), p(3,2) - p(1,2), p(3,3) - p(1,3)
    local cx, cy, cz = ay*bz - az*by, az*bx - ax*bz, ax*by - ay*bx
    local n = i[1] * 3
    return cx * g.normals[n+1] + cy * g.normals[n+2] + cz * g.normals[n+3] > 0
end

local inward = 0
for t = 0, #g.indices // 3 - 1 do
    if not facesOutward(t) then inward += 1 end
end
cpu:unload()

Built-in primitive meshes

The engine ships blockout geometry every world references by bare name ({ model = "cube" }). Each one is centred on its local origin, so an entity's scale reads directly as the size the mesh covers in the world:

MeshExtent (x, y, z)Local AABB
cube1 x 1 x 1-0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5
sphere1 x 1 x 1-0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5
cylinder1 x 1 x 1-0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5
cone1 x 1 x 1-0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5
octahedron1 x 1 x 1-0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5
rounded_box1 x 1 x 1-0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5
capsule0.5 x 1 x 0.5-0.25, -0.5, -0.25 .. 0.25, 0.5, 0.25
torus1 x 0.3 x 1-0.5, -0.15, -0.5 .. 0.5, 0.15, 0.5
plane1 x 0 x 1-0.5, 0, -0.5 .. 0.5, 0, 0.5
ground_quad1 x 0.1 x 1-0.5, -0.05, -0.5 .. 0.5, 0.05, 0.5
ground_hex1.732 x 0.1 x 2-0.866, -0.05, -1 .. 0.866, 0.05, 1

plane is a flat quad in the XZ plane, so it has no vertical extent at all and scaling it on Y moves nothing. meshRef:load():getBounds() reads the same numbers back off any mesh, built-in or authored.

Put a mesh on an entity

A component draws a mesh through a resource("mesh") field, which accepts the mesh by name, by ref, or as a MeshHandle — the component resolves whatever you give it. The builtin Model and SkinnedModel both expose a model field:

entity.find("box").component.add("Model", { model = "tri" })             -- by name
entity.find("box").component.add("Model", { model = asset.ref("tri") })  -- by ref
entity.find("box").component.add("Model", { model = meshHandle })        -- a MeshHandle (below)

Runtime mesh — no asset

Build geometry straight onto the GPU and render it without creating an asset. The resource("mesh") field takes the returned MeshHandle directly:

local h = renderer.mesh.create({ positions = {0,0,0, 1,0,0, 0,1,0}, indices = {0,1,2} })
entity.find("box").component.add("Model", { model = h })

Its geometry reads back off the handle. renderer.mesh.geometry(mesh) returns the same flat-array MeshGeometry create takes, carrying every stream the mesh has — so a runtime mesh can be inspected, edited, and rebuilt without ever becoming an asset:

local geom = renderer.mesh.geometry(h)   -- { positions, indices, tangents?, ... }
geom.positions[1] = 0.5
local h2 = renderer.mesh.create(geom)

An optional stream is present only when the mesh carries one, so geom.tangents answers whether the mesh has a tangent basis and geom.uvs1 whether it has a second UV set.

Read geometry — meshRef:load()

:load() decodes data.zmsh into the CPU store and returns a CPU handle carrying vertexCount / indexCount:

MethodReturns
cpu:getVertices()array of { pos = {x,y,z}, normal = {x,y,z}, uv = {u,v} }, one per vertex
cpu:getTriangles()array of { v1, v2, v3 }, each a full vertex (the shape above)
cpu:getBounds(){ min = {x,y,z}, max = {x,y,z} } — the local-space AABB
cpu:geometry()the complete MeshGeometry — flat arrays, every stream the mesh carries
cpu:encode()the data.zmsh bytes
cpu:unload()drop this guid's CPU copy
local cpu = meshRef:load()
local v = cpu:getVertices()[1]    -- { pos = {x,y,z}, normal = {x,y,z}, uv = {u,v} }
local aabb = cpu:getBounds()
cpu:unload()

By default the CPU copy is transient (decode → read → unload). Set keepCpu to keep it resident — see Settings.

Write geometry back — meshRef.primary

The inverse of reading: encode a MeshGeometry and write it to the asset's primary content file. The assetType picks the write up and re-uploads the live GPU mesh, so the change shows without a reload:

local geom = renderer.mesh.geometry(meshRef)
geom.tangents = computeTangents(geom)          -- add a stream it did not carry
vfs.write(meshRef.primary, renderer.mesh.encode(geom), { overwrite = true })

meshRef.primary is the asset's data.zmsh path. This is the route for a stream with no setting of its own; keepCpu + meshRef:setVertices moves positions in place, and lightmapUvs regenerates the second UV set (both below).

GPU handle — meshRef:handle()

:handle() uploads the geometry (Disk → CPU → GPU) and returns a MeshHandle, cached on the asset so every consumer shares ONE GPU entry. A component does this for you when you pass it the mesh; call it directly only for the runtime/low-level path.

Settings — meshRef:settings() / meshRef:setSettings(patch)

A mesh's serializable settings live in its .metadata. :settings() returns them with defaults filled (cached on the asset's runtime, so repeat reads don't re-decode .metadata). :setSettings(patch) writes any subset — sibling settings are preserved, and an unknown key errors.

SettingTypeDefaultMeaning
keepCpubooleanfalseKeep the CPU geometry copy resident — for an in-use mesh — so geometry can be read repeatedly without re-loading.
lightmapUvsstring"keep"The mesh's second (lightmap) UV set. "keep" leaves the stored geometry's set as-is; "generate" unwraps a fresh non-overlapping set; "none" strips it. See below.
local s = meshRef:settings()            -- { keepCpu = false }
meshRef:setSettings({ keepCpu = true })

keepCpu is an active lifecycle control, and only ever holds geometry for a mesh that is in use (GPU-resident):

  • Setting it on while the mesh is in use loads the CPU copy immediately.
  • Setting it on for a mesh that is not in use loads nothing — the copy is retained the next time the mesh is materialised, so an unused mesh is never pinned in memory.
  • Setting it off drops a resident CPU copy (the renderer keeps drawing from the GPU).

Second UV set (lightmapUvs)

A mesh can carry a second, independent UV set (uv1) alongside its texture UVs (uv) — a distinct per-vertex channel, non-overlapping where the texture UVs tile and reuse charts. lightmapUvs controls whether the stored geometry keeps one, generates one, or drops it:

ValueEffect
"keep"Leave the stored geometry's uv1 as-is (the default).
"generate"Unwrap a fresh non-overlapping uv1 (near-planar charts packed into [0,1] with gutters).
"none"Strip the uv1 set.
meshRef:setSettings({ lightmapUvs = "generate" })

Changing the setting reprocesses the asset's geometry in edit mode; a change in play mode never rewrites the source. The reprocess round-trips the geometry through the codec, so positions, normals, tangents, skinning, and the skeleton are preserved — only the uv1 set changes. A runtime mesh carries uv1 the same way: pass uvs1 (or unwrapUvs = true) to renderer.mesh.create.

Read vertices — meshRef:getVertices()

With keepCpu on for an in-use mesh, read its vertices straight from the resident CPU copy:

meshRef:setSettings({ keepCpu = true })   -- on an in-use mesh
local verts = meshRef:getVertices()       -- { { pos = {x,y,z}, normal = {x,y,z}, uv = {u,v} }, ... }

If the CPU copy isn't resident — keepCpu is off, or the mesh isn't in use — it errors loudly, naming the reason and the remedy, rather than silently loading a throwaway copy:

mesh:getVertices: cannot read vertices — keepCpu is off, so no CPU copy is kept.
Turn on CPU retention for an in-use mesh first: meshRef:setSettings({ keepCpu = true }).

Morph targets — meshRef:morphTargets()

A mesh can carry morph targets: shapes it blends towards, each a per-vertex displacement from the base geometry. Every target carries the name it was authored under, and an imported model keeps the one its source file gave its blend shapes, so content addresses a shape as the thing it means rather than as the ordinal it happened to import at.

for i, name in meshRef:morphTargets() do print(i, name) end   -- 1  Eyes_Blink

An entity blends them with ecs.MorphWeights, weight i driving target i. renderer.mesh.morphWeights(mesh, { Eyes_Blink = 1 }) builds that ordered array from weights named by shape — shapes left out sit at 0, and an unknown name errors listing the ones the mesh carries.

Discovery

CallShows
asset.list("mesh")every registered mesh
asset.inspect("<name>")a text report about the mesh (metadata, source, status)
meshRef:settings()the resolved settings
meshRef:morphTargets()the shapes it blends towards, named, in target order

For the on-disk shape of a mesh asset, run asset.inspect("<name>") or read the type's type.yaml — those are the live source of truth.

  • renderer.mesh.* — the GPU/CPU resource layer (create a runtime GPU mesh, encode/decode the ZMSH payload, readback GPU → CPU). :load/:handle wrap it; see man renderer.
  • .material + .shader — what shades the geometry.
  • .bundle — container assets that carry .mesh interiors.
  • asset-type
  • reference