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.
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().viewsreports what the cull did in each view;renderer.drawStats().clusterswhat the camera views drew.renderer.mesh.clusters(mesh)says which set a mesh draws (lod0while baking,single,dag) or why it is not drawn as clusters;renderer.mesh.clusterStore()what the geometry heap holds, includingunbakedBytes— geometry drawn at full detail while its bake is outstanding.- A mesh asset's
clusterLodsetting 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 outputsbase_coloras a solid colour.render.topologyon any material:lineList(also spelledlinesorline) assembles consecutive vertices into line segments instead of triangles. It sits in amat.yaml'srender:block,asset.create'srenderargument andrenderer.material.create'srendertable alike; the.shadertype README's "The render block" lists every key.@builtin::modules.debugDraw, builders that append line-soup geometry:appendLine(one segment),appendBoxandappendOrientedBox(wireframe boxes),appendWireSphere,appendWireCapsule,appendCross,appendOctahedron, and camera-facingappendBillboardquads. 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 withdebugViz.registerProvider(name, fn)is handedemit.line(a, b, color)andemit.box(min, max, color)each tick, and@builtin::renderFeatures.debugVizdraws 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_wireframeis 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.