Skip to content

Terrain

Terrain is ground kept as data. It lives in four places.

  • orblit_terrain holds it, and has the brushes that shape it. It is pure Dart and needs neither Flutter nor a renderer, so a server or a test can read heights too.
  • orblit_filament draws it, as OrblitTerrain in a scene.
  • orblit_stage connects the two with terrainFrom, and draws what is scattered over the ground with scatterFrom.
  • orblit_scene puts it in a scene file, as a terrain component.
pubspec.yaml
dependencies:
orblit_terrain:
git:
url: https://github.com/ChxisB/orblit.git
path: packages/orblit_terrain
orblit_stage:
git:
url: https://github.com/ChxisB/orblit.git
path: packages/orblit_stage

The gallery’s Terrain example shows all of it: half a kilometre of hills, textured by slope and height, scattered with grass, stones and trees, with a box driving over them.

A Terrain is a grid of heights, one every spacing metres, kept in square regions. A region exists only where there is ground, so an island costs nothing for the sea round it. Regions are regionSize texels across, a power of two.

import 'dart:math' as math;
import 'package:orblit_terrain/orblit_terrain.dart';
void main() {
final terrain = Terrain(regionSize: 64, spacing: 2);
// Four regions: 256 metres square, centred on the origin.
// The heights are asked for at each texel's place in the world.
for (var rz = -1; rz < 1; rz++) {
for (var rx = -1; rx < 1; rx++) {
terrain.fillHeights(
RegionKey(rx, rz),
(x, z) => 12 * math.sin(x / 30) * math.cos(z / 40),
);
}
}
// One texel by hand: region (0, 0), texel (10, 20).
terrain.regionAt(const RegionKey(0, 0))!.setHeight(10, 20, 30);
print(terrain.heightAt(21, 40)); // Near the texel just raised.
print(terrain.heightAt(500, 0)); // null: there is no ground there.
}

Texel (i, j) of region (x, z) sits at ((x × regionSize + i) × spacing, (z × regionSize + j) × spacing) in the world. Every edit moves the region’s revision, which is how the renderer knows to take it again.

Each texel has three values: a height, a cover word and a colour. The cover word is 32 bits.

Part What it is
base The set underneath, 0 to 31
overlay The set on top, 0 to 31
blend How much of the overlay shows, 0 to 1, in 255 steps
angle A turn for both sets’ pictures, in sixteenths of a circle
scale A size for both sets’ pictures, in percent: 0, 20, 40, 60, 80, −20, −40 or −60
hole No ground here. Nothing is drawn, and there is no height
navigation Marks ground something can walk. The renderer ignores it
automatic Ignore the sets named here, and choose them by slope and height
import 'package:orblit_terrain/orblit_terrain.dart';
void main() {
final terrain = Terrain(regionSize: 64);
final region = terrain.addRegion(const RegionKey(0, 0));
// Rock with a little grass over it, turned a quarter.
region.setCover(4, 4, Cover.of(base: 0, overlay: 1, blend: 0.3, angle: 4));
// A hole: a cave mouth, or a well.
region.setCover(5, 4, region.coverAt(5, 4).withHole(true));
// Darker and wetter than the pictures say.
region.setColour(
4,
4,
GroundColour.of(red: 200, green: 190, blue: 180, roughness: -0.3),
);
}

The colour multiplies whatever the sets draw, and nudges roughness up or down. GroundColour.none is the default and changes nothing.

A set is a kind of ground: two pictures and how to lay them.

import 'package:orblit_terrain/orblit_terrain.dart';
final terrain = Terrain(
sets: const [
TerrainSet(
name: 'rock',
albedo: 'textures/rock.png',
normal: 'textures/rock_normal.png',
tileSize: 12,
triplanar: true,
),
TerrainSet(
name: 'grass',
albedo: 'textures/grass.png',
normal: 'textures/grass_normal.png',
tileSize: 4,
),
],
blendSharpness: 0.87,
);
  • The albedo’s alpha is a height. Where two sets blend, the taller one shows through. Stones poke up through grass instead of the two fading into each other. blendSharpness sets how hard that edge is, from 0 (a plain fade) to 1 (a hard line along the taller).
  • The normal map’s alpha is roughness.
  • tileSize is how many metres one copy of the picture covers.
  • triplanar lays the picture from three sides rather than from above. Use it for cliffs, where a picture laid from above is stretched down the face. It costs three reads instead of one, and only where the set is.

Every picture of every set must be the same size, square, because they are layers of one texture array. A set with no picture draws plain grey.

Ground whose cover says automatic chooses its own sets: steep on slopes and high ground, flat everywhere else, with a blend between. New regions start like that, so ground is textured before anyone paints it.

import 'package:orblit_terrain/orblit_terrain.dart';
void main() {
final terrain = Terrain()
..autoCover = const AutoCover(
steep: 0, // Rock.
flat: 1, // Grass.
slope: 1.2,
heightFalloff: 0.2,
);
print(terrain.autoCover.flatness(0.9, 50)); // How much grass shows.
}

At slope 1, ground tilted 60° is all steep. At 2, ground tilted 41° is. heightFalloff is how fast height hands over to steep, per hundred metres. All of this is a setting the renderer reads each frame, so changing it resends no region.

Build an OrblitTerrain with terrainFrom in every scene you hand the view. That is cheap. The regions’ maps are shared, not copied. A region crosses to the renderer only when its revision has moved since the last frame.

import 'dart:typed_data';
import 'package:orblit_filament/orblit_filament.dart';
import 'package:orblit_stage/orblit_stage.dart';
import 'package:orblit_terrain/orblit_terrain.dart';
OrblitScene sceneOf(
Terrain terrain,
OrblitCamera camera,
Map<String, Uint8List> decoded,
) => OrblitScene(
camera: camera,
objects: const [],
terrain: [
terrainFrom(
terrain,
key: 1,
// A set names its pictures by path; this turns a path into pixels.
pixels: (path) => decoded[path],
),
],
);

pixels returns a picture decoded to RGBA bytes, a row at a time. A path it can’t supply draws as the plain picture. The pictures cross when the sets or their size change. If only the pixels change, move picturesRevision.

The renderer draws one grid, meshSize squares across, at levels sizes, each twice the last and all centred on the camera. The GPU raises the grid by the heights. However much ground there is, that is a handful of draws, and moving the camera sends no ground.

heightAt and normalAt read the same heights the renderer draws, the same way: each square of four texels is two triangles, split along the same diagonal the mesh uses. A foot placed by heightAt meets the surface you see, not a smoothed guess at it. Neither needs physics.

import 'package:orblit_terrain/orblit_terrain.dart';
import 'package:vector_math/vector_math_64.dart';
/// Where a thing standing at (x, z) goes, or null off the edge or over a hole.
Vector3? footing(Terrain terrain, double x, double z) {
final y = terrain.heightAt(x, z);
return y == null ? null : Vector3(x, y, z);
}
/// Which way is up for it, leaning with the slope.
Vector3 upAt(Terrain terrain, double x, double z) =>
terrain.normalAt(x, z) ?? Vector3(0, 1, 0);

Both answer null where there is no region, and on a triangle with a hole at any corner.

That is for putting things on the ground. For things that fall, roll and bump into it, lay the terrain in a physics world with TerrainPhysics: see the Physics guide.

A TerrainStroke is one press of a brush, from the pointer going down to it coming up. Each moveTo carries the brush somewhere new, laying a dab every spacing of the way, and writes straight into the regions’ maps. Only the regions the brush is in move their revision, so only those cross to the renderer again.

import 'package:orblit_terrain/orblit_terrain.dart';
void main() {
final terrain = Terrain(regionSize: 64)..addRegion(const RegionKey(0, 0));
// Down at (10, 10), then dragged to (40, 10).
final stroke = TerrainStroke(
terrain,
tool: BrushTool.raise,
brush: const Brush(size: 12, strength: 0.8, falloff: 0.6),
);
var patch = stroke.moveTo(10, 10);
for (var x = 15.0; x <= 40; x += 5) {
patch = patch.followedBy(stroke.moveTo(x, 10));
}
print(terrain.heightAt(25, 10)); // Raised.
// The whole stroke as one step to undo: only the tiles it touched.
print('${patch.tileCount} tiles, ${patch.byteCount} bytes');
patch.revert(terrain);
print(terrain.heightAt(25, 10)); // Flat again.
patch.apply(terrain); // And back.
}

Every moveTo hands back a TerrainPatch: the tiles of 32 texels it touched, before and after, for the one map its tool writes. followedBy folds a stroke’s patches into one, so an undo stack holds kilobytes for a small brush rather than a copy of every region. Anything else that edits the maps can make a patch the same way with a TerrainRecorder.

Tool What it does With invert
raise Lifts the ground Lowers it
lower Sinks the ground Raises it
smooth Draws each height towards its neighbours’ The same
flatten Draws the ground towards height, or the height where the stroke began The same
slope Draws the ground towards a ramp from where the stroke began to the farthest it has gone. Drag to the far end and back over the way The same
cover Lays the set at index set Hands the ground back to automatic cover
colour Tints towards colour Washes the tint out
roughness Nudges by roughness, from −1 (glossier) to +1 Takes the nudge off
hole Cuts holes Fills them

One Brush serves every tool.

  • size is metres across.
  • strength, from 0 to 1, is measured per pass, not per dab, so closer spacing makes a smoother stroke and not a stronger one.
  • falloff is how much of the radius eases off. At 0 the brush is a hard disc, and at 1 it fades from the very centre.
  • jitter is how far each dab may stray from the path, as a share of the radius.
  • spacing is how far apart the dabs are, as a share of size.

A brush shapes only the ground that exists. Where there is no region, it does nothing.

To put a brush where a pointer is, raycast finds where a ray first meets the ground. It meets the surface heightAt describes, which is the one drawn.

import 'package:orblit_terrain/orblit_terrain.dart';
import 'package:vector_math/vector_math_64.dart';
/// Where a ray from the eye meets the ground, or null through a hole, off
/// the edge, or into the sky.
Vector3? under(Terrain terrain, Vector3 eye, Vector3 towards) =>
terrain.raycast(eye, towards, maxDistance: 2000);

Grass, stones and trees are rules, not places. A ScatterLayer says how thick a thing grows, on which sets, on what slopes and at what heights, how big it is and how it stands. The layers live in terrain.scatter and are saved in the .oterrain file, a layer to a line. A ScatterPlacer works out where each one stands, and scatterFrom hands the result to the renderer.

import 'package:orblit_filament/orblit_filament.dart';
import 'package:orblit_stage/orblit_stage.dart';
import 'package:orblit_terrain/orblit_terrain.dart';
void grow(Terrain terrain) {
terrain.scatter.addAll(const [
// Tufts on the grass set, off the steepest slopes, gone 70 m away.
ScatterLayer(
name: 'grass',
seed: 1,
density: 0.5,
sets: [1],
maxSlope: 35,
size: (0.4, 0.3, 0.4),
minScale: 0.5,
maxScale: 1.4,
lean: 0.7,
colour: 0x5E8C3A,
colourVariation: 0.25,
range: 70,
),
// Stones on the rock, lying with the ground and sunk into it.
ScatterLayer(
name: 'stones',
seed: 2,
density: 0.03,
sets: [0],
size: (1.2, 0.7, 0.9),
lean: 1,
lift: -0.25,
colour: 0x8A8580,
),
// A tree is two layers on one seed, so the crown lands on the trunk.
ScatterLayer(
name: 'trunks',
seed: 3,
density: 0.004,
sets: [1],
maxSlope: 22,
size: (0.45, 4, 0.45),
colour: 0x5A3E28,
castShadows: true,
),
ScatterLayer(
name: 'crowns',
seed: 3,
density: 0.004,
sets: [1],
maxSlope: 22,
size: (2.6, 3.4, 2.6),
lift: 3.2,
colour: 0x2F5A2A,
castShadows: true,
),
]);
}
final placer = ScatterPlacer();
var scattered = const <OrblitPopulation>[];
var scatteredRevision = -1;
/// Every frame, beside terrainFrom. Cheap when nothing has moved.
List<OrblitPopulation> scatterOf(Terrain terrain) {
placer.update(terrain);
if (placer.revision != scatteredRevision) {
scattered = scatterFrom(placer, key: 100).populations;
scatteredRevision = placer.revision;
}
return scattered;
}

Hand the populations to the scene as OrblitScene(populations: ...).

Field What it means If left out
name What the layer is called Required
seed Which pattern it is laid in 0
density How many per square metre, where the ground is all its sets. Up to 100 1
sets The sets it grows on, by index Any set
minSlope, maxSlope The slopes it stands on, in degrees from level 0 to 90
minHeight, maxHeight The heights it stands at, in metres Any height
size A block’s width, height and depth, in metres, or a model’s scale 1 m each way
minScale, maxScale Each one is a random share of size between these 1
lean 0 stands upright, 1 lies square to the ground 0
turn Whether each is turned a random way about its own up true
lift How far the base sits above the ground, in metres at full size. Negative sinks it 0
colour 0xRRGGBB, sRGB White
colourVariation How much brighter or darker each is: 0.2 is 80% to 120% 0
groundTint How much the ground’s colour map tints it, 0 to 1 0
range How far off it is still drawn, in metres. 0 draws it at any distance 0
castShadows Whether it casts shadows false
mesh A model, by its path in the project, instead of a block A block
material The .omat a model is made of The model’s own

Where things stand. The pattern is laid over the world, not over each region. The world is cut into squares, one per thing at the layer’s density, and each square has one spot in it, chosen from the layer’s seed and the square’s place. The ground at that spot decides whether anything stands there. Nothing does over a hole, off the regions, or outside the layer’s slopes and heights. Where the layer’s sets share a texel with others, it grows only as thick as its share: grass on ground that is a quarter rock is three-quarters as thick. Automatic cover counts as its flat and steep sets. The pattern is integer arithmetic, so the same ground scatters the same way on every platform, the web included, whatever size the regions are.

Following edits. update places a region again when its revision moves, or when a neighbour changes along the edge they share, since the slope near an edge is read from both sides of it. A layer is placed again everywhere when its rules change, and every layer when the automatic cover does, since that moves what counts as grass. Otherwise update compares a few numbers a region and does nothing. Each region and layer is a ScatterGroup, with an id that stays the same and a revision that moves when it is placed again. So a brush stroke places again only the regions it touched, and only those cross to the renderer.

Placing is not free. A region is placed again whole, which takes about a tenth of a second for 250,000 spots. Keep dense layers like grass near one per square metre or below, and give them a range.

Blocks and models. A layer with no mesh is a block: the renderer’s cube, stretched to size and standing on its bottom face. scatterFrom draws each block layer as one OrblitPopulation a region, keyed key plus the group’s id and sharing the group’s buffers, so a hundred thousand tufts are a few draws. A layer with a mesh stands on the model’s origin. Populations draw only the cube so far, so scatterFrom makes each model an OrblitObject. Give such a layer a material, so the copies are drawn together, and keep it to thousands rather than hundreds of thousands. scatterFrom’s mesh and material turn the layer’s project paths into the absolute path and the material key the scene lists. An object has no range, so a model layer’s range is not kept.

Two layers, one seed. Layers that share a seed and a density land on the same spots, with the same turn and scale. That is how a tree is a trunk and a crown, each its own colour. Give every other layer a seed of its own, or its things will stand inside each other.

A terrain is one small settings file, .oterrain, and one .oregion file per region beside it, named for its key: x0_z-1.oregion. Editing one corner of a large world rewrites one region, not the world. The package reads and writes bytes and text only, never paths, so it works in a browser too.

import 'dart:typed_data';
import 'package:orblit_terrain/orblit_terrain.dart';
void main() {
final terrain = Terrain(regionSize: 64);
terrain.fillHeights(const RegionKey(0, 0), (x, z) => x / 10);
// Out: settings as text, each region as bytes.
final settings = terrain.encode();
final files = <String, Uint8List>{
for (final region in terrain.regions) region.key.fileName: region.encode(),
};
// In: settings first, then the regions the settings list.
final load = Terrain.decode(settings);
for (final key in load.regions) {
load.terrain.putRegion(TerrainRegion.decode(files[key.fileName]!).region);
}
print(load.problems); // Anything it could not read, and left out.
}

A region file stores only the maps that differ from their defaults. Both formats carry a version, and an older file is brought up to date as it is read.

A scene says where the ground is with a terrain component, which names the .oterrain file by its path in the project.

{
"id": "ground",
"name": "Ground",
"components": {
"terrain": { "file": "terrain/hills/hills.oterrain" }
}
}
Field What it means If left out
file The .oterrain file, from the project’s root No ground
castShadows Whether the ground casts shadows true
receiveShadows Whether shadows fall on it true

The file is the terrain, and the scene only says it is here. The regions are far too large to write into a scene, and two scenes that share a terrain share one set of files. The ground sits where its own texels say, whatever the entity’s transform.

import 'package:orblit_scene/orblit_scene.dart';
const ground = SceneEntity(
id: 'ground',
name: 'Ground',
components: {
SceneComponents.terrain: TerrainComponent(
file: 'terrain/hills/hills.oterrain',
castShadows: false,
),
},
);

Add › Terrain puts new ground in the scene: flat, half a kilometre across round the origin, with rock for cliffs and grass for the rest. It gets a folder of its own under terrain/, because a terrain’s regions are written beside it. The file is written straight away, so the scene never names a file that isn’t there.

Select it and the inspector shows what it is made of.

  • Regions is a map of squares. Click an empty one to add ground there, and a full one to take it away.
  • Sets has a card for each set. Drop a picture from the Project panel onto Colour or Surface, and set how many metres one copy covers and whether it is laid from three sides on cliffs. Add set adds one, and Remove last takes the last away. Only the last can go, because the ground names a set by its place in the list.
  • Automatic cover chooses the flat and steep sets and sets the slope, height and sharpness.

The Terrain mode, the landscape button beside the scene mode, is where the ground is shaped. The tool shelf picks the tool. The Brush panel has that tool’s own settings, such as the set to lay or the tint, and the five that every tool shares. Drag over the ground to use it, and hold Shift to run it backwards. A ring on the ground shows where the brush will land and where it starts to ease off.

  • The brush takes the pointer only over the ground. A click beside the terrain still selects what it hits, and the right button and the wheel still move the camera.
  • A drag is one step on the undo stack, however long it takes, and holds only the tiles it touched.
  • The brush works on the selected terrain, or on the first in the scene when no terrain is selected. Switching to the mode is enough to start.
  • Saving the scene saves every terrain that changed, and writes only the regions that did.

The editor draws a terrain’s scatter, and places it again as the brush moves. It has no controls for the layers yet, so add them in code or in the .oterrain file. Only block layers are drawn there; model layers are left out.

Data Drawn
Region size 2 to 4096 texels, a power of two 16 to 2048
Regions Any number 256, all within 128 regions of each other each way
Sets 32 32
Pictures Any size Up to 4096 pixels across, all the same size
Grid 16 to 256 squares across, up to 12 levels
Scatter Up to 100 per square metre a layer Blocks as populations, models as objects

A terrain the renderer can’t draw throws an ArgumentError from terrainFrom saying why, rather than silently drawing nothing.

The data runs anywhere Dart does. Every platform hands the terrain to the renderer, but so far only macOS has drawn it, scatter included. Phones, Linux, Windows and the web are still to be checked on real devices, and the web build has not yet been made with terrain in it. Placing the scatter is plain Dart and lands in the same places everywhere, the web included.

A scene’s terrain component is drawn in the editor, but not yet by OrblitDocumentView. In a game, read the file as in Files and hand terrainFrom to the scene as in Drawing it.