terrainData
A landscape's baked data, as one asset.
A terrain is never a single image. It is a heightfield, the control textures
saying which surface covers where, a mask of where there is no ground at all,
and the table of what those surfaces are. Every one of those is an ordinary
.texture the renderer already knows how to sample; this container holds them
together and names their roles, so a Terrain component references a
landscape rather than a handful of loose images — and a heightfield never
turns up in the picker beside an albedo map.
dunes.terrainData/
terrain.yaml the manifest — where the ground stands, and role → child
height.texture/ rgba32f — R metres, G/B gradient, A coverage
holes.texture/ rgba8 — R non-zero where there IS ground
control0.texture/ rgba8 — four layer weights, one per channel
control1.texture/ rgba8 — layers 5-8
atlas_albedo.texture/ rgba8 — the layers' albedo maps, packed 4 x 2
atlas_normal.texture/ rgba8 — their normal maps
atlas_mask.texture/ rgba8 — their mask maps
The children are written by the same asset.create every other texture goes
through and placed inside the container by dest, the way a bundle carries the
meshes it assembles. Nothing here is a private format.
The manifest
version: 1
size: [4000, 4000] # metres of ground, along world X and Z
origin: [0, 0] # world centre of that rect
heightRange: [-8.1, 63.4] # the vertical reach the geometry declares
height: height.texture
holes: holes.texture
controls: [control0.texture, control1.texture]
layers:
- { name: sand, albedo: "…", normal: "…", mask: "…", tiling: 12 }
- { name: rock, albedo: "…", tiling: 8 }
atlas:
albedo: atlas_albedo.texture
normal: atlas_normal.texture
mask: atlas_mask.texture
size: [2048, 1024]
Children are named relative to the container, so a landscape that is moved or renamed carries its own textures with it.
The five payloads
The heightfield is rgba32f: R the height in metres, G and B its
gradient along world X and Z, A coverage. Metres and not a normalized 16-bit
ramp, because relief is often a few metres inside a range of hundreds and eight
or sixteen bits band that into terraces. The gradient is what a surface normal
comes out of without differencing the height four more times.
The control textures are rgba8, one channel per surface layer, four layers
a texture and another texture for every four after that. A texel holds how much
of each layer covers it, and the weights are normalized to sum to one — a
texel covered half by sand and half by rock reads 128/128, whatever the two
masks happened to evaluate to, so a shader blends by them without renormalizing
per pixel. Layer n is channel ((n - 1) % 4) + 1 of control texture
ceil(n / 4); :layerSlot(name) answers that for a named layer.
The holes mask is rgba8, R non-zero where there is ground. Terrain a
cave mouth or a quarry has been cut out of has holes, and a hole is absence
rather than a height of zero.
The layer table says what each control channel stands for: its albedo, its normal map, its mask map — R metallic, G ambient occlusion, B height, A smoothness — and the metres one repeat of them covers.
The atlases are those per-layer textures packed into three sheets, four
cells across and two down, layer n at cell ((n - 1) % 4, (n - 1) // 4). A
slot per layer per map would be twenty-four sampled textures in one bind group,
past what a fragment stage is guaranteed; three sheets cost three whatever the
layer count. terrain.write packs them, resampling each source into its cell —
a cell whose layer declares nothing for that role keeps a neutral (white albedo,
flat normal, white mask), and a role no layer declares at all is not written, so
the shader keeps its own default there.
Filling one
terrain.write in a procgen graph, which writes the manifest and every child
in one step:
local g = proc.graph()
local ground = g:node("ground", "terrain.source")
:param("size", { 4000, 4000 }):param("resolution", 2)
local dunes = g:node("dunes", "terrain.fbm"):input("raster", ground)
:param("amplitude", 18):param("featureSize", 180)
-- Coverage per surface is an ordinary mask layer. A slope band is in DEGREES.
local sand = g:node("sand", "terrain.mask"):input("raster", dunes)
:param("name", "sand"):param("input", "slope"):param("max", 18):param("feather", 4)
local rock = g:node("rock", "terrain.mask"):input("raster", sand)
:param("name", "rock"):param("input", "slope"):param("min", 18):param("feather", 4)
g:node("bake", "terrain.write"):input("raster", rock)
:param("name", "dunes")
:param("layers", {
{ name = "sand", tiling = 12, albedo = "textures.sand_albedo",
normal = "textures.sand_normal", mask = "textures.sand_mask" },
{ name = "rock", tiling = 8, albedo = "textures.rock_albedo" },
})
A layer is a bare raster-layer name when a tint is all the surface needs, or a table when it carries textures. Either way the weights come from the raster layer, and the textures are packed into the atlases as the container is written.
Writing the same name again re-bakes in place and keeps the guid, so every reference already pointing at that landscape stays resolved.
Reading one
| Method | |
|---|---|
:placement() | { size, origin, heightRange } — where the ground stands and how far it reaches. |
:height() | The heightfield texture. |
:holes() | The holes mask, or nil when the ground is solid everywhere. |
:control(i) / :controls() | The weight textures, in channel order. |
:layers() | The surface table. |
:atlas() | { albedo, normal, mask, size } — the packed sheets, or nil when no layer carries a texture. |
:layerSlot(name) | Which control texture and channel a named layer is in. |
:manifest() | The whole thing, decoded. |
:setChild(role, file) / :setPlacement(p) / :setLayers(t) / :setAtlas(a) | What terrain.write fills it through. |
The placement travels with the landscape, so a Terrain component standing on
one does not re-state how big it is.
Validation
asset.validate (and the publish gate) refuse a container whose manifest does
not decode, that does not say where its ground stands, that names a child which
is not there, or that declares more layers than its control textures can carry.
A landscape whose heightfield is missing is ground nothing can stand on, and
that is worth catching before it is published rather than when an entity tries
to load it.