Builtin Library
README.md
terrainHeightfield
A .texture standing over a rect of world, read as ground.
The durable form of a landscape is an ordinary texture asset — one with a guid,
that a component references, that travels with a world, that the renderer
already knows how to sample. terrain.to_texture is what puts a graph's raster
into one and texture.write persists it. This module is the other end: what a
runtime holds while that ground is on screen.
local terrainHeightfield = require("~terrainSystem.modules.terrainHeightfield")
-- A `.terrainData` states the rect it covers, so opening one needs nothing else.
local hf = terrainHeightfield.open("/zero/source/terrain/dunes.terrainData")
-- The heightfield alone can be read too; then the caller says where it stands.
local bare = terrainHeightfield.open(tex, { size = { 4000, 4000 }, origin = { 0, 0 } })
local y = hf:heightAt(120, -60) -- metres
local nx, ny, nz = hf:normalAt(120, -60)
local slope = hf:slopeAt(120, -60) -- radians
hf:close()
The texels
rgba32f, in the layout terrain.to_texture writes:
| Channel | Holds |
|---|---|
| R | the height, in METRES — it goes negative |
| G | the gradient along world X |
| B | the gradient along world Z |
| A | coverage |
A height's interesting relief is often a few metres inside a range of hundreds,
so eight bits band it into terraces; that is what the precision is for. The
gradient is what a surface normal comes out of without differencing the height
four more times, and it is why terrain_mesh costs four texel reads a vertex
rather than twenty.
A heightmap authored anywhere else — an 8-bit image, a downloaded elevation
tile, a raster an earlier graph baked — becomes one of these by going through a
graph: terrain.from_texture reads it as ground, and terrain.to_texture
writes the result back out at this precision.
Two carriers, one set of texels
An open heightfield holds the texture in both forms the runtime reads it in:
hf.texels— a GPU buffer of the texel bytes, which theterrain_meshcompute kernel builds vertices out of.hf.cpu— the pixels, whichheightAt/normalAt/slopeAt/sampleGridanswer from.
Both are the same texels, uploaded from one read, so what the ground LOOKS like and what a query SAYS about it cannot drift apart.
The pixels are the heightfield's own copy, decoded from the asset when it opens
and held in a buffer for as long as it stands, and so are its holes mask, its
water surface and its layer weights. A heightfield answers in edit, in play and
after play, whatever else loads or unloads the same texture. The cost is one
copy a heightfield: 16 bytes a texel for the heights and the water, 4 for the
holes mask and each layer-weight texture. Two heightfields open on one landscape
hold two copies, which is also what keeps an edit to one (setHeights) to
that one.
The surface
| Verb | |
|---|---|
open(ground, opts?) | Read a .terrainData — or the heightfield .texture alone — as ground. A container carries its own { size, origin, heightRange }; a bare texture takes them from opts, and an explicit opt outranks the container either way. |
hf:heightAt(x, z) | Metres: the texel cell around the point read through cellHeight, the surface the terrain's collider holds. |
hf:normalAt(x, z) | Unit normal, three numbers, from the carried gradient. |
hf:slopeAt(x, z) | Radians; 0 is flat. |
hf:sampleGrid(cols, rows) | Row-major heights over the whole rect at any sample count, read through heightAt. |
hf:colliderTiles(perTile?) | The heightfield collider tiles that hold this ground exactly: samples on the texel centres, a half-texel strip out to each edge. Each tile is { minX, minZ, sizeX, sizeZ, xs, zs }. |
hf:texelLattice(xs, zs) | Row-major heights of the texels at the named columns and rows, as they are: a tile's grid from colliderTiles. |
terrainHeightfield.cellHeight(h00, h10, h01, h11, fx, fz) | The ground inside one cell from its four corner heights. |
hf:hasGroundAt(x, z) | Whether there is ground at all — false over a hole. |
hf:refresh() | Read the texture's pixels again and put them back on the GPU. Bumps gen. |
hf:close() | Let go of the pixels and the GPU buffer. The asset is untouched. |
heightRange is the vertical reach the geometry declares, which becomes the
mesh's bounding box — a range smaller than the ground culls the terrain away at
grazing angles. Left out, it is read off the pixels, which costs one pass over
them.
One surface
The ground between texel centres is two flat triangles a cell, split along the
diagonal from the cell's corner one texel along +Z to its corner one texel along
+X: the split a Rapier heightfield collider makes, so a collider built from
colliderTiles meets a ray at the height heightAt answers there, to float
precision. The terrain_mesh kernel stands every vertex on a stop of the
collider's lattice (the rect's edges and the texel centres), so a node meshed at
the texel spacing or finer draws the collider's own triangles (save the strip
along an edge stitched to a coarser neighbour, which follows that neighbour's
edge), and a coarser one differs from it only by the level of detail it is cut
to.
Past the outermost texel centres the ground runs level out to the rect's edge,
for the query, the collider and the mesh alike.
Holes
A landscape can be CUT OUT — a cave mouth, a quarry, a shaft — and its holes
mask is carried the same two ways the heightfield is: on the GPU for the index
kernel, which writes the quads it cuts out as degenerate triangles, and on the
CPU for hasGroundAt. Same pixels, so a place with no surface to draw is a
place nothing can be stood on either, and scatter that asks before it places
never drops a tree into a shaft.
A landscape with no mask is solid everywhere and answers hasGroundAt true
without reading anything. The edge of a hole follows the mask at the density the
node is meshed at, so a hole is quad-shaped at the LOD it is seen from.
Generations
hf.gen counts the times the texels have been read. terrainMesh records the
generation each node was meshed at, so re-authoring the ground under a standing
terrain and calling hf:refresh() brings the geometry with it — nobody has to
say which nodes changed.