Open-world landscapes: heightfield terrain up to 4 km x 4 km at 1 m (8 km at 2 m),
splat-painted ground, grass, scattered trees and rocks, water, roads, runtime editing.
Runs in WebGPU or WebGL2 on laptops and phones. Everything is gander:: in Goose.
A terrain is centered on the world origin: it covers x, z in [-size/2, size/2],
heights are world y, 0 is sea level. Call gander::draw_terrain(t) every frame after
the camera; it draws the ground, grass, scatter and water in one go.
Make one
var land: gander::Terrain;
fn main() {
gander::config("Open world", 1280, 720, 0);
land = gander::terrain_generate(gander::TerrainGen { size: 2048.0, height: 260.0, seed: 7 });
gander::camera_clip(0.1, 5000.0); // see to the horizon
gander::fog(gander::hex(0xc8d4de), 0.0004, 0.003, 200.0);
}
export "gander_frame" fn frame(dt: f32) {
let p = gander::v3(0.0, 0.0, 0.0);
let eye = gander::v3(p.x, gander::terrain_height(land, p.x, p.z) + 1.7, p.z);
gander::camera(eye, eye + gander::v3(0.0, 0.0, -1.0), 70.0);
gander::sun(gander::v3(-0.4, -0.6, -0.3), gander::hex(0xffe6c4), 3.2, true);
gander::draw_terrain(land);
}
TerrainGen fields (all optional): size meters per side (2048), resolution samples
per side (0 = about 1 m, capped at 4097, 2049 on phones), height tallest peak (220),
seed, roughness 0 rolling .. 1 jagged, ridges 0..1 mountain ridges, warp 0..1
organic shapes, erosion 0..1 gullies and smooth valleys, island (edges sink below the
sea), flat_radius + flat_x / flat_z (a flat play area or base).
Other sources:
| Call | Use |
|---|---|
terrain_load(path, size, height_min, height_max) |
8 or 16-bit PNG heightmap (black = min, white = max), or .r32 raw float32 meters. size 0 = 1 m per pixel |
terrain_from_heights(heights, samples, size) |
your own f32 array, samples * samples, rows along +z |
terrain_load_at / terrain_from_heights_at |
the same, placed anywhere and rectangular (next section) |
Load times in the browser: 2 km procedural ~3 s, 4 km ~4 s (erosion included).
Placed areas: several terrains side by side
A terrain is centred on the origin unless you place it. A level with its own ground per area (a fen, a climb, a crag under a castle, each one an Unreal sublevel landscape in a port) lays each where the level plan puts it, any size and aspect, and frees the ones the player has left:
// corner (sample 0, 0) at x -946, z -316; 946 x 632 m; black -8 m, white 30 m
fen = gander::terrain_load_at("assets/terrain/fen.png", -946.0, -316.0, 946.0, 632.0, -8.0, 30.0);
climb = gander::terrain_from_heights_at(heights, 505, 222, 2.0, 0.0, -220.0); // samples x, z, cell, corner
gander::terrain_physics(fen, true);
// every frame: draw the ones that are loaded
gander::draw_terrain(fen);
gander::draw_terrain(climb);
// the player crossed into the abbey band: give the fen back
gander::terrain_free(fen);
| Call | Use |
|---|---|
terrain_load_at(path, x, z, size_x, size_z, height_min, height_max) |
a PNG (or .r32 whose float count fits size_x:size_z) over a rectangle with its corner at (x, z); size 0 = 1 m per pixel |
terrain_from_heights_at(heights, samples_x, samples_z, cell, x, z) |
your own heights, rows along +z |
terrain_place(t, x, z) |
move any terrain's corner to (x, z) (its physics body and scatter follow) |
terrain_bounds(t) -> float4 |
x, y = corner x, z; z, w = size x, size z |
terrain_free(t) |
free its heights, GPU textures, scatter and physics; the handle is dead (calls on it warn once) |
A rectangle is held in a square grid of the longer side; past the shorter
side nothing is drawn, scattered, raycast or collided, and its water ends at
the rectangle's edge, so two areas can touch or overlap a little without
fighting. Memory is the square's (a 946 x 632 m area at 2 m: 513 x 513
samples, 1 MB). Queries (terrain_height, terrain_raycast) answer for that
terrain only: ask the one under the point (terrain_bounds), or raycast the
physics world, which sees every terrain with physics on. Put each area's
terrain in that area's group (gander::group, in the API sheet) and
group_free frees it with the rest of the area.
Ask it things
| Call | Returns |
|---|---|
terrain_height(t, x, z) |
ground y (bilinear) |
terrain_normal(t, x, z) |
ground normal |
on_terrain(t, x, z) |
float3 on the ground |
terrain_raycast(t, origin, dir, max_dist) |
gander::Hit (dist < 0: none): bullets, mouse picking, line of sight |
terrain_layer_at(t, x, z) |
dominant ground layer: footstep sounds, tire grip |
terrain_size / terrain_samples / terrain_cell / terrain_origin / terrain_min_height / terrain_max_height |
dimensions |
terrain_heights(t, out) |
every height into a growable f32[>..<] (physics) |
terrain_patches() / terrain_scatter_count() |
last frame's draw counts (probe these in tests) |
Ground layers
Eight splat layers, procedural by default, so a terrain looks finished with no art:
| Layer | Default rule |
|---|---|
LAYER_GRASS 0 |
everywhere else |
LAYER_DIRT 1 |
noise patches, mid slopes |
LAYER_ROCK 2 |
steep slopes (triplanar, no stretching on cliffs) |
LAYER_SAND 3 |
just above sea level |
LAYER_SNOW 4 |
above snow_height |
LAYER_DRY_GRASS 5 |
noise patches |
LAYER_GRAVEL 6 |
paint only (roads, paths) |
LAYER_MUD 7 |
paint only |
gander::terrain_look(land, gander::TerrainLook { snow_height: 180.0, rock_slope: 0.38, dirt: 0.15, dry: 0.6 });
gander::terrain_layer(land, gander::LAYER_GRASS, "assets/textures/meadow.png", "assets/textures/meadow_normal.png", 4.0, 0.9);
gander::terrain_layer_tint(land, gander::LAYER_ROCK, gander::hex(0xb8a890), 0.0); // warmer rock, keep tile size
TerrainLook: sea_level, sand_height, snow_height (default: none unless the
terrain tops 350 m), rock_slope (0 flat .. 1 vertical), dirt, dry (0..1), tint.
terrain_layer takes any tileable image (Forge / gpt-image "seamless tileable texture",
Poly Haven CC0), an optional normal map ("" derives one), meters per repeat, roughness.
terrain_debug(t, 1) shows lighting on flat grey, 2 normals: for checking the shape.
Grass, trees, rocks
gander::terrain_grass(land, gander::Grass { .. }); // lush default
gander::terrain_grass(land, gander::Grass { density: 0.6, height: 0.4, color: gander::hex(0x6b6a3a), tip: gander::hex(0xb8b070) }); // dry steppe
Grass grows on the grass and dry-grass layers only (never on roads, rock or sand), sways
in the wind and fades out at radius. It costs no memory: every blade is computed on the
GPU. Phones get half the density automatically.
Scatter places a model or mesh by rules, streamed in 32 m cells around the camera, so a 4 km map never holds millions of instances:
let pine = gander::model("assets/models/pine.glb");
let rock = gander::model("assets/models/rock.glb");
gander::terrain_scatter_model(land, pine, gander::Scatter { density: 1.2, spacing: 4.0, layer: gander::LAYER_GRASS, height_min: 8.0, radius: 400.0 });
gander::terrain_scatter_model(land, rock, gander::Scatter { density: 0.4, scale_min: 0.4, scale_max: 2.0, layer: gander::LAYER_ROCK, slope_max: 0.8, align: 1.0, sink: 0.25 });
Scatter: density per 100 m^2, spacing minimum meters apart, scale_min / scale_max,
slope_max, height_min / height_max, layer (only where it dominates; -1 anywhere
dry), align 0 upright .. 1 follows the slope, radius shown distance, sink (bury the
base), seed, shadows, impostor. Two rules with the same seed and numbers land on the same spots:
a trunk mesh and a crown mesh make one tree. terrain_scatter_mesh(t, mesh, material, s)
does the same for gander::mesh geometry. Meshy / Forge trees and rocks: generate them at
real scale (or set scale_min / scale_max), keep them low poly (they are drawn hundreds of
times), gander::texture_limit(1024) before loading them.
Far trees are cards. A model rule with radius 120 or more draws its instances past
impostor meters (auto: 30% of the radius, 50 to 120 m) as impostors: one camera-facing
card each, baked by the engine from the model at 16 angles and lit like it (sun side, fog),
fading in over the real tree. That makes forests to the horizon cheap, so radius: 800.0
or more is fine for open worlds. impostor: 0.0 keeps real models all the way (close-range
maps), a number sets the distance; slope-aligned rules (align 0.3 and up) and mesh rules
never use cards. Your own far objects (a skyline, a fleet on the horizon):
gander::draw_impostor(model, xf, fade), with fade 0..1 dithering it in where you switch.
Water
gander::terrain_water(land, gander::Water { level: 0.0 });
gander::terrain_water(land, gander::Water { level: 4.0, shallow: gander::hex(0x6fa58a), deep: gander::hex(0x1d3b33), clarity: 1.5, waves: 0.4 }); // murky lake
A water plane at level to the horizon, colored by depth from the heightfield, sky
reflection, sun glint, waves and shoreline foam. It also moves the sand line to the water.
Edit it (roads, craters, building pads)
var road: float3[>..<] = [];
for i: i32 in 0..20 {
let k = i as f32 / 19.0;
road.push(gander::v3(k * 800.0 - 400.0, -1000000.0, sin(k * 4.0) * 100.0)); // y -1e6: follow the ground
}
gander::terrain_road(land, road, 7.0, gander::LAYER_GRAVEL); // flatten 7 m wide, banked shoulders, painted
gander::terrain_flatten(land, 120.0, -60.0, 25.0, 34.0, 1.0); // a pad for a base at y 34
gander::terrain_raise(land, 10.0, 10.0, 6.0, -2.5); // an explosion crater
gander::terrain_smooth(land, 10.0, 10.0, 8.0, 0.5);
gander::terrain_paint(land, 10.0, 10.0, 7.0, gander::LAYER_MUD, 0.8);
Edits are cheap enough to do every frame (a terraforming tool): only the touched area is re-uploaded. Scatter and grass follow the new ground.
Feed physics
Simplest: gander::terrain_physics(land, true) once. The engine makes the heightfield
body (and remakes it after edits); on a streamed world it makes one per 256 m tile near
the camera as regions load. Bodies standing on ground that unloads go to sleep in place
instead of falling through. Before spawning the player or dropping bodies on a streamed
world, wait for gander::terrain_ground_ready(land, x, z).
By hand, the physics module takes a heightfield of floats. One-time, after creating the terrain:
var heights: f32[>..<] = [];
gander::terrain_heights(land, heights);
let n = gander::terrain_samples(land);
let ground = gander::body_heightfield(heights, n, n, gander::terrain_cell(land), gander::terrain_origin(land));
After editing the ground, rebuild the heightfield body the same way. For a character
controller without full physics, terrain_height under the feet is enough.
Streamed worlds (8 to 16 km)
Past 4 km, use terrain_world: the same TerrainGen, baked once into 1 km regions that
load around the camera, with the whole map drawn to the horizon from a light overview.
land = gander::terrain_world("highlands", gander::TerrainGen { size: 12288.0, height: 420.0, seed: 9,
ridges: 0.7, erosion: 0.6, flat_radius: 80.0, flat_x: -3000.0, flat_z: 2000.0 });
gander::terrain_physics(land, true);
gander::camera_clip(0.1, 6000.0); // the default far plane (1000 m) hides everything past 1 km
Then run gander terrain bake (it builds natively, generates every terrain_world in
main() and writes assets/terrain/<name>/ + assets/stream/terrain/<name>/); run it
again whenever a TerrainGen changes (the game logs a warning when the bake is stale).
Until baked, terrain_world simply generates the map in memory like terrain_generate.
- Sizes: up to 8192 m at 1 m samples, 16384 m at 2 m (rounded up to whole kilometres). Each region is about 1.2 MB, fetched when the camera nears it; the overview (~1.5 MB) ships in the game's first download.
- Resident at full detail: 3 x 3 regions on the web, 5 x 5 in Pro desktop builds
(
terrain_stream_radius(land, r)to choose). Grass, scatter, decals and water work as on any terrain; regions still loading show the overview, never a hole. - Everything else works on the handle: heights, normals, raycasts, layers, edits (kept for
regions that are loaded; a region keeps its edits from then on), look, layers, grass,
scatter rules, water.
terrain_regions_resident(land)is a handy probe. - Spawn only once
terrain_ground_ready(land, x, z); a test needs a few seconds of frames for the first regions (gander testlets downloads land between steps).
Recipes
Open-world FPS map. terrain_generate with size 4096, ridges 0.6, erosion 0.4,
flat_radius 60 for the spawn, a few terrain_roads between points of interest,
terrain_flatten pads where you place buildings, grass + pine and rock scatter, fog
density 0.0003..0.0006 so the horizon fades, camera_clip(0.1, 5000.0),
shadow_distance(120.0). Walk with y = terrain_height(x, z) + 1.7; slow the player on
terrain_normal(...).y < 0.75.
Island. island: true, terrain_water(Water { .. }), a scatter of palm models with
height_min: 3.0, sand comes free along the water.
Racing track. Build the track as a closed loop of points, terrain_road(t, track,
14.0, gander::LAYER_GRAVEL) (or a tarmac texture in layer 6 via terrain_layer), grass
with radius 60, cars read terrain_layer_at for grip.
Desert / snow. terrain_look with dry: 1.0 and a sand-colored grass tint, or a low
snow_height and grass off, then terrain_layer with Poly Haven textures for close-ups.
Sizes and memory
| Map | Samples | CPU heights | GPU | Notes |
|---|---|---|---|---|
| 1 km at 1 m | 1025^2 | 4 MB | ~35 MB | phones fine |
| 2 km at 1 m | 2049^2 | 17 MB | ~70 MB | default; phones cap here |
| 4 km at 1 m | 4097^2 | 67 MB | ~85 MB | desktop |
| 8 km at 2 m | 4097^2 | 67 MB | ~85 MB | set resolution: 4097 |
| 8-16 km streamed | regions | ~80 MB (3 x 3) | ~130 MB web, ~380 MB Pro | terrain_world + gander terrain bake |
GPU memory stays bounded however big the map: a whole-map overview (at most 2049^2) plus
a 1025^2 full-resolution window that follows the camera. The 8 ground layers take
~22 MB, painted layers (roads, terrain_paint) add up to 34 MB.
Rules
- Call
draw_terrainaftercamera, every frame. - Do not draw a
MESH_PLANEground under a terrain: the terrain is the ground. - Keep heights in meters (a 2 km map with 5000 m peaks looks like spikes); 150..400 m reads as hills to mountains.
- Test with probes:
terrain_patches()(typically 30..120) andterrain_scatter_count().