Terrain
Tile-based heightfield terrain under Shade - meshlet tile entities with splat materials, streamed into the scene, and a cling-to-terrain component that pins entities to the surface.
Terrain is an ECS component holding a heightfield, a splat weight map and a list of material layers. TerrainSystem creates a ShadedGeometry entity for each tile, built in a worker and streamed in as the camera sees it. ShadedGeometrySystem places those tiles in the scene, and their shared TerrainShadeMaterial mixes the splat layers and grid overlay during G-buffer shading. Register both systems to draw a terrain.
The terrain example builds a 540 x 540 heightfield with four splat-mapped layers loaded from PNGs.
Two TerrainSystems, and only one of them draws
The package carries a second system beside the component, and it resolves:
// Imports, constructs, links, and renders nothing.
import TerrainSystem from "@woosh/meep-engine/src/engine/ecs/terrain/ecs/TerrainSystem.js";
src/engine/ecs/terrain/ecs/TerrainSystem.js is a default export and a working System: it builds the component, keeps a BVH, culls tiles against the active camera and ticks terrain.update(). It puts nothing in front of the renderer - it adds no mesh to a Shade Scene and registers no render extension, and Terrain.link() only starts the tile build worker. An app that registers this system gets a terrain that answers height queries perfectly and is invisible.
The system that draws is a named export from graphics3/:
import { TerrainSystem } from "@woosh/meep-engine/src/engine/graphics3/TerrainSystem.js";
Everything below describes that one. Its tile entities require ShadedGeometrySystem on the same scene.
Setup
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { TerrainSystem } from "@woosh/meep-engine/src/engine/graphics3/TerrainSystem.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
const scene = EngineHarness.shadeScene(engine);
config.addSystem(new ShadedGeometrySystem(engine.graphics, scene));
config.addSystem(new TerrainSystem(
engine.graphics, // GraphicsEngine
scene,
engine.assetManager,
));
},
});
dependencies = [Terrain]. Three things about that constructor are worth stating outright:
- The scene is the second argument and it must be the right one.
EngineHarness.shadeScene(engine)hands out the one ShadeSceneper engine, the same oneMeshSystemandLightSystemwrite into. GiveTerrainSystema scene nothing draws and the terrain is simply absent from the frame, with no error. - No image loader registration is needed.
startup()callsassetManager.tryRegisterLoader(GameAssetType.Image, new ImageRGBADataLoader())itself. There is noTextureAssetLoader, andGameAssetType.Textureis an enum entry with no loader behind it - layer diffuse maps and the overlay sprite load asGameAssetType.Image, which decodes to aSampler2D. - You do not call
terrain.build()orterrain.link(). The system’slink()builds the component if itsBuiltflag is clear and then callsstartBuildService()directly, subscribes to the tile manager’stileBuilt/tileDestroyedsignals, and adopts any tile that was already standing.
EngineHarness.buildBasics({ enableTerrain: true }) registers TerrainSystem and builds a small terrain for you. Register ShadedGeometrySystem in your configuration as above so its tiles render. The example above is what you write when you want to author the heightfield yourself.
Decals run at AfterGBuffer, after the terrain material has shaded its surface, so they composite on top of textured ground. See Render extensions.
Building a terrain
import Terrain from "@woosh/meep-engine/src/engine/ecs/terrain/ecs/Terrain.js";
import { TerrainLayer } from "@woosh/meep-engine/src/engine/ecs/terrain/ecs/layers/TerrainLayer.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
const CELLS = 64; // grid cells per side
const GRID_SCALE = 10; // world units per cell -> 640 unit world
const SAMPLES = 128; // height sampler resolution, independent of CELLS
const terrain = new Terrain();
terrain.size.set(CELLS, CELLS);
terrain.gridScale = GRID_SCALE;
terrain.resolution = 2; // render quads per cell
// Height field: Float32, one channel. Sampled bicubically in UV space, so its
// resolution is free - it does not have to match `size` or the collision grid.
terrain.samplerHeight.resize(SAMPLES, SAMPLES);
for (let r = 0; r < SAMPLES; r++) {
for (let c = 0; c < SAMPLES; c++) {
terrain.samplerHeight.data[r * SAMPLES + c] = myHeightFn(c, r);
}
}
// One splat layer, full weight everywhere.
terrain.splat.resize(CELLS, CELLS, 1);
terrain.splat.fillLayerWeights(0, 255);
terrain.layers.addLayer(TerrainLayer.from("./textures/grass.png", 10, 10));
const t = new Transform64();
t.setTranslation(-CELLS * GRID_SCALE / 2, 0, -CELLS * GRID_SCALE / 2);
new Entity().add(t).add(terrain).build(ecd);
Terrain is a default export; TerrainLayer is named.
The Transform64 is optional as far as linking goes - a terrain links without one - but it is what places the tiles and what the raycasts invert, so in practice every terrain has one.
Key properties
| Property | Type | Default | Description |
|---|---|---|---|
size | Vector2 | (0,0) | Grid dimensions in cells. World footprint is size x gridScale. |
gridScale | number | 1 | World units per cell. |
resolution | number | 4 | Render quads per grid cell. A tile of E cells carries (E x resolution)^2 vertices. |
samplerHeight | Sampler2D | 1x1 | Float32, one channel, world-unit heights. Sampled bicubically in UV, so any resolution works. |
splat | SplatMapping | empty | Per-cell Uint8 weights, one layer deep per material layer. |
layers | TerrainLayers | empty | The material layers and the texture array they are packed into. |
overlay | TerrainOverlay | - | Per-cell RGBA painted over the terrain. See Water & overlays. |
clouds | Clouds | - | Cloud-shadow settings. Data only - nothing draws them. See Water & overlays. |
gridTransformKind | GridTransformKind | Direct | How grid coordinates map to world. See Grid transform. |
frustumCulled | boolean | true | Inert. Only the non-drawing system reads it; the drawing system always culls. |
lightMapURL | string|null | null | Inert. Round-trips through serialization; no renderer reads it. Terrain ambient occlusion is the renderer’s GTAO. |
There is no per-terrain shadow bias - bias is the renderer’s, see Lights & shadows - and no working buildLightMap(), see Ambient occlusion.
How a terrain is drawn
The tile is the unit, not the terrain. Tiles are up to 32 x 32 cells, subdivided further when resolution pushes a tile past the 65,536-vertex budget - the edge works out as max(1, min(32, floor(256 / resolution))) cells, and the last row and column of the grid are whatever is left over. Each built tile becomes one meshlet-encoded ShadedGeometry entity, removed when the tile manager destroys it. Tile geometry is built in the background worker (bundle-worker-terrain.js), which also relaxes the planar UVs against 3D edge length and builds the per-tile BVH the CPU raycasts use. Meshlet encoding happens on the main thread when the tile arrives. The terrain material derives its planar coordinates from geometry-space position so splat weights and the overlay stay aligned with the gameplay grid.
Tile vertices are already in terrain space, so every tile carries a copy of the terrain entity’s Transform64, together with ShadedGeometry, Name, ParentEntity and transient serialization metadata. The system polls the terrain transform each frame and writes it only when it differs. The tile belongs to the terrain for lifetime management and is rebuilt from terrain data on load. terrainSystem.tiles_of(entity) returns its current tile entity IDs.
Every tile shares its terrain’s TerrainShadeMaterial. Its TERRAIN_MATERIAL_TEMPLATE evaluates the splat mix and overlay as the G-buffer is shaded. Roughness is 1, metalness is 0, the normal comes from the geometry, and there is no emission. Each terrain has its own material and textures; equal settings do not merge two terrains. TerrainSystem keeps the parameters current, and sampler versions control texture uploads. Lighting, ambient occlusion and screen-space reflections read the resulting surface through the usual G-buffer.
Splat layers
Layer weights live in terrain.splat, one Uint8 channel per layer per cell; the layer diffuse maps live in terrain.layers, packed into a single texture_2d_array.
const grass = terrain.layers.addLayer(TerrainLayer.from("grass.png", 8, 8));
const rock = terrain.layers.addLayer(TerrainLayer.from("rock.png", 12, 12));
const sand = terrain.layers.addLayer(TerrainLayer.from("sand.png", 6, 6));
// The splat map carries one weight channel per layer - keep the depth in step.
terrain.splat.resize(CELLS, CELLS, 3);
terrain.splat.fillLayerWeights(grass, 255);
terrain.splat.fillLayerWeights(rock, 0);
terrain.splat.fillLayerWeights(sand, 0);
// Or write a mask in from a Sampler2D of any resolution. Sizes that match copy
// straight across; anything else is bilinearly resampled into the splat grid.
terrain.splat.writeLayerFromSampler(myMask, rock, /* channel */ 0);
Weights are normalised by their own sum in the shader, so masks may overlap freely and do not have to add up to 255. An unclaimed pixel has black ground colour; the material forces the ground opaque before applying the overlay. terrain.replaceLayers(layers, assetManager) swaps the whole set on an already-built terrain without throwing away tile geometry; addLayer grows the splat map by one channel for you.
TerrainLayer field | Description |
|---|---|
textureDiffuseURL | Asset URL of the diffuse map, loaded as GameAssetType.Image. |
size | Vector2 - world units per texture repeat. The material derives the tiling as world size / layer.size. |
diffuse | The decoded Sampler2D. Assigning one directly works; layers with an empty URL skip the rescale cache. |
extra | Free-form JSON that survives serialization. |
Sampler settings the material declares:
| Texture | Filtering | Wrap |
|---|---|---|
| Layer diffuse array | trilinear over a full mip chain, maxAnisotropy: 8 | repeat - the layers tile |
| Splat weights | linear magnification, nearest minification, no mips | clamp to edge |
| Overlay sprite | linear | clamp to edge |
Two limits:
- The layer count is a uniform, not a
#define. Adding or removing a layer recompiles nothing; the mix loop reads the material’slayer_count. - The ceiling is the device’s texture-array depth. All layers are slices of one
texture_2d_array, so the hard bound ismaxTextureArrayLayers. Shade does not raise that limit, so what you get is the adapter’s - 256 under the WebGPU defaults.
Every layer is resampled to terrain.layers.resolution (512 x 512 by default) before it goes into the array, in linear light rather than on sRGB bytes. The CPU-side array costs resolution.x * resolution.y * 4 * layerCount bytes, so 1 MB per layer at the default.
Layer diffuse maps are uploaded as sRGB, so the hardware decodes each texel before filtering and the splat blend averages in linear space.
Height queries
Terrain keeps a BVH per tile and answers raycasts synchronously against it, on the CPU, with no renderer involvement:
import { SurfacePoint3 } from "@woosh/meep-engine/src/core/geom/3d/SurfacePoint3.js";
const hit = new SurfacePoint3();
// Vertical ray down through world (x, z).
if (terrain.raycastVerticalFirstSync(hit, x, z)) {
console.log(hit.position.y, hit.normal);
}
// Arbitrary ray.
terrain.raycastFirstSync(hit, ox, oy, oz, dx, dy, dz);
Both return false if the tile under that point has not been built yet. terrain.promiseAllTiles() resolves once every tile is built, which is the way to guarantee an answer for off-screen ground. sampleHeight(x, z, onHit, onMiss, onError) wraps the vertical cast in callbacks and hands onHit the height, and projectPointsVertical / mapGridPoints drop whole arrays of points onto the surface, interpolating over any that miss.
Grid and world coordinates convert through mapPointGrid2World(x, y, v3) and mapPointWorld2Grid(v3, result).
This path is entirely separate from PickingSystem - terrain picking does not go through the renderer.
Editing heights at runtime
The build worker holds its own copy of the height field, so writing into samplerHeight.data changes nothing on its own:
terrain.samplerHeight.data[index] = newHeight;
terrain.samplerHeight.version++;
await terrain.updateHeights(); // ships the field to the worker
updateHeights() sends the snapshot and hands the tile manager the new version. Tiles built against an older snapshot are marked unbuilt, and the ones the camera can see are rebuilt on the next frames; the tile mesh in the scene is replaced rather than duplicated. To limit the work to a region, terrain.tiles.rebuildTilesByUV(u0, v0, u1, v1) retires only the tiles overlapping a normalised rectangle.
Ambient occlusion
terrain.buildLightMap() throws if called: there is no terrain AO bake. Terrain ambient occlusion is the renderer’s GTAO, on by default (renderer.feature_ssao_enabled); see Frame features. lightMapURL round-trips so level data that carries it still loads, but nothing reads it.
Cling to terrain
ClingToTerrain + ClingToTerrainSystem pins an entity’s Y to the terrain surface as its XZ changes. Both are default exports, and it is the primitive that makes trees, rocks and units sit on the land.
import ClingToTerrain from "@woosh/meep-engine/src/engine/ecs/terrain/ecs/cling/ClingToTerrain.js";
import ClingToTerrainSystem from "@woosh/meep-engine/src/engine/ecs/terrain/ecs/cling/ClingToTerrainSystem.js";
await em.addSystem(new ClingToTerrainSystem());
const cling = new ClingToTerrain();
cling.normalAlign = true; // rotate to match the surface normal
cling.rotationSpeed = 3; // rad/s cap on that correction
entity.add(cling);
| Property | Type | Default | Description |
|---|---|---|---|
normalAlign | boolean | false | Align the entity’s up axis with the surface normal. |
rotationSpeed | number | 3 | Maximum angular correction in rad/s. Non-finite values snap instantly. Deserializing JSON that omits the field yields Infinity. |
dependencies = [ClingToTerrain, Transform64]. The system listens for TRANSFORM64_EVENT_CHANGE on the entity and queues it whenever the transform is announced, then processes up to updateBatchLimit (default 1024) per tick, so a level’s worth of static props spreads over several frames. It announces the transform itself after each clamp, so whatever draws the prop follows. It finds the terrain with obtainTerrain(dataset), which returns any single Terrain in the dataset. An entity over an unbuilt tile is put back in the queue and retried.
Grid transform
Grid coordinates map to world through terrain.gridTransform, rebuilt from size, gridScale and gridTransformKind:
GridTransformKind | Scale | Offset |
|---|---|---|
Direct (default) | gridScale | gridScale / 2 - cell centres land on cell indices |
Legacy | size / (size - 1) * gridScale | 0 |
The grid’s origin is a corner, not the centre, and the entity’s Transform64 shifts and scales the whole thing. Offsetting by (-worldWidth / 2, 0, -worldHeight / 2) is the usual way to centre a terrain on the origin; a collision HeightMapShape3D built for the same field is a separate entity and has to be placed to match. See Colliders.
Serialization
Terrain has toJSON() and fromJSON(json, engine) - the second argument is the engine, because deserializing rebuilds the terrain through engine.assetManager. The JSON form carries size, scale, resolution, preview, heights (base-64 float32), layers, splat weights and the overlay’s tile image URL, plus a material blob that is stored and handed back untouched; nothing reads it.
TerrainSerializationAdapter (binary, version 2) is what the engine’s own save path uses. It writes the same fields plus a small metadata blob holding gridTransformKind and lightMapURL.
Neither form stores painted overlay cells or cloud state: the overlay is a runtime surface, and Clouds is settings that nothing currently draws.