Terrain & Landscapes, Explained

OpenGLContext streams large landscapes as an OGC 3D Tiles octree of glTF tiles: a screen-space-error traversal refines detail toward the camera, frustum culling keeps the resident set a moving window around the view, and tiles page in and out under a memory budget on background threads. The same tiles carry per-tile collision meshes, so a first-person character walks on exactly what it sees, or flies above and drops back to the surface. A procedural generator ships real-world-like features — rolling grassland, a river canyon, a lake and snow-capped mountains — and real heightmaps (DEMs) load through the same path. Vegetation scatters on the surface with distance level-of-detail. The indented technical notes point at the code, all under OpenGLContext/loaders/tiles3d/.

There is a second, simpler path for a landscape that fits in memory whole: a height field with a splat-textured surface, walked by the same avatar and the same movement modes with no tiles and no baking.

Quick start — walk a world

The viewer is the oglc-terrain command (installed with the package) or, from a checkout, python -m OpenGLContext.bin.terrain_view:

oglc-terrain                         # walk the default procedural world
oglc-terrain --fly                   # start in free-fly
oglc-terrain --extent 4096 --levels 4   # a bigger, deeper-LOD world (85 tiles)
oglc-terrain --dem heightmap.png --height-scale 600   # a real heightmap
oglc-terrain path/to/tileset.json    # view an existing 3D Tiles tileset
oglc-terrain --size 1280x720 --sse 12 --memory 512    # window + quality/budget

Controls: W A S D move, Q E turn, Shift (hold) sprint, Space jump, G toggles walk / fly, R F rise / descend while flying, and - / = slow down / speed up (fly & sprint). Fly is fast, for covering a big world quickly.

A dense forest of instanced conifers and knee-high grass surrounds you, refreshed as you move so it stays dense wherever you walk, with the sun casting shadows through the canopy. Trees and grass are alpha-cut textured cards (procedural bark, pine-needle and grass-blade textures) rather than solid geometry, so they read as foliage. Tune density with --density (lower is faster) or drop it with --no-vegetation.

For a procedural/DEM world the close-up ground is a detailed camera-following textured patch (photographic CC0 material) rather than the coarse streamed tiles, so it can carry real detail; the streamed tiles are used only to view a raw tileset.json. Materials come from ambientCG (CC0, cached under the per-user app-data directory with a provenance manifest) via loaders/cc0.py, with a procedural fallback offline. The download and each map taken out of its archive are size-capped (cc0.MAX_ARCHIVE_BYTES, cc0.MAX_MEMBER_BYTES). Foliage textures and textured glTF prototypes are in loaders/tiles3d/foliage.py (grass/bark/needle generators, CC0-bark option, alpha-MASK cards, instanced); shadows are the engine's cascaded shadow maps (OPENGLCONTEXT_SHADOWS, see Shadow Mapping).

The viewer is bin/terrain_view.py. It needs the core profile + PBR renderer (it sets OPENGLCONTEXT_PROFILE=core, OPENGLCONTEXT_RENDERER=pbr and the GLFW backend itself), because terrain tiles are PBR glTF and their per-vertex colours only render under the PBR pass. Input is bound on both key-down and character events (some Wayland/GLFW setups deliver only the latter), and the character controller drives the camera from OnIdle with the default free-fly navigator unbound so the two don't fight. Ground collision is analytic: the avatar is clamped to height_fn(x, z) each frame — exact, matching the visible tiles, and impossible to tunnel through at any frame rate — so no separate collision mesh is needed for the floor. Vegetation is instanced (one shared prototype per layer) with a full-mesh-near / single-cone-far LOD, in two camera-following fields (frequent grass, occasional trees). Water is a translucent plane at the water level.

Viewing real 3D Tiles datasets (experimental)

Toronto's downtown streamed as 3D Tiles: the harbour, the CN Tower and
              the SkyDome above a map-textured ground
Open Street Map Tiles view of Toronto Harbour, the CN Tower and the SkyDome

3D Tiles support is experimental. A city-sized dataset loads, streams and is walkable, and the known faults are recorded in plans/TILES3D-CITY-VIEWING.md: tiles whose geometry floats above its ground, an initial load that fetches far more of the dataset at once than the view needs, and a frame rate around 30 fps at city scale. The APIs here may change while those are dealt with.

oglc-terrain above is the procedural/DEM playground. To load and stream a real, third-party OGC 3D Tiles dataset — a photogrammetry capture, a city model, a GIS terrain — open it with oglc-view. It is the interactive front end for the loaders/tiles3d runtime and is built for the parts real data actually uses:

oglc-view path/to/tileset.json           # a local tileset
oglc-view https://host/path/tileset.json     # stream a tileset straight from the web
oglc-view tileset.json --sse 8           # more detail (lower screen-space error)
oglc-view tileset.json --memory 1024     # bigger tile memory budget (MiB)
oglc-view tileset.json --capture shot.png    # render one offscreen still, then exit

The source is a local path or an http(s):// URL; with a URL the root, its tile content, and any external tilesets are fetched over the network and cached on disk (under ~/.cache/openglcontext/tiles3d, or --cache-dir), so each tile downloads once. It auto-frames the whole tileset at startup and lets you free-fly with the mouse and W A S D/ arrow keys; tiles stream in and out by screen-space error as you move. Supported today:

What a tileset is allowed to reach

A tileset.json names its own tile payloads and nested tilesets, so for a tileset from anywhere but this machine those URIs are chosen by whoever wrote it. They are held to the same rules as a glTF document's external references:

What is unrestricted is which URI you may name, not what the document at it may do. Naming a URL is a decision only you can make — the viewer fetches what you point it at, as curl would — and from there the file is untrusted like any other: the payload is size-capped, redirects on that first fetch are locked to the origin you named (so a server cannot bounce it to a link-local address), and every URI the document goes on to name is confined by the rules above. A dataset split across two hosts needs its own resolver passed to build_runtime_tileset. The policy itself lives in loaders/resolver.py, which is the one place it is written down.

Not yet bounded: the number of tiles a tileset may name. Each payload is capped and the resident set is held to the memory budget, but a hostile tileset can still name unboundedly many tiles and fill the on-disk fetch cache. Recorded in plans/TILES3D-CITY-VIEWING.md.

Tested against Cesium's TilesetWithDiscreteLOD sample (a tileset.json with an ECEF root transform and a low→medium→high b3dm LOD chain): the framed view selects the coarse tile and flying closer (or --sse 0.02) refines to the finest. Like oglc-terrain it forces the core profile + PBR renderer. A known limitation: a hard camera teleport can hole for a few frames, because REPLACE refinement keeps no standing coarse-LOD fallback resident; gradual flight sharpens in cleanly.

Sample datasets to try

The CesiumGS 3D Tiles sample tilesets (github.com/CesiumGS/3d-tiles-samples, Apache 2.0) are small and self-contained, and served over raw GitHub with no authentication — so you can stream them straight from the URL, no download step. These are verified to load:

# Cesium "dragon" — b3dm, an ECEF transform and a low/medium/high LOD chain
oglc-view https://raw.githubusercontent.com/CesiumGS/3d-tiles-samples/main/1.0/TilesetWithDiscreteLOD/tileset.json
#   add --sse 0.02 to pull the finest LOD over the network

# 1.1 glTF-native scene — houses and trees, several glTF contents per tile
oglc-view https://raw.githubusercontent.com/CesiumGS/3d-tiles-samples/main/1.1/MetadataGranularities/tileset.json

# 1.1 multiple-contents plane
oglc-view https://raw.githubusercontent.com/CesiumGS/3d-tiles-samples/main/1.1/MultipleContents/tileset.json

Each tile is cached under ~/.cache/openglcontext/tiles3d on first fetch, so re-runs are offline and instant. Prefer a local copy? Clone the repo (git clone https://github.com/CesiumGS/3d-tiles-samples) and pass a path to …/1.0/TilesetWithDiscreteLOD/tileset.json instead — identical result. You can also point the viewer at any tileset you bake yourself with oglc-terrain (see Making a world) or at your own captures exported to 3D Tiles (RealityCapture, Cesium ion, py3dtiles, etc.).

Not yet loadable (the viewer skips or errors on these): point clouds (.pnts), instanced models (.i3dm), composite tiles (.cmpt), and implicit tiling (.subtree) — so Cesium's TilesetWithTreeBillboards (i3dm), TilesetWithRequestVolume (pnts) and the SparseImplicit* samples don't render yet. Plain http(s):// tilesets stream fine (above); only API-key services (Cesium ion, Google Photorealistic 3D Tiles) need auth headers / token handling that isn't wired up — for those, export or download the tiles first.

How it works

Terrain is an OGC 3D Tiles dataset: a tileset.json bounding-volume hierarchy whose leaves are glTF meshes. Every frame the runtime walks the tree, converts each tile's geometric error to a screen-space error (pixels) against the live camera, and refines a tile into its children only while that error exceeds a threshold — so detail concentrates near the viewer. Frustum culling prunes tiles outside the view, which is what bounds the working set.

screenspaceerror.py (the perspective SSE), tileset.py (the parsed world-space tree; we parse box/sphere ourselves because py3dtiles rejects sphere volumes), traversal.py (select_tiles → a render set and a speculative want set), and frustum.py (Gribb-Hartmann plane extraction + bounding_sphere tests).

Streaming is what the traversal decides: wanted tiles that are not resident are queued for background loading (priority by distance); finished loads are uploaded to GL, throttled per frame; and tiles that fall out of the want set are evicted least-recently-wanted-first once the resident bytes exceed the budget. A tile still loading falls back to its nearest resident ancestor, so the world sharpens in rather than popping holes.

loadmanager.py (priority queue + worker pool, cancellable), residency.py (lifecycle UNLOADED→LOADING→READY→RENDERABLE, LRU eviction under a byte budget), and runtime.py (TilesetRuntime.update ties it together each frame). Loading and glTF parsing run off the render thread; only the cheap mount and draw are on the GL thread.

The runtime is mounted as a scenegraph node, TilesTerrain, so it renders, shadows and picks like any other geometry. Each resident tile also registers its mesh as a static trimesh collider in a physics world, which is what makes the terrain walkable.

scenegraph/tilesterrain.py (the node; call update_for_camera(camera, viewport_height, view_projection=…) each frame before rendering) and physics_colliders.py (streams colliders via the runtime's on_renderable/on_evicted hooks). See Physics & Collision for the character controller.

Exporting a city from OpenStreetMap

A city-sized dataset to fly, walk and page through comes from OpenStreetMap building footprints, extruded to 3D Tiles by osm-data-3d-tiles (Node, ISC-licensed) and viewed with oglc-view like any other tileset. The generator reads its buildings from a vector-tile server rather than from OSM directly, so an export is four steps: choose the area, put the buildings in front of the generator as MVT tiles, run the generator, then lay the output out so its URIs resolve from disk.

1. The area and its source tiles

Pick a bounding box in web mercator (EPSG:3857) metres — that is what the generator's EXTENT takes — and put the buildings for it at <TILE_URL>/16/<x>/<y>.pbf: one Mapbox Vector Tile per zoom 16 tile of the standard XYZ grid, carrying a layer named buildings whose features are the footprints. Each feature needs osm_id and osm_type, and takes its shape from the OSM tags it carries — height, levels, roof_type, material and the rest; a footprint with none of them extrudes to the generator's one-storey default. Attributes absent from a feature mean "unknown", so write only the ones the building actually has. The full attribute list is in the sample's specs/mvt-buildings-layer.md.

Anything that serves those tiles will do: the generator's companion osm-data-vector-tiles (PostGIS + osm2pgsql), a Tegola or Martin server over your own OSM import, or a directory of .pbf files behind a static server, which is what the worked example does.

2. Run the generator

git clone https://github.com/TANK2003/osm-data-3d-tiles
cd osm-data-3d-tiles && npm install && npm install --no-save dotenv

cat > .env <<'ENV'
TILE_URL=http://localhost:8899          # serves /16/<x>/<y>.pbf
EXTENT=-8847116.5,5403748.5,-8830418.6,5417593.6   # minX,minY,maxX,maxY, EPSG:3857
ENV

mkdir -p exported/subtiles exported/b3dm
npm run generate-tileset -- --projection ecef     # tileset.json + subtiles/
npm run seed-b3dm -- --tile_json tileset.json     # bake every b3dm up front

--projection ecef writes an Earth-centred tileset, which is the portable choice: the viewer levels it, and Cesium, QGIS and Giro3D place it on the globe. --projection mercator writes the same content in a local mercator box instead. Seeding is what makes the result stand alone — without it the tiles are generated on demand by the project's own Express server, and there is nothing to hand to anyone else.

3. Lay it out so the URIs resolve

The generator writes content into exported/b3dm/ while the sub-tilesets naming it live in exported/subtiles/; those URIs resolve only through its Express server, which looks tiles up by filename. A tileset read from disk or from a static host resolves each URI against the file it appears in, so copy the .b3dm files in beside the sub-tilesets that name them:

tileset.json          # root, one child per sub-tileset
subtiles/12_*.json    # a sub-tileset per zoom 12 tile
subtiles/16_*.b3dm    # content, a sibling of the sub-tileset naming it

Keeping the content inside the directory it is referenced from is also what the viewer requires of a tileset it did not write: a local tileset may read only files under its own directory (see above), so a ../b3dm/… reference out of subtiles/ is refused. The generator also lists a full 16×16 grid of zoom 16 children under every zoom 12 sub-tileset, including tiles that are open water or outside the area, so drop the children whose content was never written.

4. Ground it

The generator exports buildings and nothing else, so on its own an export is a city of facades over a void: nothing to stand on, and from the air no way to tell a street from a rooftop. A second content beside the buildings fixes both — a quad covering the tile, textured with a map of that tile drawn from OSM roads, water and parks, listed with the buildings under 3D Tiles 1.1 contents so the two stream and page together. The worked example below draws its own map tiles rather than fetching rendered ones, which keeps the whole export redistributable under the data's own licence.

5. View it

oglc-view path/to/tileset.json           # auto-framed, free-fly
oglc-view path/to/tileset.json --sse 8 --memory 64   # sharper, small budget: pages hard
oglc-view path/to/tileset.json --physics # walk it with gravity and collision

g drops into the dataset from wherever the camera is — the avatar takes the camera's position and gravity brings it down to the roof or street below it — and g again hands the camera back to free-fly where you left off. In a dataset that says it is in metres (any geospatial tileset) the avatar is a person: 1.8 m, walking at 3 m/s, running at 6 with shift, and f flies it. Sized against the extent instead, walking a city would spawn a 300-metre giant in the middle of the lake.

A dataset opens over its content rather than outside its bounding sphere — hovering above the middle of it, close enough that buildings are buildings — and flying speed is sized to the dataset when it is framed, so a city crosses in about twenty seconds rather than half an hour and the paging is visible as you move. Hold shift to fly faster still; the settings screen tunes both (see Movement Modes).

A worked end-to-end example of all four steps — Toronto from High Park to the Don Valley and the Islands, 83,064 buildings in 436 tiles, 41 MB — lives in toronto-3dtiles/ beside this checkout: one build.sh over scripts for the Overpass download, the MVT encoding and the layout, plus a verifier that checks every tile's placement against the tile it claims to be. Streamed with --memory 8 it loads 152 tiles and evicts 92 over a single traverse, which is the paging behaviour a dataset that size is there to exercise.

OpenStreetMap data is ODbL 1.0: an export made this way, and anything derived from it, carries "© OpenStreetMap contributors" and the same licence terms with it.

Making a world

Three sources feed one baker. Each writes a tileset.json plus its .glb tiles and returns the tileset path.

from OpenGLContext.loaders.tiles3d import procedural, dem

# 1. Procedural: hills, canyon, lake, mountains (per-vertex coloured, with skirts).
procedural.build_terrain_tileset("world/", extent=2048, levels=3, tile_res=33)

# 2. A real heightmap (grayscale DEM from QGIS/USGS/SRTM, any format PIL reads):
dem.build_dem_tileset("dem.png", "world/", extent=4096,
                      height_scale=600, base=-40, levels=4)

# 3. Your own function y = f(x, z) (numpy arrays in, heights out):
procedural.build_terrain_tileset("world/", height_fn=my_height_fn)

Mount the result and drive it from the camera each frame:

from OpenGLContext.scenegraph.tilesterrain import TilesTerrain
terrain = TilesTerrain("world/tileset.json", max_sse=16.0,
                       physics_world=world)          # physics_world optional
# in your render/idle callback, before the pass runs:
terrain.update_for_camera(eye_xyz, viewport_height, view_projection=vp_matrix)

A quadtree of depth L has sum(4**l) tiles; each is meshed at tile_res vertices per edge, so deeper tiles cover less ground at the same vertex count (finer detail). The procedural field (value-noise fBM + ridged mountains + a carved canyon + a lake basin) is procedural.terrain_height; colours come from height and slope in terrain_colors. A skirt is dropped around every tile edge (terrain_patch(skirt_depth=…)) so seams between adjacent LOD tiles show no gaps.

Vegetation

Vegetation scatters deterministically on the tile surface, area-weighted, and filtered to sensible places (grass elevations, not water or peaks). Every instance shares one prototype so the instancing engine collapses them to a single draw. Trees use a distance LOD — a full mesh near the camera, a cheap billboard far away — and grass is a dense blade layer limited to a disc around the viewer.

from OpenGLContext.loaders.tiles3d.vegetation import (
    build_vegetation_lod, build_grass_patch)
pos, nrm, col, idx = procedural.terrain_patch(-700, 700, -700, 700, 48)
trees = build_vegetation_lod(pos, idx.reshape(-1, 3), near_mesh, far_billboard,
                             density=0.00035, seed=7, camera=eye,
                             near_distance=450,
                             keep=lambda p: (p[:,1] > 4) & (p[:,1] < 130))

scatter.py (per-triangle uniform barycentric sampling, seeded, with a keep mask) and vegetation.py (group_from_scatter, partition_by_distance, build_vegetation_lod, build_grass_patch).

Refining & tuning

KnobEffect
--sse / max_ssescreen-space-error target in pixels; lower = more detail (more tiles, higher cost)
--memory / memory_budgetresident tile byte budget; smaller forces more aggressive eviction/streaming
--extent, --levels, --tile-res world size, LOD depth (tile count), and per-tile mesh resolution
prefetch_factorhow far ahead finer tiles load before they are strictly needed (hides pop-in when moving)
hysteresissticky refinement so LOD does not flicker at the threshold
--dem, --height-scale, --base ingest a real heightmap; base<0 sinks low areas below the water level so they read as lakes/sea

The runtime exposes these on TilesetRuntime/ TilesTerrain constructors. Frustum culling is enabled by passing a view_projection to update_for_camera; without it, tiles are considered by distance only (useful for tests, wasteful for a real view).

Caves, overhangs & arbitrary 3D

Because tiles are ordinary glTF meshes, terrain is not limited to a height surface: a cave, arch or overhang is just a tile with arbitrary geometry, placed in the octree and streamed, culled and collided like any other. A heightfield cannot express the solid-air-solid column of an overhang; a glTF tile can.

sample.build_overhang_tileset bakes an elevated slab over ground (genuine solid-air-solid) that streams, renders and yields a walkable collider — the pattern a procedural voxel/Transvoxel cave baker would follow.

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, sampled bilinearly — 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.

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 the older behaviour and 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.

scenegraph/terrain/ (heightfield.py, splat.py), scenegraph/vegetation/ (instanced clumps, billboards and near meshes) 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 and test_terrain_vegetation.py.

Demos & validation

The behaviour is pinned by tests/tiles3d/ (SSE, traversal, residency, load manager, runtime, frustum, procedural terrain, DEM, scatter/vegetation, geomorph/skirts, and navigation: gravity settles the avatar on the surface, walking follows it, flying ascends, fly→walk drops to the surface — including on the actual streamed colliders) plus offscreen render regressions (tests/test_tiles_*_render.py). Run them with python -m pytest tests/tiles3d/.