---
title: "Morph targets, clusters and lines"
description: "Part of the rendering guide (guides { path = \"topics/rendering\" }), which lists every part."
section: "Topics"
slug: "topics-rendering-geometry"
canonical: "https://origozero.ai/docs/topics-rendering-geometry"
updated: "2026-10-19T13:22:55.546182010+00:00"
tags: ["documentation", "guide", "rendering"]
---

# Morph targets, clusters and lines

## Morph targets — a mesh blending toward other shapes

A mesh can carry morph targets: per-vertex position and normal deltas that
describe a shape it can blend toward. An entity carries how strongly each is
blended, as `ecs.MorphWeights`, and the vertex stage draws the base shape plus
the weighted sum of the targets. Weight `i` drives target `i`, in the order the
mesh declares them.

```lua
local mesh = renderer.mesh.create({
    positions = P, indices = I, normals = N,
    morphTargets = {
        { name = "smile", positions = smile },
        { name = "frown", positions = frown },
    },
})
ecs.insertSync(face, ecs.MorphWeights { weights = { 0.8, 0.0 } })
```

A target carries the name it was authored under, and an imported model keeps the
one its source file gave — an FBX blend-shape channel, a glTF target name. That
is what makes a shape addressable as itself: an ordinal is a property of import
order, so it moves when the model is re-exported with a shape inserted, while
`Eyes_Blink` stays `Eyes_Blink`. `renderer.mesh.morphTargets` reads the names in
target order, and `renderer.mesh.morphWeights` turns weights named by shape into
the ordered array the component takes:

```lua
for i, name in renderer.mesh.morphTargets(meshRef) do print(i, name) end

ecs.set(face, ecs.MorphWeights {
    weights = renderer.mesh.morphWeights(meshRef, { Eyes_Blink = 1, Mouth_Smile = 0.4 }),
})
```

Every target the mesh carries gets a slot; the shapes left out sit at 0. A name
the mesh does not carry errors listing the ones it does, so a mistyped viseme
says so instead of quietly moving nothing.

A blend costs one entry per target its weights move — the mesh's own target
count is what it may address, and how many of those move at once is what the
frame pays for. So a facial rig carrying dozens of shapes costs the few it is
holding, and a mesh may declare as many targets as its delta block fits in
the delta budget below.

```lua
local m = renderer.morphStats()
print(("%d slots carrying %d blends over %d meshes (%d targets, %.1f KiB)")
    :format(m.instances, m.blends, m.meshes, m.targets, m.deltaBytes / 1024))
```

`instances` is how many render slots the stage deformed and `blends` how many
single-target blends they carried between them — a mesh with 40 targets whose
weights move 3 of them contributes 3. `meshes` / `targets` / `deltaBytes` are
the residency of the delta blocks themselves, which a mesh pays for once
however many entities blend it.

Every mesh's block lives in one shared buffer the vertex stage binds whole, so
the blocks together are held to `deltaBudgetBytes`: the largest storage buffer
the device binds at once, 128 MiB on many mobile browsers. A block costs 32
bytes per vertex per target over every vertex of the mesh, so 67 targets on a
50,000-vertex body take about 107 MB, and the same targets on a separate
8,000-vertex face mesh about 17 MB. A mesh whose block would take the resident
blocks past the budget is refused: the engine log names the mesh, its block and
the budget, the mesh draws in its base shape, and `refusedMeshes` counts it. The
refusal holds until the mesh is uploaded again, after which it is measured
against the room the other blocks leave. `deltaBufferBytes` is the buffer's
size, which never exceeds the budget.

```lua
local m = renderer.morphStats()
print(("%.1f of %.1f MiB of morph deltas, %d meshes refused")
    :format(m.deltaBytes / 2^20, m.deltaBudgetBytes / 2^20, m.refusedMeshes))
```

The deformation reaches the visibility raster, the colour passes and every
shadow page, so a morphed surface lays the depth of its morphed shape, casts the shadow of it, and reports the motion its own change of
shape made. The box the instance is tested against carries the blend as well:
the mesh's stored bounds grown, per target, by the extreme its weighted delta
reaches, so a blend that carries a surface into the frustum keeps it in the
frame and the same instance at weight zero is rejected against the stored shape.

## Static geometry is drawn as clusters

Every static mesh the renderer holds — opaque or cutout, triangle lists, on a generated surface shader — is drawn from a list of *clusters* the GPU chooses each frame, not one draw per object. A mesh is split into clusters of up to 124 triangles when it uploads, and a bake builds coarser levels in the background; until the bake lands the mesh draws its full-detail set, so nothing waits for one.

Each frame, for every camera view, the engine tests every instance (hidden, render layer, `VisibilityRange` from that view's camera, frustum, screen size, occlusion) and then every cluster of the instances that pass: the level-of-detail cut, the frustum, facing away, occlusion. What survives is drawn with one indirect draw per material pipeline per pass.

- `renderer.clusterErrorPx(px)` sets how coarse the cut may go, in pixels of the view drawing it (default 1). It is a graphics setting of this client.
- `renderer.cullStats().views` reports what the cull did in each view; `renderer.drawStats().clusters` what the camera views drew.
- `renderer.mesh.clusters(mesh)` says which set a mesh draws (`lod0` while baking, `single`, `dag`) or why it is not drawn as clusters; `renderer.mesh.clusterStore()` what the geometry heap holds, including `unbakedBytes` — geometry drawn at full detail while its bake is outstanding.
- A mesh asset's `clusterLod` setting chooses the LOD hierarchy (`true`) or one level at full detail (`false`).

Skinned, morphing and compute-written meshes, blended materials, line materials and shaders with a hand-written vertex stage keep drawing one draw per batch.

A shader whose `vertex()` or `vertex_clip()` hook moves geometry declares how far with a `vertexBoundsDilation` float property (world units), which each material sets; the cull grows every bound of a material's geometry by that material's value. Without it, geometry the hook pushes outside the mesh's bounds disappears at the screen edge and behind occluders.


## Drawing lines and wireframes

Lines, paths, gizmos and wireframe geometry draw through assets the library
already ships, each documented in its own README:

- **`@builtin::materials.solid_line`**, a flat, opaque line surface for gizmos,
  paths and debug geometry, on **`@builtin::shaders.line_material`**, an unlit
  shader that outputs `base_color` as a solid colour.
- **`render.topology`** on any material: `lineList` (also spelled `lines` or
  `line`) assembles consecutive vertices into line segments instead of
  triangles. It sits in a `mat.yaml`'s `render:` block, `asset.create`'s
  `render` argument and `renderer.material.create`'s `render` table alike; the
  `.shader` type README's "The render block" lists every key.
- **`@builtin::modules.debugDraw`**, builders that append line-soup geometry:
  `appendLine` (one segment), `appendBox` and `appendOrientedBox` (wireframe
  boxes), `appendWireSphere`, `appendWireCapsule`, `appendCross`,
  `appendOctahedron`, and camera-facing `appendBillboard` quads. The vertex
  bytes it packs draw through a Draw pass with a line-list material.
- **`@builtin::modules.debugViz`**, the shared edit-mode batch every editor
  gizmo draws through: a provider registered with
  `debugViz.registerProvider(name, fn)` is handed `emit.line(a, b, color)` and
  `emit.box(min, max, color)` each tick, and `@builtin::renderFeatures.debugViz`
  draws the whole batch as GPU lines. `emit.shape(entityId, key, bytes,
  vertexCount)` draws one local-space line blob through every entity that asks
  for it, so geometry shared by many entities lives in one buffer.
  `@builtin::modules.debug_wireframe` is the provider that draws one line per
  triangle edge of every scene mesh that way.
- **Render features** (`topics/rendering-render-features`) for a wireframe or outline look a
  custom pass produces over the scene.

`asset.inspect` reads each of them by the identity named here, for example
`asset.inspect("@builtin::modules.debugDraw")` for the builders' signatures.
