mesh
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)? Readcore/resource-modelfirst.
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:
| Field | Shape | Notes |
|---|---|---|
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 |
unwrapUvs | boolean | generate uvs1 when it is absent |
skinning | { joints, weights } | per-vertex skin binding (four each per vertex) |
skins | array of skin tables | the skeleton (see renderer.mesh.decode for the shape) |
morphTargets | array 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.
| Convention | How the engine reads the geometry |
|---|---|
| Index base | indices 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 winding | A 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 faces | A 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. |
| Normals | normals 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. |
| Axes | Model 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:
| Mesh | Extent (x, y, z) | Local AABB |
|---|---|---|
cube | 1 x 1 x 1 | -0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5 |
sphere | 1 x 1 x 1 | -0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5 |
cylinder | 1 x 1 x 1 | -0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5 |
cone | 1 x 1 x 1 | -0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5 |
octahedron | 1 x 1 x 1 | -0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5 |
rounded_box | 1 x 1 x 1 | -0.5, -0.5, -0.5 .. 0.5, 0.5, 0.5 |
capsule | 0.5 x 1 x 0.5 | -0.25, -0.5, -0.25 .. 0.25, 0.5, 0.25 |
torus | 1 x 0.3 x 1 | -0.5, -0.15, -0.5 .. 0.5, 0.15, 0.5 |
plane | 1 x 0 x 1 | -0.5, 0, -0.5 .. 0.5, 0, 0.5 |
ground_quad | 1 x 0.1 x 1 | -0.5, -0.05, -0.5 .. 0.5, 0.05, 0.5 |
ground_hex | 1.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:
| Method | Returns |
|---|---|
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.
| Setting | Type | Default | Meaning |
|---|---|---|---|
keepCpu | boolean | false | Keep the CPU geometry copy resident — for an in-use mesh — so geometry can be read repeatedly without re-loading. |
lightmapUvs | string | "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:
| Value | Effect |
|---|---|
"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
| Call | Shows |
|---|---|
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.
Related
renderer.mesh.*— the GPU/CPU resource layer (createa runtime GPU mesh,encode/decodetheZMSHpayload,readbackGPU → CPU).:load/:handlewrap it; seeman renderer..material+.shader— what shades the geometry..bundle— container assets that carry.meshinteriors.