Log inGet started

terrainData

Updated October 13, 2026

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; its height layer becomes the heightfield.
  • name: the container's identity, a bare identifier (dunes writes dunes.terrainData). folder or dest places 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 by terrain.mask.
  • surfaces: optional records of { name, tint?, albedo?, normal?, mask?, tiling? } matched to a layer by name, 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.
  • resolution and layerResolution: 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.

  • asset-type
  • reference