World

Water & overlays

Water is a depth-aware surface drawn as its own pass after lighting, reading the terrain height field for its shore line; TerrainOverlay paints per-cell RGBA over the ground; cloud shadows are data with no renderer.

Two things sit on top of the Terrain system and read its data: a water surface (Water + WaterSystem) and a per-cell RGBA overlay (TerrainOverlay). Both are drawn. A third, cloud shadows, is authored settings that nothing renders - see Cloud shadows.

Water

Water is an ECS component holding the surface’s level, colours and wave settings. WaterSystem reads it every frame onto a surface record and draws that in a pass of its own.

import Water from "@woosh/meep-engine/src/engine/graphics/ecs/water/Water.js";
import { WaterSystem } from "@woosh/meep-engine/src/engine/graphics3/WaterSystem.js";

await em.addSystem(new WaterSystem(engine.graphics));   // graphics only

The component is a default export. The system is a named export under src/engine/graphics3/, and its constructor takes (graphics) and nothing else - no scene, no asset manager. The module also exports WaterExtension, which the system registers for itself at startup; you never construct it.

EngineHarness.buildTerrain adds a Water component alongside the terrain unless you pass enableWater: false, and registers the system if it is not already there.

Water is not in the scene

There is no water node, no water mesh and no water material. WaterSystem keeps a list of surfaces and owns a RenderExtension at FramePhase.AfterLighting - opaque geometry lit and composited, transparency not yet run. The pass reads SceneColor and the view depth, and writes a new scene colour, so whatever transparency draws afterwards blends over the water rather than under it. See Render extensions.

Three consequences of that shape:

  • The pass has no depth attachment. The shader samples scene depth to know how much water the view is looking through, and a texture cannot be a sampled source and a render attachment in the same pass. The depth test is done in the shader instead, as a discard where the surface is behind something opaque. Water therefore never writes depth and cannot occlude anything drawn later.
  • There is no geometry to upload. The quad is generated from the vertex index - six vertices, a rectangle at a constant height. Waves displace nothing; they only move where the shore line falls.
  • Nothing needs notifying when you edit the component. Values are re-read from the Water component into the surface record every update().

Water needs a terrain

The surface’s shore line is the terrain height field, so #refresh calls obtainTerrain(dataset) on every update. With no Terrain in the dataset there is no height texture, and the surface stays out of the pass entirely - no water is drawn, and no error is raised. The check runs every frame, so water that links during a load, before the terrain entity exists, joins the pass as soon as one does.

obtainTerrain returns any single Terrain in the dataset, so a level with two terrains is not a supported arrangement here.

Placement is fixed, not fitted

WATER_SIZE is 800 (a named export from engine/graphics/ecs/water/WATER_SIZE.js) and the plane is placed at -WATER_SIZE * 0.25 on both X and Z, spanning [-200, 600] in world units regardless of how large the terrain is. The map is an island in an ocean, not a lake fitted to the map: where the surface hangs past the terrain, the height texture clamps at the edge, so the shore line continues instead of stopping at the boundary.

The entity’s Transform64 is ignored. WaterSystem never reads one - dependencies = [Water] alone - so moving or scaling the entity does nothing to the surface. Level is the only placement control there is. Multiple Water components produce multiple surfaces, all at that same extent, differing only in level and appearance.

Creating a water body

import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";

const water = new Water();

water.level.set(2.0);                       // world Y of the surface

water.color.set(0, 0.3, 0.5);               // deep water
water.shoreColor.set(0.584, 0.792, 0.85);   // shallow edge

water.shoreDepthTransition.min = 0.7;       // depth where shore colour ends
water.shoreDepthTransition.max = 2.0;       // depth where deep colour is reached

water.waveSpeed.set(1.8);
water.waveAmplitude.set(0.3);
water.waveFrequency.set(1);
water.scattering.set(1.2);

new Entity().add(new Transform64()).add(water).build(ecd);
PropertyTypeDefaultDescription
levelVector10World Y of the surface.
colorColor(0, 0.3, 0.5)Deep-water colour.
shoreColorColor(0.584, 0.792, 0.85)Shallow / shore colour.
shoreDepthTransitionNumericInterval[0.7, 2]Water depth over which shore colour gives way to deep colour.
waveSpeedVector11.8Wave animation rate.
waveAmplitudeVector10.3How far the wave moves the apparent water depth. Visual only - no geometry moves.
waveFrequencyVector11Spatial frequency of the wave pattern.
scatteringVector11.2How fast opacity rises with the depth of scene behind the water.
bvhBvhClient-Bounds for spatial queries; updateBounds() refreshes it from level.

The component’s own toJSON() / fromJSON() carry only level and color - the wave, scattering and shore fields do not survive that round trip. WaterSerializationAdapter (binary, version 1) is the path that carries every authored value - level, both colours, the shore interval, all three wave settings and scattering - and it is what the engine’s save system uses.

What the pass computes

For each pixel of the surface quad:

  1. Scene depth behind the water. Both the scene depth and the surface’s own depth are converted to view space through the camera’s device-to-view parameters, which is what makes this correct under Shade’s reverse-Z infinite-far projection. If nothing is behind the surface the fragment is discarded.
  2. Terrain height under the water, sampled from a single-channel float16 texture built from terrain.samplerHeight, softened with a 3 x 3 box of taps. The softness is the look, not an approximation of one - a hard heightmap read makes the shore line crawl along texel edges.
  3. Water depth is level - terrainHeight, offset each frame by sin((worldX + worldZ) x waveFrequency + time x waveSpeed) / pi scaled by waveAmplitude.
  4. Colour is smoothstep(shoreDepthTransition.min, .max, depth) between shoreColor at alpha 0.8 and color at alpha 1.
  5. Opacity is that alpha times 1 - exp(-behind x scattering), so the water fades out where the scene behind it is close, and goes opaque where it is deep. The result is composited with straight source-alpha blending.

The height field is a half-float texture rather than a 32-bit one, and that is forced: a float32 texture is unfilterable-float on any device that does not offer float32-filterable, which Shade’s device floor does not require, and the pass reads the height through a linear sampler.

The height field is re-uploaded, and here is when

Shade uploads a texture once and has no notion of one changing, but a Terrain carries a 1 x 1 placeholder height field from the moment it is constructed and the level’s real one arrives later. So the surface tracks its source and rebuilds the texture when either the samplerHeight.data array is replaced or its version moves. Both, because Sampler2D.fromJSON swaps data without touching version, so deserialization is invisible to the counter, while in-place edits bump the counter and keep the array.

In practice: if you write heights in place, bump samplerHeight.version (which is what terrain.updateHeights() expects anyway) and the water picks the change up on the next update.

WaterSystem.surfaces is a read-only getter over what the pass draws. Surfaces follow their entities; removing the component is how one goes away.

Terrain overlays

TerrainOverlay is a per-cell RGBA image the size of the terrain grid, painted over the ground by the same AfterGBuffer pass that mixes the splat layers. It is reached through terrain.overlay - there is no separate ECS component.

It is two textures. The cell map is one RGBA texel per grid cell, read with textureLoad and clamped, so cells stay crisp. The cell sprite is a single image repeated inside every cell that has colour. A cell’s colour tints the sprite; a cell the game left transparent draws nothing at all, and so does a sprite texel with alpha at or below 0.01. The tinted result is composited over the splat colour as a straight over, in linear light.

That makes it a natural fit for turn-based movement ranges, threat maps and tile-selection UI: the game paints cells, and the sprite decides what a painted cell looks like.

import Vector4 from "@woosh/meep-engine/src/core/geom/Vector4.js";

const overlay = terrain.overlay;

overlay.paintPoint(12, 8, new Vector4(1, 0, 0, 0.6));       // grid coordinates
overlay.paintPointAlphaBlend(12, 8, new Vector4(0, 1, 0, 0.4));
overlay.clearPoint(12, 8);

overlay.push();     // snapshot and clear - e.g. while showing a build preview
// ... draw the preview ...
overlay.pop();      // restore

// The per-cell sprite. Loaded through the asset manager as GameAssetType.Image.
overlay.baseTileImage = "./textures/grid-tile.png";
MethodDescription
paintPoint(x, y, vec4)Write RGBA (float 0-1) into grid cell (x, y).
paintPointAlphaBlend(x, y, vec4)Alpha-blend over the cell’s existing value.
clearPoint(x, y)Zero the cell.
readPoint(x, y, result)Read the cell’s RGBA into a Vector4.
writeData(uint8Array)Replace the whole overlay with raw RGBA bytes. Length must match exactly.
clear()Zero everything.
push()Snapshot the cells, sprite URL and border width, then clear the overlay.
pop()Restore the top snapshot, resizing the overlay back if it changed.
update()Bump the sampler version after writing through overlay.sampler yourself.

The per-cell writes, writeData and clear all bump the sampler version themselves, which is what makes the splat pass re-upload the texture. update() is only for a caller that reached into overlay.sampler.data directly. overlay.canvas throws - use overlay.sampler.

paintSampler(src, dx, dy, dw, dh) and paintImage(canvas, ...) are on the class but write nothing: both hand the blitter the TerrainOverlay rather than overlay.sampler, and the overlay has no width, read or write, so the copy loop’s bounds come out NaN and it never runs. To blit a region, scale into overlay.sampler yourself and call update().

baseTileImage defaults to a white-pixel data URL, which the system holds as pixels rather than decoding, so an overlay with no authored sprite is just the cell colours. Changing it reloads through the asset manager, and a change that lands while a previous load is still in flight is discarded.

overlay.borderWidth is inert. The WGSL grid overlay has no border uniform: the border is whatever the cell sprite’s own alpha draws. The field round-trips, but writing to it changes nothing.

Cloud shadows

Every Terrain constructs a Clouds (terrain.clouds), enabled by default, and nothing draws it: nothing in Shade reads its values. What it holds is settings: enabled, variability, setSpeed(x, y) and an accumulated time.

The section exists because the class ships, every terrain owns one, and code that reads or writes terrain.clouds compiles - the honest answer to “why is nothing happening” is more useful than silence. Treat it as authored data with no consumer.

One further detail: clouds.time is advanced by Terrain.update(timeDelta), which the drawing TerrainSystem does not call, so the clock does not run.