World

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

PropertyTypeDefaultDescription
sizeVector2(0,0)Grid dimensions in cells (width × height).
gridScalenumber1World units per cell. World footprint = size × gridScale.
resolutionnumber4Geometric subdivisions per cell. Higher = smoother mesh.
samplerHeightSampler2DflatFloat32, one channel, SAMPLES × SAMPLES height values.
frustumCulledbooleantrueDisable to always render all tiles (debug).
shadowBiasnumberObject-space depth bias applied during shadow-map rendering to avoid self-shadowing at creases.
lightMapURLstring|nullnullURL of a pre-baked AO texture to load on build().
cloudsCloudsProcedural cloud-shadow layer (see Water & overlays).
overlayTerrainOverlayPer-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:

PropertyDescription
textureDiffuseURLAsset URL of the diffuse texture. Loaded through the asset manager on build().
sizeVector2 — 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);
PropertyTypeDefaultDescription
normalAlignbooleanfalseRotate the entity to align its up-axis with the surface normal.
rotationSpeednumberInfinityMaximum 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.