Builtin Library
README.md
TerrainStream (component)
Ground many kilometres across, streamed. Part of the terrainSystem package.
A Terrain is one heightfield standing whole. An entity carrying
TerrainStream is a landscape divided into square tiles that load, draw and
collide around the places it is needed and are let go of behind them, within a
per-frame budget, so the memory it holds follows the radius it streams rather
than the size of the map.
-- The ground, as a module: any function of x and z.
-- /source/modules/mountains.module returns { height = function(x, z) ... end }
entity(id).component.add("TerrainStream", {
size = { 8192, 8192 }, tileSize = 512, resolution = 2,
source = "modules.mountains",
})
-- Or at runtime, with no module at all.
local t = entity(id).component.get("TerrainStream")
t:setSource({
height = function(x, z) return 60 * math.noise(x / 900, z / 900) end,
layer = function(x, z) return if math.abs(z) < 4 then "asphalt" else "grass" end,
layers = {
asphalt = { tint = { 0.06, 0.06, 0.07 } },
grass = { tint = { 0.30, 0.45, 0.20 } },
},
})
The tiles stream around every entity carrying StreamingSource (the player, a
vehicle), around the on-screen camera (the editor camera in edit, the gameplay
camera in play), and around what addFocus names; focusPoints() lists those
places this frame, so content that streams beside the landscape (a road
surface, scenery) streams around the same ones. The ground near an entity
focus collides, in blocks of colliderCells cells; the camera only draws.
A procedural source's layer(x, z) names the surface each point is, and the
one function decides both what the ground is and how it looks: contacts and
casts on the ground report it, layerAt answers it, Physics.defineSurface
gives it its grip, and every tile draws it, at every level of detail, meeting
the tile beside it the way the heights do. A road painted into layer is
drawn as road where it collides as road, to within one sample spacing. A
surface is read once a sample, so its drawn edge runs midway between the
samples and steps with them: a finer resolution draws it finer, and a tile
held at a coarser level of detail draws it at that level's spacing.
The source's layers states what each surface looks like, in the shape a
baked landscape's layer table takes: { asphalt = { tint = { r, g, b } }, grass = { tint = ..., albedo = texture, normal = texture, mask = texture, tiling = 4 } }, where tint multiplies the albedo texture and tiling is
the metres one repeat of the textures covers (an array of names and
{ name = ..., tint = ... } entries works too). A surface layers does not
describe is drawn in a colour of its own. Where layer names no surface, the
ground is drawn the way terrainStandard draws it, by height and slope, so a
source can name the road alone and leave the hillside as it was. One tile
draws the eight surfaces covering most of it, and says so in the log when it
names more. A source without layer is drawn by terrainStandard, a baked
tile by its own layer table, and material, when given, shades every tile
instead.
The tiles stand under the entity, in its local space: the landscape
moves, turns, tilts and scales with it, and the queries below take world X and
Z and answer for the ground drawn under that point, the ground a ray cast
straight down there meets: heightAt its world Y, normalAt its normal in the
world.
Properties
| Name | Type | Default | Description |
|---|---|---|---|
size | vec2 | { 4096, 4096 } | The rect the landscape covers, in metres along X and Z. A whole number of tiles each way. |
origin | vec2 | { 0, 0 } | The rect's centre. |
tileSize | number | 512 | Metres along a tile's side. |
resolution | number | 2 | Metres between two samples of the ground. tileSize is a whole number of them. |
source | string | "" | The identity of a module returning { height = function(x, z) -> metres, layer? = function(x, z) -> surface name, layers? = { name = { tint, albedo?, normal?, mask?, tiling? } } }: a module of the world's source by its identity (modules.mountains for /source/modules/mountains.module, myGame.landscape for /source/myGame.package/landscape.module), or a library module by its @builtin:: identity. |
tiles | table | {} | One baked .terrainData a tile, { ["tx:tz"] = path or guid }, each baked over terrainStream.tileSource. |
material | AssetRef<material> | nil | What every tile is shaded with. Empty: a baked tile by its own layer table, procedural ground by the surfaces its layer names, and by terrainStandard where it names none. |
collision | boolean | true | Collider blocks around entity focus points. |
loadRadius | number | 1500 | Tiles within this distance of a focus are drawn. |
unloadRadius | number | 0 | Tiles past this distance from every focus are let go of. Zero is 1.25 x loadRadius. |
colliderRadius | number | 160 | Collider blocks within this distance of an entity focus collide. |
budgetMs | number | 4 | Milliseconds a frame may spend on tiles. |
colliderCells | number | 32 | Cells along a side of one collider block. |
Distances are measured from a focus to the nearest edge of a tile, the way
spatialStreaming measures a cell, and the band between loadRadius and
unloadRadius keeps a focus resting on a tile boundary from loading and
releasing the same tile every frame.
What a frame does
- Every tile within reach of a focus is wanted; a tile past
unloadRadiusof all of them is let go of: its entity, its colliders, its mesh pool and its heights. - The collider block under each entity focus is stood up the frame it is
missing, whatever the budget: a body over ground that is not there falls.
isReadyAt(x, z)says whether it stands. - The LOD cut is selected once across every tile standing, so it is balanced across tile edges and every edge between two tiles is stitched the way an edge between two nodes is.
- The rest of the work is spent inside
budgetMs, the ground bodies collide with first: the collider blocks around entity focus points, one block ofcolliderCellscells at a time, nearest block first. A procedural block reads its samples from the source, so collision never waits on drawing; a baked tile is opened before its blocks, which read its texels. Then the drawn ground, nearest tile first: sampling a tile's heights (a run of samples at a time, stopping at the budget), putting them on the GPU and meshing it. A piece of work that cannot be cut short starts only when what that kind of work has cost so far fits in what is left of the frame; a frame after one that ran nothing for that reason starts its first piece anyway, so work larger than the budget still lands. A far tile's heights are held only as finely as its finest node draws them. - A collider block is let go of once it is a block's span further than
colliderRadiusfrom every entity focus.
Seams
Every tile samples one grid of points, resolution metres apart from the
rect's corner. Two tiles sharing an edge carry the same samples along it, and
two collider blocks share their edge row, so the drawn surfaces and the
colliders of neighbouring tiles meet sample for sample.
Several peers
The tiles are local to each engine. Every peer streams the same source, so
peers agree on the ground under every body; a peer that simulates a vehicle
puts a StreamingSource on it (or names it with addFocus) so the ground
under it collides on that peer.
Methods
stream(): theterrainStreamobject (tiles,stats, ...).setSource(source): the ground as a table of functions or a module identity. A ground given in edit stands through play; one given while play runs stands until play ends. The ground follows its source: editing the module it comes from (named bysource, or given tosetSourceby identity or as the tablerequirereturns), or replacing a function of the table it was given, stands the ground again from the new code.heightAt(x, z),normalAt(x, z),layerAt(x, z): the ground at a point, whether or not its tile is standing.isReadyAt(x, z): whether the collider under a point stands.addFocus(target): stream around an entity or point too: an entity id, an entity, or a position (vector.create(x, y, z),{ x, y, z }or{ x = x, y = y, z = z }). A table is read every frame, so moving the table moves the focus: the ground ahead of a fast body, say.removeFocus(target): stop streaming around a targetaddFocusadded.focusPoints(): every place the landscape streams around this frame, in world coordinates, as{ x, y, z, kind, collides }(kindis"source","entity","point"or"camera").stats(): tiles standing, drawn and colliding, bytes held, the frame time spent and its worst, and the cost of each kind of work.standingTiles(): every tile standing, with the detail its heights are held at (thetilesfield names the baked ground).