Terrain
Terrain is ground kept as data. It lives in four places.
orblit_terrainholds 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_filamentdraws it, asOrblitTerrainin a scene.orblit_stageconnects the two withterrainFrom, and draws what is scattered over the ground withscatterFrom.orblit_sceneputs it in a scene file, as aterraincomponent.
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_stageThe 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.
Ground as data
Section titled “Ground as data”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.
What covers it
Section titled “What covers it”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.
Texture sets
Section titled “Texture sets”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.
blendSharpnesssets 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.
tileSizeis how many metres one copy of the picture covers.triplanarlays 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.
Automatic cover
Section titled “Automatic cover”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.
Drawing it
Section titled “Drawing it”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.
Standing on it
Section titled “Standing on it”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.
Brushes
Section titled “Brushes”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.
sizeis 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.falloffis how much of the radius eases off. At 0 the brush is a hard disc, and at 1 it fades from the very centre.jitteris how far each dab may stray from the path, as a share of the radius.spacingis how far apart the dabs are, as a share ofsize.
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);Scatter
Section titled “Scatter”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.
In a scene
Section titled “In a scene”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, ), },);In the editor
Section titled “In the editor”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.
Limits
Section titled “Limits”| 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.
Where it runs
Section titled “Where it runs”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.
