Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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

FolderRole
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.yamlPackage 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.source takes size (metres along x and z), resolution (metres per cell) and an optional origin, and answers the raster with its height layer.
  • Shapes it. Landform nodes (fbm, cone, ridge, ramp, flatten, terrace, coast) blend into the ground the node before left, each under its own blend. terrain.erode weathers whatever stands when it runs.
  • Floods it. terrain.lake finds the shore from the ground: the water fills the basin its seed sits in to a level and stops where the ground rises. terrain.sea and terrain.river write the same water and waterMask layers.
  • Says what covers where. Coverage per surface is an ordinary mask layer written by terrain.mask; naming it in terrain.write's layers makes it a surface of the baked landscape.
  • Fills it. terrain.scatter puts points across the surface weighted by a mask layer and banded by slope and height, terrain.drape puts anyone else's points down onto it, and from there the instances nodes and bundle.spawn are the ordinary path every other family takes.
  • Bakes it. terrain.write takes the raster, a name, the layers (the mask layer names holding each surface's coverage, in channel order) and optional surfaces records ({ name, tint?, albedo?, normal?, mask?, tiling? }), and answers the reference to the <name>.terrainData it 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 the Raster every terrain node takes and returns.
  • renderer.mesh (engine global): the LOD pool becomes a guid-keyed GPU mesh through renderer.mesh.create.