A landscape reaches the screen one of two ways here, and which one it wants depends on whether it fits in memory.
| Path | What it is | Running 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 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.
A height field answers about the same ground three ways, and every one of them gives the same answer:
| Reader | What 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.
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.
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.
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.
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.
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
| Method | What 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.
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.
tests/tiles_terrain.py, tests/tiles_vegetation.py
— minimal heightfield / instanced-vegetation demos.tests/tiles_walk.py — first-person walk/fly.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.