terrainData
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,
the water surface standing over it, 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
water.texture/ rgba32f: R the surface's world Y, G its coverage
water_shade.texture/ rgba8: R the water column over the deepest column, G coverage
control0.texture/ rgba8: four layer weights, one per channel
control1.texture/ rgba8: layers 5-8
slice_albedo_0.texture/ rgba8: one layer texture per distinct albedo
slice_normal_0.texture/ rgba8: one per distinct normal map
slice_mask_0.texture/ rgba8: one per distinct mask map
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. A container holds holes, water and the slice_* children
only when the landscape has them.
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
water: water.texture
waterShade: { file: water_shade.texture, depthMax: 12.5 }
controls: [control0.texture, control1.texture]
layers:
- { name: sand, tint: [0.62, 0.56, 0.42], tiling: 12, albedoSlice: 0, normalSlice: 0, maskSlice: -1 }
- { name: rock, tiling: 8, albedoSlice: 1, normalSlice: -1, maskSlice: -1 }
slices:
albedo: [slice_albedo_0.texture, slice_albedo_1.texture]
normal: [slice_normal_0.texture]
size: [1024, 1024]
Children are named relative to the container, so a landscape that is moved or renamed carries its own textures with it.
The 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 water surface is rgba32f: R the world Y the surface stands at, in
metres, G whether a surface covers that texel. A sea, a lake and a river are the
same thing here: a level and where it reaches. water_shade is its filterable
rgba8 companion that the water's material reads per pixel, R the column over
depthMax and G coverage.
The layer table says what each control channel stands for: its tint, 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 (tiling).
The layer slices are the layers' own textures, one child per distinct texture
per role (albedo, normal, mask), all resampled to one square edge (slices.size).
A surface binds each role's slices as one texture array, and each layer's
albedoSlice, normalSlice and maskSlice says which slice it reads (0-based,
-1 for none). A slot per layer per map would be twenty-four sampled textures in
one bind group, past what a fragment stage is guaranteed; three arrays cost three
slots whatever the layer count, and every slice wraps on its own.
Filling one
terrain.write in a procgen graph writes the manifest and every child in one
step, and answers the reference to the container. It takes:
raster: the ground; itsheightlayer becomes the heightfield.name: the container's identity, a bare identifier (duneswritesdunes.terrainData).folderordestplaces it.layers: the names of the raster layers holding each surface's coverage, in channel order, separated by commas ("sand, rock"). Coverage per surface is an ordinary mask layer written byterrain.mask.surfaces: optional records of{ name, tint?, albedo?, normal?, mask?, tiling? }matched to a layer byname, saying what each surface is shaded with.holes/solid: a mask layer marking where ground is cut out, or where it exists.height,water,waterMask: the raster layers read for the heightfield and the water surface.resolutionandlayerResolution: the texel size of the maps and the edge of one layer slice.
A surface that names no texture is shaded by its tint. 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. |
:water() | The water surface texture, or nil when the landscape is dry. |
:waterShade() | { texture, depthMax }, or nil for a landscape carrying no water. |
:control(i) / :controls() | The weight textures, in channel order. |
:layers() | The surface table. |
:slices() | { albedo?, normal?, mask?, size }: the layer textures per role, 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. |
:instantiate(target?, opts?) | An entity carrying a Terrain component bound to this asset. |
:setChild(role, file) / :setPlacement(p) / :setLayers(t) / :setSlices(s) / :setWaterShade(file, depthMax) | 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.
In the Inspector
Selecting a terrain opens its Terrain section: its size, origin and height range, the heightfield's resolution once one is written, a table of its material layers (name, tiling, whether it has an albedo), and a link to each child texture (heightfield, holes, water, control maps). A terrain not yet sculpted says it has no child textures.