World

Terrain

Tile-based heightfield terrain under Shade - one meshlet mesh per tile streamed into the scene, splat texturing as a full-screen pass after the G-buffer, 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 turns that data into something Shade draws: each tile becomes one meshlet-encoded mesh in the scene, built in a worker and streamed in as the camera sees it, and a full-screen pass after the G-buffer paints the splat mix over every pixel the frame attributes to a tile. Register the system once and any entity carrying Terrain is picked up.

The terrain example builds a 540 x 540 heightfield with four splat-mapped layers loaded from PNGs; terrain from image derives the same thing from a single source image.

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. The module also exports TerrainExtension, the RenderExtension the system registers for itself; you never construct it.

Setup

import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { TerrainSystem } from "@woosh/meep-engine/src/engine/graphics3/TerrainSystem.js";

const engine = await EngineHarness.bootstrap({
    configuration: (config, engine) => {
        config.addSystem(new TerrainSystem(
            engine.graphics,       // GraphicsEngine
            EngineHarness.shadeScene(engine),
            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 Shade Scene per engine, the same one MeshSystem and LightSystem write into. Give TerrainSystem a scene nothing draws and the terrain is simply absent from the frame, with no error.
  • No image loader registration is needed. startup() calls assetManager.tryRegisterLoader(GameAssetType.Image, new ImageRGBADataLoader()) itself. There is no TextureAssetLoader, and GameAssetType.Texture is an enum entry with no loader behind it - layer diffuse maps and the overlay sprite load as GameAssetType.Image, which decodes to a Sampler2D.
  • You do not call terrain.build() or terrain.link(). The system’s link() builds the component if its Built flag is clear and then calls startBuildService() directly, subscribes to the tile manager’s tileBuilt / tileDestroyed signals, and adopts any tile that was already standing.

EngineHarness.buildBasics({ enableTerrain: true }) registers the system and builds a small terrain for you; the example above is what you write when you want to author the heightfield yourself.

Ordering against decals is declared rather than arranged: DecalExtension.after = [TerrainExtension], so decals composite on top of the textured ground no matter which order the two systems were registered in. 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

PropertyTypeDefaultDescription
sizeVector2(0,0)Grid dimensions in cells. World footprint is size x gridScale.
gridScalenumber1World units per cell.
resolutionnumber4Render quads per grid cell. A tile of E cells carries (E x resolution)^2 vertices.
samplerHeightSampler2D1x1Float32, one channel, world-unit heights. Sampled bicubically in UV, so any resolution works.
splatSplatMappingemptyPer-cell Uint8 weights, one layer deep per material layer.
layersTerrainLayersemptyThe material layers and the texture array they are packed into.
overlayTerrainOverlay-Per-cell RGBA painted over the terrain. See Water & overlays.
cloudsClouds-Cloud-shadow settings. Data only - nothing draws them. See Water & overlays.
gridTransformKindGridTransformKindDirectHow grid coordinates map to world. See Grid transform.
frustumCulledbooleantrueInert. Only the non-drawing system reads it; the drawing system always culls.
lightMapURLstring|nullnullInert. 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

camera frustum queried against the tile BVHworker builds tile vertices, normals, uvs, BVHtile_geometry_to_meshlets on the main threadone Mesh per tile, on the flat terrain material, added to the Scenerasterizer writes visibility buffer and G-bufferAfterGBuffer - splat pass rewrites albedo for tile pixels

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 Mesh in the scene, and is removed from the scene 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 so texel density stays roughly even across slopes, and builds the per-tile BVH the CPU raycasts use. Meshlet encoding happens on the main thread when the tile arrives.

Tile vertices are already in terrain space, so every tile mesh carries the terrain entity’s Transform64 and nothing else. The system polls that transform each frame rather than subscribing to it, and writes it only when it actually differs - updateMatrices() bumps a version whether or not anything moved, and that version is what makes the scene database re-upload the row and rebuild the top-level acceleration structure.

Texturing is a full-screen pass, not a material. Tiles draw with one flat StandardShadeMaterial per terrain, named terrain, at roughness 1 and metalness 0 - what it contributes is everything except colour. At AfterGBuffer, TerrainExtension runs GPUTerrainSplatRenderer, which reads the G-buffer albedo and the visibility buffer, and writes a replacement albedo back into GBufferTextures. Which pixels it claims is not a guess: the frame’s visibility buffer says which mesh each pixel came from, and a per-frame row table says which of those rows are this terrain’s tiles. A terrain with no tile in the scene yet packs an empty table and the pass is skipped, leaving the frame’s albedo alone.

Because the replacement lands in the G-buffer before anything is lit, terrain colour is seen by shading, ambient occlusion and screen-space reflections the same way a normal material’s would be.

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. A pixel no layer claims comes back transparent black. 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 fieldDescription
textureDiffuseURLAsset URL of the diffuse map, loaded as GameAssetType.Image.
sizeVector2 - world units per texture repeat. The pass derives the tiling as world size / layer.size.
diffuseThe decoded Sampler2D. Assigning one directly works; layers with an empty URL skip the rescale cache.
extraFree-form JSON that survives serialization.

Sampler settings the pass declares:

TextureFilteringWrap
Layer diffuse arraytrilinear over a full mip chain, maxAnisotropy: 8repeat - the layers tile
Splat weightslinear magnification, nearest minification, no mipsclamp to edge
Overlay spritelinearclamp to edge

Two limits:

  • The layer count is a uniform, not a #define. Adding or removing a layer recompiles nothing; the mix loop reads layer_count from the pass settings.
  • The ceiling is the device’s texture-array depth. All layers are slices of one texture_2d_array, so the hard bound is maxTextureArrayLayers. 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);
PropertyTypeDefaultDescription
normalAlignbooleanfalseAlign the entity’s up axis with the surface normal.
rotationSpeednumber3Maximum 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:

GridTransformKindScaleOffset
Direct (default)gridScalegridScale / 2 - cell centres land on cell indices
Legacysize / (size - 1) * gridScale0

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.