Log inGet started

Morph targets, clusters and lines

Updated October 19, 2026

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.

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:

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.

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.

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.

  • documentation
  • guide
  • rendering