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.
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.
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:
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:
http(s) may reference only the
same origin — the scheme, host and port it was fetched
from — re-checked on every redirect, so a tile URI cannot name another
host or an address on the local network;tiles3d.fetch.DEFAULT_MAX_TILE_BYTES, 256 MiB by default;
pass max_bytes to read_bytes for a dataset that
genuinely ships larger tiles).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.
http(s):// URLs for the root, tile
content and nested tilesets, with an on-disk fetch cache.b3dm tile content (the Batched-3D-Model
wrapper is unwrapped to its embedded GLB and rendered by the normal glTF loader).region bounding volumes
(regions convert to WGS 84 ECEF, as used by Cesium ion / Google
Photorealistic / most GIS tilesets)..json is loaded and grafted into the tree.--no-recenter switches off, which gives
the dataset exactly as written. Non-identity tile transforms are honoured.asset.gltfUpAxis asks (Y when a tileset does not say;
Z means the content is already in the tile frame, which is what the
bakers here write). Without this a conforming export renders on its side.contents),
e.g. buildings and trees as separate glTF combined into one tile.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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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).
| Knob | Effect |
|---|---|
--sse / max_sse | screen-space-error target in pixels; lower = more detail (more tiles, higher cost) |
--memory / memory_budget | resident 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_factor | how far ahead finer tiles load before they are strictly needed (hides pop-in when moving) |
hysteresis | sticky 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).
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.
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
| 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 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.
oglc-terrain — the procedural/DEM interactive viewer (above).oglc-view — stream and fly a real OGC 3D Tiles
tileset.json (b3dm, region volumes, external + ECEF tilesets).tests/tiles_landscape.py — aerial showcase (terrain + vegetation).tests/tiles_walk.py — first-person walk/fly.tests/tiles_terrain.py, tests/tiles_vegetation.py
— minimal heightfield / instanced-vegetation demos.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/.