Terrain
Meep's tile-based heightfield terrain — worker-thread LOD mesh generation, unlimited splat-map layers, and a cling-to-terrain component that pins entities to the surface.
Terrain is an ECS component that renders a large heightfield world. The mesh is
built in a background worker from a Float32 height sampler, split into 32 × 32
cell tiles for frustum culling, and painted with an arbitrary number of splat-map
material layers. TerrainSystem manages the lifecycle — register it once and any
entity carrying Terrain (plus a Transform) is automatically picked up.
The raycast-vehicle demo uses this system directly: a 64 × 64 cell terrain with simplex-noise heights, one texture layer, and a baked ambient-occlusion lightmap.
Setup
import Terrain from "@woosh/meep-engine/src/engine/ecs/terrain/ecs/Terrain.js";
import TerrainSystem from "@woosh/meep-engine/src/engine/ecs/terrain/ecs/TerrainSystem.js";
import { TerrainLayer } from "@woosh/meep-engine/src/engine/ecs/terrain/ecs/layers/TerrainLayer.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { ImageRGBADataLoader } from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageRGBADataLoader.js";
import { TextureAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/texture/TextureAssetLoader.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
// Register the system once, before any Terrain entities are built.
config.addLoader(GameAssetType.Image, new ImageRGBADataLoader());
config.addLoader(GameAssetType.Texture, new TextureAssetLoader());
config.addSystem(new TerrainSystem(engine.graphics, engine.assetManager));
Then create a Terrain component, fill its height sampler, add layers, and attach
it to an entity:
const CELLS = 64; // grid cells per side
const GRID_SCALE = 10; // world units per cell → 640 m world
const SAMPLES = CELLS * 2; // sample resolution (must match collision grid)
const terrain = new Terrain();
terrain.size.set(CELLS, CELLS); // grid dimensions
terrain.gridScale = GRID_SCALE; // world units per cell
terrain.resolution = 2; // mesh subdivisions per cell
// Fill the height sampler (Float32, one channel).
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));
// Optional: bake an ambient-occlusion / hillshade lightmap.
terrain.buildLightMap(); // returns a Promise<DataTexture>
const t = new Transform();
t.position.set(-CELLS * GRID_SCALE / 2, 0, -CELLS * GRID_SCALE / 2);
new Entity().add(t).add(terrain).build(ecd);
Key properties
| Property | Type | Default | Description |
|---|---|---|---|
size | Vector2 | (0,0) | Grid dimensions in cells (width × height). |
gridScale | number | 1 | World units per cell. World footprint = size × gridScale. |
resolution | number | 4 | Geometric subdivisions per cell. Higher = smoother mesh. |
samplerHeight | Sampler2D | flat | Float32, one channel, SAMPLES × SAMPLES height values. |
frustumCulled | boolean | true | Disable to always render all tiles (debug). |
shadowBias | number | — | Object-space depth bias applied during shadow-map rendering to avoid self-shadowing at creases. |
lightMapURL | string|null | null | URL of a pre-baked AO texture to load on build(). |
clouds | Clouds | — | Procedural cloud-shadow layer (see Water & overlays). |
overlay | TerrainOverlay | — | Per-tile RGBA overlay drawn on top of terrain (see Water & overlays). |
Tiles and worker-thread mesh generation
The terrain is split into 32 × 32 cell tiles. Tile meshes are built in a
background worker thread (bundle-worker-terrain.js) so the main thread is never
blocked. The worker receives the full height sampler once via setHeightSampler,
then generates each tile’s vertex/index buffer and BVH on demand. Tiles are
queued as they enter the camera frustum and built as fast as the worker can run.
The worker also builds a per-tile BVH used for CPU raycasts (raycastFirstSync,
raycastVerticalFirstSync) — the same paths ClingToTerrainSystem and physics
queries use.
Height queries
Terrain exposes two synchronous raycasts that work against the tile BVHs:
import { SurfacePoint3 } from "@woosh/meep-engine/src/core/geom/3d/SurfacePoint3.js";
const hit = new SurfacePoint3();
// Vertical ray down from (x, y, z) in world space.
const found = terrain.raycastVerticalFirstSync(hit, x, z);
if (found) console.log(hit.position.y, hit.normal);
// Arbitrary ray.
const found2 = terrain.raycastFirstSync(hit, ox, oy, oz, dx, dy, dz);
Both return false if the tile at that position has not yet been built by the
worker. For guaranteed results on unbuilt tiles, call terrain.promiseAllTiles()
(async, waits for every tile to finish building) before sampling.
Splat-map materials
The splat system is a DataTexture2DArray of per-layer weight maps, each storing
one Uint8 value (0–255) per cell. There is no hard layer limit — the GLSL loop
bound is a #define that is recompiled whenever terrain.layers.count() changes:
// Add any number of layers.
const idx0 = terrain.layers.addLayer(TerrainLayer.from("grass.png", 8, 8));
const idx1 = terrain.layers.addLayer(TerrainLayer.from("rock.png", 12, 12));
const idx2 = terrain.layers.addLayer(TerrainLayer.from("sand.png", 6, 6));
// Resize the splat map to match.
terrain.splat.resize(CELLS, CELLS, 3);
// Fill weights programmatically.
terrain.splat.fillLayerWeights(0, 255); // grass everywhere
terrain.splat.fillLayerWeights(1, 0); // rock: none
terrain.splat.fillLayerWeights(2, 0); // sand: none
// Or write from a Sampler2D (one channel per layer).
terrain.splat.writeLayerFromSampler(mySampler, layerIndex, channel);
TerrainLayer properties:
| Property | Description |
|---|---|
textureDiffuseURL | Asset URL of the diffuse texture. Loaded through the asset manager on build(). |
size | Vector2 — world-space tile size in units. The material scales the texture to repeat every size units across the terrain. |
Lightmap / AO
terrain.buildLightMap(quality?) computes a hillshade / ambient-occlusion texture
from the height sampler on the main thread and applies it as an aoMap on the
terrain material. quality is texels per grid cell (default 4, capped at 2048).
The return value is a Promise<DataTexture> that resolves when the bake is
complete.
Alternatively, set terrain.lightMapURL before calling build() to load a
pre-baked AO texture from disk.
Cling to terrain
ClingToTerrain + ClingToTerrainSystem keeps an entity’s Y position pinned to
the terrain surface as its XZ position changes. It is the primitive that makes
trees, rocks, and AI 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";
// Register the system alongside TerrainSystem.
em.addSystem(new ClingToTerrainSystem());
// Add the component to any entity that has a Transform.
const cling = new ClingToTerrain();
cling.normalAlign = true; // rotate entity to match terrain slope
cling.rotationSpeed = 3; // rad/s — maximum angular correction rate per second
entity.add(cling);
| Property | Type | Default | Description |
|---|---|---|---|
normalAlign | boolean | false | Rotate the entity to align its up-axis with the surface normal. |
rotationSpeed | number | Infinity | Maximum angular correction in rad/s. Infinity snaps instantly. |
The system listens for Transform.position and Transform.rotation changes and
queues an update. It processes up to updateBatchLimit (default 1024) entities
per tick, so a large number of static props updates are spread over several frames
rather than all at once.
ClingToTerrainSystem calls terrain.raycastVerticalFirstSync internally. An
entity over an unbuilt tile will not update until the worker has finished building
that tile — it is placed back in the queue and retried on the next tick.
Grid transform
The terrain grid’s origin is a corner, not the centre. The Transform on the
entity shifts and scales the whole mesh. In the raycast-vehicle demo the entity is
offset by (-worldWidth/2, 0, -worldHeight/2) so the terrain is centred on the
origin. The collision HeightMapShape3D is a separate entity centred at the origin
so both are in the same world space.
Serialization
Terrain has toJSON() / fromJSON(). The serialized form stores size,
scale, resolution, heights (base-64 encoded float32), layers, splat weights,
and the overlay tile image URL.