Terrain
A Terrain is a heightfield the engine owns: one square grid of heights plus a few per-cell maps, from which every chunk mesh, vegetation bake and collision shape is rebuilt at load. This page covers the @awaken/terrain runtime type, how a terrain gets made and edited in the editor, what a project and an exported game carry, and the limits.
What the engine owns, and what it does not
@awaken/terrain (packages/terrain/src) is the runtime half. It knows how to store a terrain, build its meshes, collide with it and answer questions about its surface. It does not know how a world is made: no noise, erosion, brushes or biome rules live in the engine. A generator (the terrain example plugin, an importer, your own plugin or script) produces a TerrainInput and hands it to the engine, which packs it into a TerrainAsset.
The split is the point of the design. A generated 513 world stored as its meshes made a 99 MB project and a 24.4 MB game.html; stored as the asset and rebuilt at load, the same world (seed 1234) is an 8.2 MB project and a 9.77 MB game.html.
The Terrain component
The engine's Terrain component has one field, asset: the id of the terrain asset this entity draws and answers queries from. It is read-only in the Inspector, because the tool that built the terrain sets it. Anything about how the terrain was made (a seed, a preset, slider values) belongs on that tool's own component beside it, never on Terrain.
The entity with the Terrain component is the terrain's origin. Its children draw it:
| Child | Components | What it is |
|---|---|---|
Chunk cx,cz | MeshRenderer, LodGroup, Collider | One chunk of ground. Its LodGroup lists the chunk's three LOD mesh ids and the distances to switch at. Its Collider carries the ground mesh and, when anything standing on the chunk collides, a second mesh shape holding the merged vegetation proxies. |
<layer> cx,cz | MeshRenderer, optional LodGroup | One scattered layer (trees, rocks, grass) baked into one mesh for that chunk. A layer that thins with distance gets a LodGroup. |
Each chunk entity sits at its chunk's centre and its meshes are baked relative to that point, so the LodGroup switch measures the camera's distance to that chunk rather than to the terrain's centre. Move, turn or scale the Terrain entity and everything under it follows, queries included.
The entities above are what the terrain example plugin creates from getTerrainLayout (below). A generator of your own may build a different tree, as long as the entity carrying Terrain points at a real asset.
The terrain asset
A TerrainAsset (packages/terrain/src/asset.ts) holds:
| Field | Shape | Meaning |
|---|---|---|
resolution | integer | Grid vertices per side. Generators keep it 2^n + 1 (129, 257, 513, 1025). |
size | metres | Length of one side of the square. |
height | metres | How high a fully raised point (1.0) reaches. |
heights | Float32Array, resolution² | Row-major heights, normalized 0–1. Vertex (0, 0) sits at (-size/2, -size/2) in the terrain's local space. |
chunkRes | integer | Grid vertices per chunk side. (resolution - 1) must divide by (chunkRes - 1). Default 129, or the whole terrain as one chunk when 129 does not tile it. |
colors | Uint8Array, resolution² x 3, or null | Per-vertex colour as a 0–2 multiplier on the ground material (1.6 brightens, 0.5 darkens), stored in 2/255 steps. |
biome + biomeNames | Uint8Array + names, or null | A biome index per cell, read by api.terrain.biomeAt. |
water | Float32Array, or null | Per-cell water-surface height (a lake's spill level, a river's surface), -1 where dry. |
seaLevel | normalized height, or -1 | The sea, everywhere the ground lies below it. |
layers | list | Scattered instance layers (below). |
makeTerrainAsset throws, naming the problem, when the heights array does not match the resolution, the chunk size does not tile it, a map has the wrong length, or a layer collider has a non-positive size. Nothing is installed when it throws.
Instance layers
A layer is one prototype mesh and the packed instances of it. Each instance is 10 floats in a TerrainInput (x, y, z in terrain-local metres, scale, rotation about Y, an r/g/b colour multiplier, and the x/z of the ground normal it tilts onto) and packs to 6 bytes of position plus 8 bytes of attributes:
- Position as
u16per axis: X and Z over the terrain's size, Y over -0.1 to 1.1 of its height. On a 2 km world that is a 3 cm step. - Scale in 1/32 steps up to 7.97, rotation in 1/256 of a turn, colour in 2/255 steps, tilt as a signed byte per axis.
At load each layer is baked into one merged mesh per chunk, so a forest is a handful of draws that the renderer culls chunk by chunk. A layer can set:
castShadow(default on) anddoubleSided(default off).fade: metres from a chunk's centre at which the layer starts thinning. Pastfadeit keeps every 4th instance, pastfade x 2every 16th. 0 never thins. The example plugin sets it on grass only, because a tree that vanishes is noticed.collider: the shape each instance collides as, in prototype units, or null to walk through it (grass).cylinderis an upright six-sided prism ofradiusandheight(a trunk, 24 triangles);boxhas half-extentsradiuson X and Z andheighton Y (a boulder, 12 triangles). Both lean with the instance. All proxies in a chunk merge into one static trimesh that rides the chunk's Collider, and that geometry is kept out of the GPU's mesh store.
Scripts: api.terrain
Game scripts read the surface under any world-space point:
| Member | Returns |
|---|---|
api.terrain.heightAt(x, z) | World Y of the ground under (x, z), bilinear between grid vertices. |
api.terrain.normalAt(x, z) | World-space unit normal, the four surrounding vertex normals blended. |
api.terrain.biomeAt(x, z) | The biome name at the nearest grid cell, or null on a terrain without biomes. |
api.terrain.waterAt(x, z) | World Y of the water surface over (x, z): the cell's lake or river level, or the sea where the ground lies below seaLevel, whichever is higher. Null where the ground is dry. |
Every query returns null where no terrain lies under the point, so a null check is the "am I over the world" test. The heightfield is read directly, so the answer does not depend on physics being loaded.
ts
const p = api.getPosition();
const y = api.terrain.heightAt(p.x, p.z);
if (y !== null) api.setPosition(p.x, y + 1.1, p.z);
const water = api.terrain.waterAt(p.x, p.z);
const swimming = water !== null && water > p.y;Queries go through each Terrain entity's transform. A moved, Y-rotated or scaled terrain answers correctly. A terrain tilted off the horizontal is not supported: the point is taken into its local space at world y = 0, which is exact only for a level terrain. When terrains overlap, the first one whose footprint contains the point answers. Only enabled Terrain components whose asset exists take part, and the set is re-read when the World's structureRev changes. The editor's Play view and the exported player both wire api.terrain.
Making and editing a terrain in the editor
The editor ships no terrain tool of its own. Terrains are made by a plugin, and the editor repo carries one as an example: examples/plugins/terrain/, a multi-file plugin whose entry is terrain.plugin.ts. Open the Plugins panel and use Load folder on that directory (or Load file with every file selected). It is the authoring tool; the engine type above is what it writes.
Once loaded it adds, among others:
- Three entries in the Hierarchy's Create menu: Terrain (flat) (a 129 x 129 vertex grid, 512 m a side), Terrain Stamp, and Terrain World (a generated world).
- Viewport tools Raise, Lower, Smooth, Flatten, Road and Quick stamp.
- A TerrainGenerator component on the terrain root, holding the generator settings, with Regenerate and New seed + regenerate buttons, and a TerrainStamp component for stamp objects.
The tools themselves (stamps, the World Designer, roads, vegetation, water) are documented on Plugins.
The plugin surface
Any plugin can build terrains through four ctx.assets calls (apps/editor/src/plugins/terrainHost.ts):
| Call | What the host does |
|---|---|
addTerrain(id, input) | Packs the TerrainInput, installs it and builds every derived mesh. One undo entry that restores the whole previous terrain, meshes included. |
getTerrain(id, heightsOnly?) | Returns a copy of the asset. heightsOnly skips the maps and layers, which is what a brush reads once per stroke. |
getTerrainLayout(id) | One entry per chunk: its centre, its three LOD mesh ids and switch distances, its layer bakes and its collision mesh id. The plugin creates the chunk entities from this. |
updateTerrain(id, rect, slice) | Writes a rectangle of heights. The host snapshots the old heights for undo (folding into an open runGroup) and rebuilds only the chunks the rectangle touches, grown by one vertex for the normal ring, writing positions and normals in place so chunk colours are kept. |
Derived meshes are named from the asset id: <id>_c<cx>_<cz>_l<level> for ground, <id>_veg<layer>_c<cx>_<cz> for a layer bake, <id>_col_c<cx>_<cz> for collision.
Level of detail and cracks
Every chunk carries three LOD levels: every grid vertex, every 2nd, every 4th. The engine's LodGroup component switches between them. The switch distance is not a fixed multiple of the chunk size: each level switches in only once its largest height error, padded by 1.25, covers less than 0.0015 radians on screen (about 2 pixels at 1080p over a 60° field of view). A smooth chunk switches at about its own width; a rough one later, or never within the world. Level 2 never switches in closer than 1.5 x level 1's distance.
Where a coarse chunk meets a finer one, the shared edge can open a crack. Each chunk edge gets a skirt, a short wall hanging straight down, sized per edge and per level by the crack that edge can actually open (doubled, at least 1 cm). Edges on the outside of the terrain have no neighbour and get no skirt. Normals come from the whole heightfield, not the chunk's slice, so two chunks always agree on their shared border.
Saving and shipping
- Project. A
.awakenproject stores each terrain asset with its typed arrays as binary spans: heights asu16(1/65535 of the height, 6 mm on a 400 m climb), colours and biomes as bytes, water asf32, layer prototypes inline. Derived meshes are left out of the save and rebuilt when the project opens. Re-quantizing heights already on theu16grid returns the same values, so repeated saves do not drift. - Export. The game file carries the same assets (Export to a game file). The player rebuilds the chunk and vegetation meshes on load (its progress label reads "Building terrain…"), and registers
TerrainandLodGroupso the LOD switch works in the shipped game. Components contributed by plugins, such asTerrainGeneratorandTerrainStamp, are stripped from the exported scenes and prefabs: they are authoring data no game reads. The ground material and its textures ship like any other referenced asset.
Limits
- One square grid per terrain. No holes, overhangs, caves or non-square terrains. Place several Terrain entities for a larger area; queries take the first one under the point.
- The chunk size must tile the grid.
(resolution - 1)has to divide by(chunkRes - 1), ormakeTerrainAssetthrows. - 12,000,000 baked vertices per layer. A layer's bake is instances x prototype vertices, and over
MAX_LAYER_VERTICESthe asset is refused, naming the layer and both numbers. A 60-vertex tree at 15,000 instances is 900,000; a heavy imported tree crosses the cap quickly. Prototypes are copied per instance, not GPU-instanced, and the engine has no impostors for distant layers;fadethinning is the only distance reduction. - No texture painting in the engine. The asset carries per-vertex colour multipliers. Splat maps, ground textures and normal maps are whatever material and textures the generator binds to the chunk MeshRenderers.
- Water is levels, not a surface. The asset stores water heights for queries; drawing a lake or sea is up to the generator.
- Imported terrains are not Terrain assets. A Unity terrain or an Unreal landscape imports as an ordinary ground mesh with a baked albedo (see Textures & Terrain), so
api.terrainanswersnullover it. - Old plugin terrains are not migrated. Saves made with the plugin's earlier
Terrainschema, before the engine owned terrain (2026-09-07), do not load as terrain assets.
See also
- Plugins - the terrain example plugin's brushes, stamps and World Designer
- Scripting API - the rest of the script surface
- Rendering performance - LodGroup and draw-call budgets
- Colliders - mesh colliders and static bodies
- Textures & Terrain (import) - how imported terrains arrive