Builtin Library
- ..
- README.md8.3 KB
- init.luau34.6 KB
- quadtree.luau27.9 KB
README.md
terrainMesh
Puts a heightfield on screen as one draw.
What it is
A vertex pool of maxNodes slots, each a fixed nodeVerts square grid
(33x33 by default). A quadtree cut decides which parts of the ground are
drawn at which density: near nodes are small and dense, far nodes large and
sparse, and every node meshes to the same grid, so a node costs the same
wherever it is and the cut stays a few hundred nodes at any camera distance.
terrain_mesh fills the slots the cut needs; terrain_index writes the index
run over them; renderer.mesh.create { vertexBuffer, indexBuffer } turns the
pair into one mesh. One entity carries one Model bound to it, so the whole
terrain — every node, at every level — is one indexed draw.
Nodes are never entities. Entity count is the binding constraint on this renderer; a per-chunk entity would put a hundred draws where this puts one. A step that seems to need one is a step that has the design backwards.
A cut change calls renderer.mesh.update under the same guid, so the Model
is never re-bound and the scene graph is never touched.
Using it
local terrainHeightfield = require("~terrainSystem.modules.terrainHeightfield")
local terrainMesh = require("~terrainSystem.modules.terrainMesh")
local hf = terrainHeightfield.open(groundTexture, { size = { 2048, 2048 } })
local mesh = terrainMesh.create(hf, { camera = terrainMesh.cameraPosition() })
terrainMesh.updateCut(mesh, hf, { x = 900, y = 30, z = 900 })
The Terrain component does all of this from a .texture on an entity and
follows the on-screen camera with the cut every frame, in edit and in play
alike. Reach for the module directly only when the ground is not an entity's.
The vertices
terrain_mesh writes the engine's 23-word vertex layout, one thread per node
vertex, positions in WORLD space — so the entity carrying the mesh stands at the
world origin with no transform of its own.
It reads the heightfield's texels as one array over the whole rect. The height
is the texel cell around a position read as two flat triangles, split along the
diagonal from its corner one texel along +Z to its corner one texel along +X:
the surface terrainHeightfield.cellHeight answers and the terrain's collider
holds.
Every vertex stands on a stop of the collider's lattice: the rect's two edges
and the texel centres along each axis. A node's grid places each vertex and the
vertex takes the lattice stop nearest that place, so a node whose vertices are
the texel spacing apart or closer draws exactly the collider's cells, split
along the diagonal the collider splits them along, and a point anywhere on its
triangles stands at the height a body rests at and heightAt answers, save
the strip of cells along an edge stitched to a coarser neighbour, which follows
that neighbour's edge. Grid vertices that land on one stop draw triangles with
no area, which rasterise nothing. A coarser node draws a subset of the same
lattice, and differs from the ground by its level of detail alone.
A vertex is placed from whole indices counted from the corner of the rect the cut tiles, in steps of its node's vertex spacing, so a vertex two nodes share (across an edge, or a child's vertex and the parent's it morphs onto) is computed from the same numbers by both and lands on the same stop. A cut whose nodes do not stand on that grid is refused by name. The normal comes from the gradient those same four texels already carry, blended across them: four reads a vertex rather than the twenty a central difference of the height would cost.
Holes
terrain_index writes one node's triangles, and a quad whose ground the
landscape cuts out is written DEGENERATE — six copies of one index — rather than
left out. The run is a fixed stride per node, so a quad that vanished would
shift every quad after it; a triangle whose three corners are one point covers
no pixels and is discarded before it rasterises. A hole therefore costs the
index slots it already owned and nothing else.
The quad's own centre decides, so the edge of a hole follows the mask at the density the node is meshed at. The run carries the heightfield's generation as well as the slot order, so a mask re-read under a standing terrain moves the hole with it.
Cracks and popping
Two things happen at node edges, and both are in terrain_mesh:
- Stitching. The cut is balanced — no leaf borders a neighbour more than one level coarser — and each node carries the mask of which of its four edges do. On such an edge the fine node's odd-indexed vertices snap onto the previous even one, so the fine edge becomes the polyline through the coarse neighbour's own vertices. The two edges are then the same line.
- Geomorphing. Over the far end of the distance band a node is a leaf across, its vertices slide onto the positions they would hold one level coarser. By the time the cut merges the node into its parent, the node is already drawing the parent's geometry. The morph runs off each vertex's own distance to the camera, so a vertex two nodes share is placed identically by both.
What re-meshes, and when
A node is re-meshed when it is new to the cut, when its coarse-neighbour mask
changes, when the camera moves (the geomorph is camera-relative), or when the
heightfield's gen has moved past the generation that node was last built at.
Nothing else touches it — updateCut from a viewpoint the cut was already taken
from remeshes zero nodes and reports changed = false.
terrainMesh.invalidate(state, rect) marks the nodes overlapping a world rect,
for when something changed the ground in one place.
Nodes that are not drawn
create takes a keep predicate — (node) -> boolean — and the cut then
covers whatever it keeps rather than the whole rect. That is what lets the
terrain END before the grid does: an island's seabed is ground nobody can see,
and ground nobody can see is ground nothing has to mesh.
The predicate is a parameter of selection rather than a filter applied afterwards, because the node budget is spent on what is drawn: a cut that coarsened to fit nodes it then threw away would be spending the budget on seabed. It runs after the tree is balanced, so what it drops cannot unbalance what remains, and a node whose coarser neighbour was dropped has nothing on that side to crack against.
Ground that is not square
The quadtree tiles the rect with square root nodes, and every rect of any
width and depth has a root grid (quadtree.rootGrid). A square rect is one
root. An oblong one roots on the largest measure both sides divide into while
that measure is at least minNodeSize and the grid holds at most a quarter of
the pool (an 800 x 600 m rect roots as twelve 200 m squares). Otherwise the
root is the shorter side, or the longer side over that quarter if larger, and
the grid covers the rect: its last row or column reaches past the far
edge. A cut leaves out every node standing wholly past the rect, a node
across its edge refines by the distance to the part of it the rect holds, and
the mesh puts that node's vertices past the edge on the edge, so the ground
ends exactly where the rect does. A 210 x 170 m rect roots as two 170 m
squares, 2048 x 3000 m as two of 2048 m, and 1000 x 3700 m as four of
1000 m. A pool given a rect cuts and roots that rect the same way, and
the mesh holds every vertex to it, so the ground it draws ends on that rect's
edges. The water surface roots its rect the same way.
rootSize states the square instead. A stated square past the pool is
refused, naming the size, the squares it makes and the pool.
The bounding box
The mesh's box is the heightfield's rect by its declared heightRange. It is
taken from the declaration rather than reduced from whatever is drawn, because a
box that is too small culls the terrain away at grazing camera angles and the
symptom is nothing at all on screen.
Entity binding
terrainMesh.bind(entityId, hf, state) records that an entity's geometry IS this
heightfield drawn through this pool. forEntity reads it back, isBound asks,
and unbind releases the mesh, the pool and the heightfield's pixels together.
followCamera(entityId) is the per-frame entry the Terrain component calls.