Skip to content

Terrain

Ground is the largest thing most 3D scenes draw and the thing a mesh is worst at: too many triangles near the camera and too few where it matters. The Terrain component draws it as a quadtree of squares round the eye, every square the same small patch, so a hill underfoot is drawn in fine cells and the same hill on the horizon in cells many times as large.

Add a Terrain component to an entity. It is placed at that entity; its children are the quadtree’s nodes, made when the scene runs (and in the editor’s scene view) and moved as the eye moves. Nothing is baked into the scene and nothing is saved besides the component.

The ground is one of two kinds, chosen by extent:

  • Area: ground sizeX by sizeZ, centred on the entity, from a heightmap or, without one, a window on its provider’s ground read where the area stands in the world (so two areas of the same size and noise side by side join, and an area is the same ground its provider gives without end; moving it moves the window, not the ground). It may be worn by rain and painted by a splatmap.
  • Infinite: ground made by a provider (below) read at any point in the world, so it has no edge. The same provider, world, seed and version are the same ground everywhere, in the editor, in a game and on a server. It is drawn round the eye out to drawDistance.
Field What it does
extent Area or Infinite, as above.
heightmap Area only. An image read as data: its bytes as the file holds them, with no colour conversion, so it ships as the PNG it is (a heightmap compressed on the way fails the terrain, saying so). Empty makes the ground from noise.
heightmapFormat How the heightmap holds its heights. Grey: the grey (or red) channel, at the depth of the file: 16 bits from a 16-bit PNG (what World Machine, Gaea and most tools export), 256 steps from an 8-bit one. Red + green: sixteen bits across red (high byte) and green (low byte) of an 8-bit image.
sizeX, sizeZ, height An area’s world size and the world height of its highest ground. For infinite ground sizeX is the span that frequency hills are spread over, and sizeZ is not used.
cellSize The finest cell of the ground’s mesh, in world units: what the ground is drawn at near the eye. Further off the cells double, level by level.
detail A multiplier on how far the finest cells reach before the ground coarsens. Above 1 is finer at a distance and costs more triangles.
drawDistance Infinite only. How far from the eye the ground is drawn.
collision, collisionDistance Whether 3D bodies and characters collide with the ground, and how far round each one that moves the ground is built for it. collisionLayer and friction (advanced) are the ground’s layer and grip.
provider, world, generatorVersion Infinite, or an area with no heightmap. The generator the ground is made by (estella:noise, the engine’s noise, or one the game registers), the world it belongs to as the game names it, and the generator version that world was made with.
seed, frequency, octaves, persistence, ridged The noise: another seed is another ground; frequency is how many hills across; ridged goes from rolling hills to sharp ridges.
resolution Area only. Samples along each side of an area’s noise ground.
warp 0 to 1: how far the noise is bent by noise of its own, which gives winding valleys and bent ridges instead of round hills.
erosion 0 to 4: how much rain wears the ground (below). 0 leaves it as made.
splatmap Area only. An image whose four channels are the weights of layers 1 to 4. Empty paints by height and slope.
splatmap2 Area only. The weights of layers 5 to 8, read with the first.
layers The terrain layers the ground is drawn with, up to eight, in the order the paint weighs them. Empty draws the four layer colours or the Terrain material’s textures.
rockSlope, dirtHeight, snowHeight How steep ground must be to turn to rock, and the heights, as a share of height, where grass gives way to dirt and where snow begins.
layerColor0–3 Grass, dirt, snow, rock: the colours the ground is drawn in when it has no material.
material A material on the Terrain shader (below), or on Terrain Layers when the ground has layers. Empty uses the stock one.

Changing detail, drawDistance or a layer colour takes effect on the next frame. Changing any other field builds the ground again.

On an area, erosion runs rain over the field drop by drop, one drop for every sample of the field at 1: each drop runs downhill, cuts the ground where it can carry more than it does and lays it down where it slows, which leaves gullies down the slopes, sharper ridges between them and silt in the valleys. The same seed wears the same ground. It works on a heightmap’s field as well as a noise one. Where the water ran the grass is taken and dirt is painted in its place, so the channels show. Rain over a large field is seconds of work, so it is spread over frames and shown as it runs (see Building over frames).

Infinite ground has no edge for rain to run off, so its erosion is the noise’s own: each octave of the noise is damped where the octaves under it are already steep. That leaves smooth valleys between sharp ridges, much as rain would, at no cost beyond the noise. It paints no dirt channels.

The Terrain & Water example: green hills, a river running to the sea, and the sea in the foreground

The Terrain & Water example: layered ground, a river and the sea, all from the Terrain and Water components.

A terrain layer (a .esterrainlayer file; New → New Terrain Layer in the editor) is one kind of ground — grass, a path’s dirt, sand, rock — as the textures an artist made for it. A Terrain lists the layers it is drawn with (layers, up to eight), and its splatmaps weigh them: the first’s red, green, blue and alpha are layers 1 to 4, the second’s layers 5 to 8. Without splatmaps the first four are painted by height and slope as grass, dirt, snow and rock, and a generated world’s biomes can paint all eight.

Field What it is
albedo The colour, sRGB. Empty is white, coloured by the tint.
normal A tangent-space normal map, green up (the OpenGL convention). Empty is flat.
mask Red the ambient occlusion, green the roughness, blue the height. Empty: no occlusion, roughness, and the albedo’s brightness as height.
tile How many world units one repeat of the textures covers.
tint Multiplies the albedo.
roughness The roughness where there is no mask.
normalStrength How strongly the normal map bends the light: 1 as drawn, 0 flat.
heightBlend Optional. How much this layer’s own height decides where it meets another: 0 blends by the paint alone (grass into sand), 1 by the layers’ heights (stones standing through grass). Where several layers meet, each counts by how much of it is there. Absent, it is the material’s u_heightBlend.

The ground is drawn by the Terrain Layers shader (builtin:terrain-layers). Every layer is read by world position without repeat near the eye, with a wider tile mixed in far off; where layers meet, the one whose mask stands higher shows (stones through grass, sand between stones); on a slope each is also projected from the sides, so a cliff is not stretched. The normal maps and the masks’ occlusion and roughness light it. Its material sets how: u_sharpness, u_heightBlend, u_blendNoise, u_antiTile, u_farScale, u_macroVariation, u_triplanarFrom (how steep before the sides are read) and u_wetLevel/u_wetBand, as the Terrain shader’s below.

What an artist delivers, per layer: the three textures, square, a power of two (1024 is plenty; they are packed at the largest layer’s size, at most 1024, 512 on a minigame), tiling seamlessly, the normal and mask saved as data (not colour-managed). A layer’s textures are packed into texture arrays when the terrain is built, and again whenever a layer file or one of its textures is saved.

Details of a Terrain: the layer list and the shape fields

The Terrain component: its layers, then the shape of the ground and where its heights come from.

The four layers are grass, dirt, snow and rock. Every point of ground has a weight of each layer, from the splatmap or from how high and how steep the ground is there, and the Terrain shader (builtin:terrain) draws them. The textures are tiled by world position, so a layer runs unbroken across the ground however it is cut, and the rock is projected from three sides so a cliff is not a smear.

Parameter What it does
u_layer0–3 The layers’ textures: grass, dirt, snow, rock.
u_tint0–3 Each layer’s colour, which multiplies its texture.
u_tile0–3 How many world units one repeat of each layer covers.
u_sharpness How much the layer with more weight takes over at a seam: 1 is a plain blend, higher a harder edge.
u_heightBlend How much the seams follow the layers’ own relief (their brightness): rock shows through the gaps in the grass before the grass gives way, and sand fills the low places between stones. 0 is a plain blend of the weights.
u_antiTile How much of each layer is read a second time, turned and on a wider tile, and blended in by slow noise, so no repeat is seen across a field. It fades out with distance, where a repeat is too small to see.
u_macroVariation A wide, slow variation in brightness laid over every layer so a big meadow is not one colour.
u_relief How deep, in world units, the layers’ relief is drawn into the light (their brightness taken as height). 0 leaves the ground smooth.
u_wetLevel, u_wetBand Ground at the water: below u_wetLevel + u_wetBand (world heights) it darkens and shines, most at u_wetLevel. A band of 0 draws none.
u_roughness The ground’s roughness where it is dry.

The shader’s other parameters (u_heightPages, u_lodRange0 and the like) are written by the Terrain component every frame; a material leaves them alone.

The ground is cut by a quadtree. Every node of the tree, at every level, is drawn with the same patch of 32 by 32 cells, scaled to the node’s square: a level-0 node is 32 * cellSize across, and each level up is twice as wide, so its cells are twice as large. Each level has a range from the eye. Level 0’s is 2.5 * 32 * cellSize * detail, and each level’s is twice the one below’s. A node nearer the eye than the range of the level below it is split into its four children, so the ground is finest round the eye and coarsens level by level with distance. An area has enough levels for a top node to be as wide as the area; infinite ground has enough for the top level’s range to reach drawDistance.

The nodes are ordinary MeshRenderer children of the terrain entity, all of one mesh and one material, which the renderer draws as instances: the whole ground is a handful of draws. The patch itself is flat. The Terrain shader lifts each vertex to the ground’s height, read from the node’s page (below), and gives it the ground’s normal from the same page.

Toward the end of its level’s range, the shader slides each vertex onto the grid of the level above (geomorphing). By the edge of the range every vertex of a fine node lies where the coarser node beside it has one, so two levels meet along the same line: there are no cracks and nothing to hide them with, and nothing pops as the eye moves.

The heights a node is drawn with are kept in a page: the node’s square sampled 64 times along each side (two samples to a patch cell), with one more past each edge so the slope at the edge is known. A page holds the height in 16 bits over the ground’s range and the normal, in one atlas texture, and the weights of the four layers in another; each atlas is 2048 by 2048 and holds 900 pages. A coarse node’s page covers more ground at the same 64 samples, so its detail falls off with its cells.

Pages are made as the tree asks for them, a few each frame within a time budget, coarse and near ones first. Until a node’s page is made, the node is drawn by its nearest ancestor that has one, so the ground is there at once and sharpens as the pages come in. When the atlas is full, the page drawn least recently gives up its place.

Making pages and wearing an area by rain are spread over frames rather than done in one, so neither holds up a frame. While they run they are listed with their progress (“Building the terrain”, “Eroding the terrain”) in the editor’s status bar, and a game can read the same list to show a loading screen.

Terrain is an optional module: a package carries it when a scene has a Terrain, or when a script imports esengine/terrain (as below). A game with neither ships none of it.

The ground can be asked how high it is, for putting things on it:

import { TerrainsRes } from 'esengine/terrain';
const terrains = app.getResource(TerrainsRes);
const y = terrains.heightAt(x, z); // world height, or null where there is no terrain
const n = terrains.normalAt(x, z); // the unit normal there, or null

These read the surface: the lattice of cellSize cells, each node as high as the source says there, each cell cut in two along the diagonal from its (1, 0) corner to its (0, 1) corner. That is exactly the finest level the ground is drawn at near the eye and what bodies collide with, so a query, a ray and the drawn triangles agree (to the GPU’s 16-bit height encoding, a few millimetres for a few hundred units of height), and the answer does not change with the camera. normalAt is the normal of the triangle there. They answer for any point whether or not its page has been made. Until an area’s images are read and its erosion is done, heightAt returns null and terrains.loading is true. A provider that answers later leaves a point null until the samples round it come in.

terrains.status(entity) says where a terrain stands: loading, ready, or failed with a reason. A terrain whose heightmap cannot be read, whose provider is not registered, or whose version its provider no longer has fails: it draws nothing and answers null, rather than growing noise where the game expected its own ground.

With collision on, the ground is built for the 3D physics world in tiles of 32 cells within collisionDistance of every 3D body that moves and every character: the tile under one at once, the rest a few a frame, nearest first, and let go once nothing is near. Each tile is a static mesh body owned by the terrain entity, so a ray or a contact that hits the ground reports the terrain. While a terrain is rebuilt its old tiles stay until the new ones replace them, so nothing standing on it falls. terrains.collisionReadyAt(x, z) says whether the ground under a point is built, for a game to wait on before it drops something there (a provider that answers later builds a tile once its samples are in). Far from anything that moves there is nothing in the physics world to hit; terrains.raycast(origin, direction, maxDistance) meets the same triangles anywhere, with no physics, for a long line of sight, a pick or a landing spot.

The tiles are meshes rather than a compressed heightfield shape on purpose: a heightfield shape quantizes its samples and cuts its cells its own way, and the ground that is collided with would no longer be the ground that is drawn and queried.

The tiles go through the CollisionGeometry resource (esengine), which any system that makes static geometry as the game runs can use; the 3D physics world keeps one static body for each piece in it.

A game changes the ground where it needs to (a plot levelled to build on, a pad, a road’s bed) with modifiers: functions of the world position and the height the ground has there, each over a square of the world. They are laid over whatever the terrain’s ground is, in the order they were set, and every reader follows them: what is drawn, heightAt and normalAt, rays and collision. Only the pages and collision tiles a modifier reaches are made again, and what stands on the ground (plants, water, a generated world) is placed again.

import { flattenModifier } from 'esengine/terrain';
const terrains = app.getResource(TerrainsRes);
const level = terrains.heightBeneathAt(x, z)!; // the ground as it was made, under every modifier
terrains.setModifier(flattenModifier('plot 1', { type: 'rect', x, z, halfX: 40, halfZ: 30, yaw }, level, 12));
terrains.deleteModifier('plot 1'); // the ground comes back

A WorldZone with flatten levels its ground this way.

findPath searches the ground for a way from one place to another that a walker held to a slope can take: what a game checks a spawn, a quest’s goal or a village against before it trusts a generated world. It reads the ground where the search goes (modifiers and all), so it needs nothing baked and works on ground without end.

const terrains = app.getResource(TerrainsRes);
const waters = app.getResource(WatersRes);
const way = terrains.findPath(spawn, village, {
maxSlope: 32, // degrees; a walker switches back up anything gentler across
step: 8, // the search's cell, in world units
blocked: (x, z) => (waters.shoreAt(x, z) ?? 1) < 0, // no wading
});
if (way.state !== 'reached') regenerate(); // 'unreachable', 'exhausted' (ran out of maxCells) or 'noGround'

The way comes back pulled straight wherever a straight walk holds, with its length along the ground. It is a query for checks and plans, not for every frame: a few hundred metres cost milliseconds, and maxCells bounds a long one, which then says it ran out rather than that there is no way. Agents that walk every frame use navigation.

Ground without end is made by a provider: one function of the world position, the same on every machine. It is asked for a region (the world, seed and version, a corner in world units, a spacing and a count of samples a side) and answers the heights there, and, if it paints the ground, four layer weights a sample. Every region is the same function read at other points, so neighbouring pages share their edge’s samples, a coarse page and a fine one agree where their samples meet, and the order pages are made in changes nothing. Positions are the world’s, not the terrain’s: two terrains placed apart over one world are the same ground.

import { registerTerrainProvider, type TerrainProvider } from 'esengine/terrain';
const planet: TerrainProvider = {
id: 'mygame:planet',
versions: [1, 2], // every version a saved world may still name
range: (seed, version) => [-200, 900], // the least and most height, which the GPU encodes over
sample({ world, seed, version, x0, z0, spacing, count }) {
const heights = new Float32Array(count * count);
// ... heights[j * count + i] at (x0 + i * spacing, z0 + j * spacing)
return { heights }; // or a Promise, from a worker or a server
},
};
registerTerrainProvider(planet);

A version names an algorithm for good. A saved world keeps generatorVersion; when the algorithm changes, add a version and keep the old one, so what was built on the old ground still stands on it. The engine’s noise is estella:noise, version 1. A provider with heightAt answers a point at once; without it a point is answered from small sampled tiles at the finest cell size.

A Planet is ground on a sphere. It is the same machinery as the ground above with a round world under it: a cube whose six faces are each a quadtree, every point pushed out to the radius, so the finest cells are under the eye and a planet seen from far off is a handful of coarse nodes. The same ground is drawn from the surface and from space, with nothing to switch between.

Field What it does
radius The planet’s radius in metres. The planet stands at its entity, unrotated and unscaled.
provider, world, generatorVersion, seed The generator the ground is made by (registerPlanetProvider), the world it belongs to as the game names it, its version and its seed. The same four are the same ground everywhere.
cellSize, detail The finest cell of the ground’s mesh in metres, and how far out the finest cells reach (above 1 is further).
layerColor0–3, macroScale, macroVariation The four layers’ colours (grass, dirt, snow, rock), and the wander in brightness laid over them: macroScale is its size in metres, macroVariation its strength (0 to 1).
layers Up to four .esterrainlayer files the ground is drawn with; empty draws the four colours.
collision, collisionDistance, collisionLayer, friction As on a flat terrain: whether bodies collide, how far round each moving body the ground is built, its layer and its grip.
gravity How hard the planet pulls what is on it, in metres a second squared at the surface; 0 (the default) pulls nothing.
sea, seaLevel, seaColor, seaOpacity Whether the planet has a sea, its height above the radius, its colour and how much of it covers what is under it (0 to 1).

A planet’s provider answers for directions, not positions: it is handed unit directions from the planet’s centre and the radius they are on, and gives back each one’s height above the radius and, if it paints, four layer weights. A sphere has no x and z, so a generator reads three-dimensional noise at the point’s place, which has no seam and no pole.

registerPlanetProvider({
id: 'my-game:planet',
versions: [1],
range: () => [-40, 200],
sample({ seed, radius, directions, count }) {
const heights = new Float32Array(count);
for (let i = 0; i < count; i++) heights[i] = myHeight(seed, radius, directions[i * 3]!, directions[i * 3 + 1]!, directions[i * 3 + 2]!);
return { heights };
},
});

A planet answers the questions a round world puts to everything standing on it, through the PlanetRegistryRes resource: up(planet, x, y, z) is the unit direction away from its centre, altitude the height above the radius, groundHeight the generator’s own height under a position (null while a provider answers later) and groundPoint the point on the ground under it. nearest(x, y, z) names the planet whose surface is nearest. Set gravity (metres a second squared at the surface) and 3D physics pulls every dynamic body toward the centre of the nearest planet that pulls: the world’s one gravity is taken off the body and the planet’s put on, as a force each fixed step, so with the world’s gravity at zero a body simply falls toward the planet.

A generated world can be laid out over a planet: put a WorldGen with surface set to Planet on the Planet’s entity. Each of the cube’s six faces is laid out as a plane of metres by the same rules a flat world has (biomes over fields, recipes, layers), a direction is planned by the face it falls on, and what is placed is taken to the planet and stands along its up. The fields are the planet’s own: the ground’s height, its slope, noise read in three dimensions (so a field wanders over an edge between two faces with no seam), paint.<layer> for each of the planet’s layers, and the fields the provider names with fields (an ecology). The biomes also paint the ground (paintGround), so what is drawn and what grows are one map. seaLevel is the height of the sea: lower ground is under water. Zones (WorldZone) stand at a position in the world and are kept on the face they fall on.

The ground collides: the finest nodes within collisionDistance of every moving 3D body and character are built as collision geometry from the same samples the ground is drawn from (collision, collisionDistance, collisionLayer, friction, as on a flat terrain). A planet’s transform is a position only: a turned or scaled planet’s collision stands where it would be unturned. layers names up to four .esterrainlayer files, as on a flat terrain; each is read on the planes of the world’s axes by how much the ground faces them, with a wider read mixed in far off so the repeat is lost. Without layers the ground is the four colours. A planet’s provider can hand the engine its generator as a module a worker can load (workerSource: the text of a self-contained module of plain JavaScript whose default export answers sample, fields, versions and id). The world is then laid out and the ground painted on worker threads, and the main thread only puts each answer in the world as it arrives; without it everything is done on the main thread as before.

Set sea and the planet has a sea: a translucent, lit sphere at seaLevel above the radius, in which the ground that dips below it stands (waves and shore foam are the flat water’s and come to the sphere later). The sky follows the horizon round: give skyAtmosphere and heightFog the planet’s radius and centre and they work out the sky and the haze for the ground the camera stands on, with the sun a fixed direction, so a place’s day and night are where it is on the planet.

Three things hide the line where a model meets the ground. None of them needs the ground to know.

The Ground Blend material. The builtin:ground-blend shader is the Model shader, with every map and param it has, for a rock, a kerb or a foundation. Where the surface comes close to what stands behind it, it gives way to the picture of that very pixel, so whatever the ground is drawn with (its layers, its light, its shadows) is what the seam fades into, with nothing baked. It works on anything behind it, not only a terrain.

Param What it does
u_blendDistance How high above what is behind the surface has fully arrived, in world units. It is a height and not a distance along the view, so the band holds as the camera tilts. 0 leaves a hard edge.
u_blendNoise, u_blendNoiseScale How much the edge wanders, and the world size of its wander, so it is not a ruled line.
u_layer2, u_layer2Tint, u_layer2Roughness A second layer: dirt, moss, wet stone.
u_layer2Tile The length of one repeat of it in the world, read from above so it lines up across objects. 0 reads the mesh’s own uv.
u_layerReach How far up from what is behind it the second layer climbs, as a distance field does.
u_vertexBlend 1 lets a vertex colour’s red paint the second layer, and stops the colour tinting the surface.

Such a material is drawn after a copy of the opaque scene is taken, the way water is, and a scene with one takes that copy even with no post effect of its own.

A road. A RoadDecal on an entity with a Spline lays the entity’s MeshRenderer along the curve and over the ground, row by row, and is laid again when the spline or the ground changes. Each edge fades over edgeFade of its half width, so give it a blended material. With no terrain it lies along the curve as drawn.

Field What it does
step, across How far apart its rows are along the curve, and how many cells across. Finer follows the ground and the bends more closely.
widthScale Times the width each spline point says.
lift How far above the ground it lies. Far off the ground is drawn coarser than it is asked, so a road seen from far needs more.
edgeFade The share of its half width over which each edge fades.
tile The length of one repeat of its texture along the curve.

Snow and wet. The snowCover post effect lays snow on whatever faces the sky and stands under nothing, the terrain and the rock on it alike, with no material changed: u_amount, u_snowLine and u_snowLineBand (the height it stops at), u_slopeLimit and u_grainScale. The Snow weather brings it. The rain effect with u_intensity at 0 and u_wetness up is the same for a wet ground, under no rain.

A heightmap is read whole: an 8193 by 8193 field is about 256 MiB. Point tiles at a .esterrain and the ground is kept as tiles of 513 by 513 samples (neighbours share their edge) and a low-detail overview.

  • Distant ground is drawn from the overview, which is always in memory.
  • Full-detail tiles are read only near the view or a moving body, and past 256 MiB the least recently used are let go. Where no tile has been read yet the height is not known, and collision and queries wait for it.
  • Split into Tiles in the terrain sculpt panel converts a terrain.
  • Tiled ground is sculpted and painted like any other, on the tiles in memory: a dab over a tile not read yet is skipped and the tile is read. A changed tile stays in memory until it is saved, and a save writes the changed tiles and the overview only.
  • The sculpt panel exports the heights as one image the size of the area, and imports one over the tiles. With Loaded tiles no tile that is not in memory is read: an export takes the rest from the overview, and an import writes the tiles in memory and the overview only; All tiles reads and writes every one. An import is refused while there are changes not saved.
  • The tile images are named only by the .esterrain, so they are not preloaded and uploaded as textures the way a heightmap is.

Measured in the editor on a 4096-unit area: an 8193 by 8193 field opened as tiles holds 36 MiB, and 45 MiB close to the ground.

  • It is a height for each place. A cave, an overhang or a hole needs Voxel Terrain, a separate ground that is a volume.

  • Collision is the finest level. Far off the ground is drawn coarser than it collides, which is unseen from where anything collides with it; only what moves gets ground to hit, within collisionDistance.

  • Paint is as fine as the pages. Layer weights are sampled with the heights, two to a cell, so a splatmap finer than that near the eye is averaged, and far off it is coarser still.

  • No holes, caves or overhangs. A heightfield has one height at each place. Caves and bridges are meshes placed with it.

  • Position only. The terrain follows its entity’s position; its rotation and scale are not taken into account, by heightAt or by the shader that lifts the ground. Keep the entity unrotated and unscaled, and set the ground’s size with sizeX, sizeZ and height.

  • An infinite ground is made by its provider. It takes no heightmap or splatmap, and its erosion (the engine’s noise) paints no dirt.

  • Ground Blend fades into what is seen behind it. Where nothing is close behind the surface (a rock’s side seen against a far valley) there is no seam to hide and none is hidden. A wall just behind a Ground Blend object counts as close, as the ground does. Two such objects do not fade into each other: neither is in the copy the other reads. It needs float render targets; without them it draws as a plain Model.

  • snowCover is laid on the picture. It has no depth, so it does not round an edge or bury a stone, and it is limited to what the camera sees.

  • Pages are made a few a frame, so on the first frames, or after a jump of the eye, the ground is drawn coarse for a moment and sharpens rather than stalling a frame.