Home/GANDER docs/GANDER terrain

GANDER terrain

Heightfield landscapes up to 8 km, splat-painted ground, grass, scattered trees and rocks, water, roads and runtime editing.

terrain.mdllms-full.txt

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 test lets 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_terrain after camera, every frame.
  • Do not draw a MESH_PLANE ground 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) and terrain_scatter_count().