Builtin Library
README.md
Tessellation
Compute-shader mesh subdivision with material-driven displacement.
Tessellation sits next to Model on a renderable. The engine's
tessellation system reads the entity's current mesh geometry,
subdivides each triangle into 4^level micro-triangles, displaces the
result along the interpolated normal from the entity material's
displacement map, and swaps the entity's MeshInstance.mesh for the
displaced derivative. The material is bound per-entity, so the
tessellated surface renders with the same PBR parameters and normal maps
as the source.
entity.find("floor").component.add("Tessellation", {
level = 4,
displacementScale = 0.05,
})
Fields
| Field | Type | Purpose |
|---|---|---|
level | number | Subdivision level (0..=6). Clamped when out of range, and getStatus() answers "clamped". 4^level micro-tris per source tri. Under mode = "adaptive" this is the ceiling. |
displacementScale | number | World-units of displacement per unit of sampled height. Sign matters — negative inverts inward. With no displacement map on the material, the surface is subdivided but left flat. |
mode | string | "static" or "adaptive"; any other value makes getStatus() answer "rejected_mode" and leaves the entity rendering the mesh it already had. |
screenError | number | Target micro-triangle edge in pixels, read under mode = "adaptive". Smaller buys more subdivision at a given distance; zero or less asks for level 0. Default 8. |
stitch | boolean | Place this surface's boundary on the coarsest lattice any patch sharing that boundary uses. Default true. |
Methods
The tessellation system writes what each bake did onto the entity's
native ecs.Tessellation component, and these methods read it back.
ecs.get(id, ecs.Tessellation) reads the same values as its status,
activeLevel, outputTriangles, outputVertices and stitchedEdges
fields.
| Method | Purpose |
|---|---|
tess:getStatus() | Read the latest status string: "" before the first dispatch, then "pending", "ok", "clamped", "too_large" or "rejected_mode". |
tess:getActiveLevel() | Read the level the installed mesh was baked at. |
tess:getTriangleCount() | Read the installed mesh's triangle count: source triangles × 4^activeLevel. |
tess:getVertexCount() | Read the installed mesh's vertex count: source triangles × the lattice a patch carries at activeLevel. |
tess:getStitchedEdges() | Read how many boundary edges the last bake placed on a coarser neighbour's lattice. |
Adaptive level
entity.find("cliff").component.add("Tessellation", {
mode = "adaptive",
level = 5, -- ceiling, reached up close
screenError = 8, -- micro-triangles ~8 px on an edge
})
Under mode = "adaptive" the level is re-derived each frame from how
much screen the surface covers: the entity's bounding sphere is projected
under the viewport camera's own projection (perspective or orthographic),
and the level is the one whose 4^level micro-triangles land near
screenError pixels on an edge — clamped to level. A surface therefore
gains detail as the camera approaches and sheds it on retreat. A sphere
the view frustum does not reach covers no pixels, so a surface behind or
outside the frame drops to level 0 until it comes back into view.
Sample getTriangleCount() across a dolly to watch it move. Two rules
bound the re-baking: a half-level dead band around the active level, so a
camera parked on a boundary holds one level, and a minimum spacing
between re-bakes, so a fast sweep through several levels re-bakes at a
bounded rate rather than every frame.
A whole entity holds one level, so the micro-triangles along every shared edge inside it match by construction. Where two entities meet, see Shared boundaries below.
Shared boundaries
A surface's outline — the edges only one of its triangles uses — is the only place another surface can meet it. Two surfaces whose outlines share an edge and subdivide it to different levels place their points along it on different curves: the finer one follows the displacement between the coarser one's samples, and the space between the two curves is a crack the background shows through.
Every frame, each tessellated surface's outline is placed in world space
and matched against the others'. Where a shared edge is found, the finer
surface bakes its points along that edge onto the coarser one's lattice,
so both trace one boundary. The rest of the finer surface keeps its own
level. getStitchedEdges() reports how many of its boundary edges were
placed this way, and a neighbour that changes level, moves away, or
appears re-bakes the surfaces that share an edge with it.
-- Two tiles meeting at x = 0, subdivided differently.
entity.find("tile_west").component.add("Tessellation", { level = 5, displacementScale = 1.5 })
entity.find("tile_east").component.add("Tessellation", { level = 2, displacementScale = 1.5 })
-- tile_west:getStitchedEdges() == 1 — its east edge is on tile_east's lattice.
Two outlines count as shared when both endpoints land on the same point
of a 0.1 mm world-space grid, which is what lets tiles authored to meet
match however each one is placed — the rounding each entity's own
transform leaves behind is orders of magnitude finer than one cell.
stitch = false leaves a surface subdivided uniformly to its own
level everywhere; its level is still what neighbours that do stitch clamp
themselves to.
Output size
Each source triangle's micro-vertices are the grid points of one shared
barycentric lattice: (n + 1)(n + 2) / 2 of them for n = 2^activeLevel,
carrying n^2 micro-triangles. Micro-triangles meeting along an edge
inside a patch therefore name the same vertex, and the index buffer names
lattice addresses — three per micro-triangle. At level 6 a patch is 2145
vertices for 4096 triangles. Read the two counts with getVertexCount()
and getTriangleCount().
Displacement
Displacement height comes from the entity material's displacement map
(a grayscale height texture). A material without one still tessellates —
the geometry is subdivided for a smoother silhouette — but stays flat.
displacementScale scales the sampled height into world units along each
micro-vertex's interpolated normal.
Limits
- Maximum subdivision level: 6 (= 4096 micro-triangles per source
triangle). Higher values are clamped, and
getStatus()answers"clamped". - Projected output cap: 64 MiB, counting the vertex buffer and the index
buffer together. A dispatch that exceeds this is skipped, and
getStatus()answers"too_large": lower the level or use a lower-poly source mesh.