---
title: "terrainData"
description: "A landscape's baked data, as one asset."
section: "Types"
slug: "types-terraindata"
canonical: "https://origozero.ai/docs/types-terraindata"
updated: "2026-10-13T20:37:09.356730097+00:00"
tags: ["asset-type", "reference"]
---

# 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

```yaml
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.
