# gander terrain 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 ```goose 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: ```goose // 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 | ```goose 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 ```goose 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: ```goose 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 ```goose 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) ```goose 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: ```goose 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. ```goose 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//` + `assets/stream/terrain//`); 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_road`s 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()`.