Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit8d45df8664

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.