Terrain & Landscapes, Explained

A landscape reaches the screen one of two ways here, and which one it wants depends on whether it fits in memory.

PathWhat it isRunning on it
Height field (this page) An elevation grid over a centred world square, drawn as one splat-textured mesh. A 4 km square at 513² samples is one draw, and the height under any point is arithmetic rather than a ray cast. No tiles, no baking, no streaming. The forest demo — real Great Smoky Mountains elevation, walked at eye height.
Streamed 3D Tiles An octree of glTF tiles paged in and out around the camera by screen-space error, under a memory budget, each tile carrying its own collision mesh. What a world too big to load needs. GLinting Steel — a circuit baked into a world and streamed in around the car.

The two share what stands on the ground: the same vegetation nodes, the same roads, the same water, and the same movement modes.

A hillside of firs over undergrowth, seen from standing height
A height field at full size: one splat-textured mesh under half a million instanced trees and grass clumps, on a real digital elevation model.

Height-field terrain, walked

A landscape that fits in memory whole needs none of the streaming above. OpenGLContext.scenegraph.terrain holds it as a HeightField — an elevation grid over a centred world square — drawn by SplatTerrain, which blends several ground materials per fragment from a control image. A 4 km square at 513² samples is one mesh and one draw, and the height under any point is arithmetic rather than a ray cast.

One surface, three readers

A height field answers about the same ground three ways, and every one of them gives the same answer:

ReaderWhat asks it
field.mesh() what SplatTerrain draws — the ground a player sees
HeightFieldColliders the trimesh chunks a car drives on, cut from that same grid
field.sample(x, z) everything analytic: the walker's floor, the seat of every scattered plant, the slope a grass mask thins by

Four corner samples do not lie in a plane, so each cell of the grid is drawn as two triangles, and the height inside a cell depends on which of the two a point falls in. sample reads that same triangulated surface, so a camera clamped with it stands on the ground that is drawn, and a plant seated on it meets that ground.

Interpolating the four corners of a cell instead — a bilinear patch — names a height on a surface nothing draws: it rides a quarter of the cell's twist above the drawn ground on one diagonal and the same below it on the other. Over the eight-metre cells of a 4 km square at 513², that is metres — a camera under the hill looking out through it, and vegetation buried to the tips. tests/unit/test_heightfield_is_the_drawn_surface.py holds the three readers to each other.

A height function is not the ground; the mesh built from it is. The same rule applies wherever a surface is meshed by sampling a function at vertices — the streamed tiles, terrain_patch. What is drawn there is the triangles between those samples, so anything placed on that ground has to be placed against them: scatter over the tile mesh (scatter_on_mesh), or against a sampler that reads it. Feeding the original function to scatter_disc seats plants on a surface that was never drawn.

Where a height field comes from

A landscape is authored as a function of (x, z) — procedural noise, a DEM reader, terrain with a road's earthworks cut into it — and rendered and collided against as a grid. HeightField.from_function(fn, res, extent) is the step between: it samples the function over the square and takes the datum and the relief from what the function actually does there, so the grid's whole 0–1 range is spent on the ground that is present.

from OpenGLContext.scenegraph.terrain import HeightField, LayerRule, control_map
field = HeightField.from_function(my_ground, res=1025, extent=4096.0)
field.save_image('terrain-height.png')            # 16-bit, no datum in it
HeightField.from_image('terrain-height.png', 1025, 4096.0,
                       field.relief, base=field.base)

base is the world height the grid's zero stands at. A landscape's lowest point is rarely sea level, and the grid says only how far the ground rises, not where it sits; the two numbers travel with the image. Give base and relief explicitly when two fields of one landscape have to agree, or they meet in a step.

Landscapes of your own

The shipped field is four things added together, and a TerrainProfile is how much of each there is: broad rolling hills; ridged mountains under a mask, so they stand in ranges rather than everywhere; a meandering canyon cut into whatever is above it; and a broad basin dished out of one region, whose floor is where a lake sits.

from OpenGLContext.loaders.tiles3d.procedural import TerrainProfile, terrain_height_for

alps = TerrainProfile(hills=70.0, mountains=900.0, mountain_scale=1400.0,
                      mountain_cover=0.82, canyon=0.0, basin=0.0, datum=60.0)
height_fn = terrain_height_for(alps)          # an ordinary height function

Every amount is metres of relief and every scale is metres on the ground, so what a landscape is can be read off its profile. seed gives another landscape of the same description — another set of ranges, another course for the river — rather than another kind of landscape. SHIPPED_TERRAIN is the profile terrain_height is, and it does not move: worlds already baked came from those numbers.

fbm and ridged are the noise the landscape is made of, exposed so that anything adding to it — a sculpted hill, a scatter mask, a splat weight — can be made of the same grain rather than of a second kind of noise that does not match.

The result is an ordinary height function, so it feeds HeightField.from_function above or a baked tileset equally.

Which ground material shows where

The splat's control map is an RGBA image: red is how much of the first material shows at that spot, green the second, and so on. Painting one is how a landscape artist works; deriving one from the land is how a generated world gets its ground. A LayerRule is an elevation band, a slope band and a weight, and control_map turns a height field and a list of them into the image:

control_map(field, [
    LayerRule(),                                   # grass: the fallback
    LayerRule(slope=(0.16, 0.55), weight=1.5),     # needle litter on the slopes
    LayerRule(slope=(0.5, 1e9), weight=3.0),       # rock where soil will not stay
    LayerRule(weight=0.0),                         # dirt: painted, not derived
], size=512, painted=[(3, road_corridor)])

The first layer is the fallback: ground no rule wants is made of it. Bands feather at their edges, because a hard edge between two ground materials reads as a painted line. painted forces a layer where the rules cannot know to — a road's corridor, a lake bed, a clearing — taking that fraction of the pixel away from everything else, so the weights still add to one.

Size the map to the smallest thing it has to say. The control map is also what decides where ground cover grows, so a corridor thinner than one of its pixels is a corridor the grass grows straight over. Over four kilometres, 512 pixels is eight metres each and 2048 is two.

Standing on one

A field is a surface, so a vehicle needs triangles. OpenGLContext.physics.heightfield.HeightFieldColliders cuts it into square chunks and keeps the ones near whatever is moving in the physics world:

from OpenGLContext.physics.heightfield import HeightFieldColliders
ground = HeightFieldColliders(physics_world, field, reach=320.0)
ground.update(car_position)                        # once a frame

A four-kilometre field at four-metre spacing is two million triangles and a car touches four of them at a time, so what is out of reach is removed again: an hour of driving costs what one view of the world costs. Chunks are cut on the field's own grid lines and share their edge rows, so two neighbours agree exactly where they meet.

holes is how something that passes through the ground says so. A tunnel's bore runs inside the hill and the hill's surface is still drawn over it; left in the physics world that surface is a wall across the road. holes(x, z) -> mask cuts the collider wherever it is set, and the bore's own lining is what the vehicle then drives through.

Walking it

OpenGLContext.move.terrainwalk.TerrainWalkMixin is what walks it. It is the terrain form of PhysicsWalkMixin: the same avatar, the same declared movement modes and the same keys as a glTF model or an arena map, with the ground taken from the height field and the obstacles from a field of cylinders — tree trunks, rocks — resolved analytically.

class Forest( OverlayMixin, TerrainWalkMixin, BaseContext ):
    def OnInit( self ):
        self.sg = my_scene                          # with the SplatTerrain in it
        self.eye_height = 1.7                       # metres; sizes the avatar
        self.platform.setPosition( where_to_start )
        self.init_walk( height_field, trunk_positions, trunk_radii )
        self.setupPhysics( enable=True )            # binds 'g', starts walking
        self.add_stream( 10.0, refresh_grass )      # follow the walker
MethodWhat it does
init_walk( field, positions, radii ) Bind the ground and the cylinders. The radii have player_radius added, and the cylinders are bucketed into a hash grid, so a collision test looks at a handful of neighbours rather than at a whole forest.
setupPhysics( enable=True ) Stand the avatar up and give it the camera. From PhysicsWalkMixin, unchanged — g hands the camera back to the free-fly navigator, f flies.
add_stream( step, fn, turn=None ) Call fn(x, z) once the walker has moved step world units, or turned turn radians. What refreshes the grass and the near-mesh trees that follow the camera; use turn for a field that depends on the facing, such as a view-cone cull.
eye_height, player_radius The camera height and the body radius the scene was written against. They size the avatar, rather than the physics defaults.

The surface is a floor, not a rail: the avatar is lifted to it from at or below and left alone above, so a jump rises, an arrival from the air falls, and flying over the canopy works. Trunks stop a walker and not a flier. Without setupPhysics the mix-in still holds a free-fly camera down on the terrain, which is what the offscreen capture and benchmark tools use.

Demos & validation

oglc-forest — the forest demo, a separate distribution — is this path at full size: real Great Smoky Mountains elevation, a four-layer splat ground, 230k GPU-instanced trees with impostor LOD, two layers of camera-following grass, and the overlay settings and key-binding screens on the same keys every other program here uses.

scenegraph/terrain/ (heightfield.py, splat.py, control.py), scenegraph/vegetation/ (instanced clumps, billboards, near meshes and field.py), physics/heightfield.py and move/terrainwalk.py. The behaviour is pinned by tests/unit/test_terrainwalk_avatar.py (where the walker ends up, on a slope, against a trunk, mid-jump and in the air), test_terrainwalk_broadphase.py, test_terrain_vegetation.py, test_heightfield_datum.py, test_terrain_control.py, test_heightfield_colliders.py and test_vegetation_field.py.