---
title: "mesh"
description: "A mesh is renderable geometry — vertices and triangle indices, and, for a skinned mesh, the per-vertex skinning data that binds it to a skeleton. A component that draws geometry references a mesh,…"
section: "Types"
slug: "types-mesh"
canonical: "https://origozero.ai/docs/types-mesh"
updated: "2026-08-25T21:06:38.267288559+00:00"
tags: ["asset-type", "reference"]
---

# 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)? 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:

```luau
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:

```luau
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:

```luau
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:

```luau
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:

```luau
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:

```luau
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 |

```luau
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:

```luau
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. |

```luau
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. |

```luau
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:

```luau
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.

```luau
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 (`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.
