Builtin Library
readme.md
terrainSystem
Ground. A landscape is built as a procgen graph, baked into a
.terrainData asset, and stood on an entity by the Terrain
component: three steps, each of which is the same move every other
kind of content in the engine makes.
A self-contained package: every asset the terrain layer needs lives
inside this folder, addressed through the package self-alias
~terrainSystem so the library stays portable no matter what name
the host project imports it under.
The graph
A terrain graph is return proc.define(function(g) ... end) like any other. It opens a
rect of ground with g.terrain.source({ size = ..., resolution = ... }), shapes it with
nodes that each take the raster and answer it changed (g.terrain.fbm, .cone, .ridge,
.coast, .erode), floods it (.lake, .sea, .river), writes the coverage of each
surface as a mask layer (g.terrain.mask), and ends in g.terrain.write, which bakes the
raster into a .terrainData and answers a reference to it. A graph that stands the landscape
in the scene passes that reference to g.prefab.from_asset and shows the prefab through
g.io.output. g.terrain.landscape ends a graph with the ground as one element carrying
Terrain, which g.io.output stands and g.io.export saves with its .terrainData
inside; the Landscape node asset (procNodes/Landscape.procNode) is that ending under
the name g.Landscape.
The nodes shape a proc.raster Raster. terrain.write bakes it into a .terrainData
container of ordinary .texture assets, and the baked asset instantiates as a Terrain
entity. The component reads the container and draws it as ONE indexed draw through a
quadtree LOD cut that refines under whichever camera is looking at it.
What's in here
| Folder | Role |
|---|---|
nodes/terrain.nodebox/ | The terrain nodes a graph calls as g.terrain.<name>: landforms, noise, masks, water, erosion, scatter, and the nodes a landscape is built from. |
procPacks/terrain.procPack/ | Registers the terrain.* op family from modules/terrainOps. |
records/terrain.procRecord/ | The landscape record kind terrain.landscape answers: stands live as a scratch .terrainData, saves as one inside the folder of the asset being exported. |
procNodes/Landscape.procNode/ | The canonical graph ending: the raster as one element carrying Terrain. |
modules/terrainOps.module/ | The op family: the shapes, the noise, the masks, the water, the erosion, the scatter, and terrain.write. |
assetTypes/terrainData.assetType/ | The .terrainData container: heightfield, control textures, holes mask, water surface and layer table, as one asset. |
components/Terrain.component/ | What makes an entity a landscape: reads a .terrainData, draws it, collides it, answers about it. |
components/TerrainStream.component/ | A landscape many kilometres across, standing in tiles that load, draw and collide around the player, the camera and every vehicle, within a per-frame budget. |
modules/terrainStream.module/ | The streamed landscape itself: its source, its tiles, the one LOD cut across them, the frame budget and what it reports. |
modules/terrainHeightfield.module/ | A landscape read as ground: the texels on the GPU for the mesh kernel and on the CPU for queries, from one read. |
modules/terrainMesh.module/ | The LOD vertex pool and quadtree cut that put a heightfield on screen as one draw. |
modules/terrainWater.module/ | The water surface a landscape carries: the same machinery, plus a skirt to the horizon and a shore it ends at. |
modules/terrainSurface.module/ | The layer table as the material that shades it: control textures bound as weights, layer textures bound as arrays. |
computeShaders/ | The GPU kernels the ops evaluate and the mesh builds through. |
materials/ | The built-in ground, splat and water materials. |
shaders/terrainBlend.shader/ | Altitude/slope-blended terrain shader with cliff override. |
tests/ | Locks the pack and the landscape path in against a running engine. |
demo/textures/ | Five ground surfaces as albedo + normal pairs, for a landscape's layer table and for anyone wanting a real image. |
documentation/ | Mental model, asset-format spec, authoring guide. |
package.yaml | Package manifest. |
The terrain nodes a graph calls live in this package's terrain.nodebox
(nodes/terrain.nodebox/), each called as g.terrain.<name>; its README lists
them by movement.
How to import it
The package publishes nothing as a global. Require what you need:
local proc = require("~procgen.proc") -- the graph
local terrainHeightfield = require("~terrainSystem.modules.terrainHeightfield")
local terrainMesh = require("~terrainSystem.modules.terrainMesh")
A graph needs no require for the terrain nodes: g.terrain.<name>(...) resolves them by
their asset identity from anywhere.
A host project that wants terrainSystem as a global is free to rawset it
in its own prelude. The library deliberately leaves that choice to
the importer because the name belongs to the importer, not to us.
Inside the package, modules reference each other through the relative
package alias ~:
local terrainMesh = require("~.modules.terrainMesh")
local DEFAULT_MATERIAL = "~terrainSystem.materials.terrainStandard"
~ resolves to the enclosing package, whatever the host called it
when they imported the library, so no @<lib>:: prefix is hard-coded in the
package's source.
Layering at a glance
┌───────────────────────────────────────┐
authoring │ a procgen GRAPH │
│ terrain.source → fbm / cone / mask /│
│ coast / erode / scatter → … │
└───────────────────┬───────────────────┘
│ terrain.write
┌───────────────────▼───────────────────┐
content │ <name>.terrainData │
│ height.texture control*.texture │
│ holes.texture water.texture │
│ slice_*.texture + the layer table │
└───────────────────┬───────────────────┘
│
┌───────────────────▼───────────────────┐
ECS entity │ components/Terrain.component │
│ public.terrain (AssetRef) │
│ public.material (AssetRef?) │
└───────────────────┬───────────────────┘
│
┌───────────────────▼───────────────────┐
runtime │ modules/terrainHeightfield (the read) │
│ modules/terrainMesh (LOD, one draw) │
└───────────────────┬───────────────────┘
│
┌───────────────────▼───────────────────┐
engine │ generic primitives, nothing terrain- │
│ specific: computeShader dispatch, │
│ substrate buffers, renderer.mesh │
└───────────────────────────────────────┘
The whole system is content, and every layer above reaches the engine only through primitives any package could use. Terrain is a FAMILY OF NODES inside procgen, and what makes it terrain is what its nodes write and what its container holds.
What a landscape graph does
- Opens the ground.
terrain.sourcetakessize(metres along x and z),resolution(metres per cell) and an optionalorigin, and answers the raster with itsheightlayer. - Shapes it. Landform nodes (
fbm,cone,ridge,ramp,flatten,terrace,coast) blend into the ground the node before left, each under its ownblend.terrain.erodeweathers whatever stands when it runs. - Floods it.
terrain.lakefinds the shore from the ground: the water fills the basin its seed sits in to a level and stops where the ground rises.terrain.seaandterrain.riverwrite the samewaterandwaterMasklayers. - Says what covers where. Coverage per surface is an ordinary mask layer
written by
terrain.mask; naming it interrain.write'slayersmakes it a surface of the baked landscape. - Fills it.
terrain.scatterputs points across the surface weighted by a mask layer and banded by slope and height,terrain.drapeputs anyone else's points down onto it, and from there theinstancesnodes andbundle.spawnare the ordinary path every other family takes. - Bakes it.
terrain.writetakes the raster, aname, thelayers(the mask layer names holding each surface's coverage, in channel order) and optionalsurfacesrecords ({ name, tint?, albedo?, normal?, mask?, tiling? }), and answers the reference to the<name>.terrainDatait wrote.
Once baked, the landscape is an asset like any other:
local island = asset.resolve("island", "terrainData")
local e = island:instantiate()
-- Sample it for placement.
local y = e.component.get("Terrain"):heightAt(playerX, playerZ)
A landscape larger than one heightfield
Terrain stands one heightfield whole. A map of many kilometres is a
TerrainStream instead: its ground comes from a source answering for any point
(a height(x, z) function, or one .terrainData baked per tile over
terrainStream.tileSource), and its tiles stand only around the places it is
needed:
entity(id).component.add("TerrainStream", { size = { 8192, 8192 }, tileSize = 512, resolution = 2 })
entity(id).component.get("TerrainStream"):setSource({
height = function(x, z) return 60 * math.noise(x / 900, z / 900) end,
})
player.component.add("StreamingSource", {}) -- the ground collides under the player
Tiles within loadRadius of a focus are drawn, the ones within
colliderRadius of an entity focus collide, and the ones past unloadRadius
are let go of. The tiles share one LOD cut, so no edge between two tiles
cracks, and every tile samples one grid of points, so the colliders of two
tiles meet sample for sample. heightAt answers anywhere on the map whether
the tile under the point is standing or not.
The round trip
Ground goes out of the graph and comes back in through the same pair of nodes, so a landscape made anywhere is a landscape these nodes can carve:
Raster ──► terrain.write ──► <name>.terrainData ──► Terrain component ──► on screen
│
▼
terrain.from_texture ◄── any heightfield image
Re-baking a landscape under the same name keeps its guid, so every reference
already pointing at it stays resolved and every entity standing on it moves when
it is reloaded. assetTypes/terrainData.assetType/README.md has the manifest
and the channel layout of each payload.
Related types
~terrainSystem.assetTypes.terrainData: the container this system writes and reads.~procgen.proc: the graph, the registry and theRasterevery terrain node takes and returns.renderer.mesh(engine global): the LOD pool becomes a guid-keyed GPU mesh throughrenderer.mesh.create.