Water & overlays

Author meshlet water surfaces with physical optical media and GPU waves; TerrainOverlay paints per-cell RGBA over the ground.

Water surfaces and terrain overlays are separate rendering features. Water uses authored geometry and a physical optical medium; TerrainOverlay paints the Terrain grid. Cloud shadows are authored settings that nothing renders - see Cloud shadows.

Water

Water holds the surface’s index of refraction, roughness, participating medium and hidden-simulation policy. The same entity needs MeshletGeometry for its shape and Transform64 for its placement. WaterSystem registers those bodies with Shade’s shared AVBOIT transparency pipeline; it queues authored edits through change signals rather than scanning every body each frame.

Four examples each show one part of this: Shallow to deep fills one body with each medium preset over a sloping bed, Many waters runs three bodies at once, Refraction and reflection puts the optical controls on sliders, and Splashes drives splash.

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

const waterSystem = new WaterSystem(engine.graphics, EngineHarness.shadeScene(engine));
await em.addSystem(waterSystem);

The component is a default export. The system is a named export under src/engine/graphics3/, and its constructor takes (graphics, scene). Here engine and em are your running engine and entity manager, and EngineHarness is imported from src/engine/EngineHarness.js. The system registers WaterSurfaceExtension at FrameStart to prepare surface data and WaterExtension at BeforePresent for diagnostics.

EngineHarness.buildTerrain creates a separate water entity unless you pass enableWater: false, and registers the system if necessary. Its plane is twice the terrain size in each horizontal dimension, translated to (size.x, waterLevel, size.y).

Rendering through AVBOIT

WaterSystem registers a GPUWaterRenderer transparency channel. Water contributes extinction and radiance alongside transparent meshes, particles and trails; the shared AVBOIT resolve composites it once. Keep renderer.transparency_mode set to ShadeTransparencyMode.AVBOIT: the MBOIT path does not draw this channel.

The exported WaterExtension runs at FramePhase.BeforePresent for optional inspection views. It does not draw the normal water surface. See Render extensions.

  • Scene depth and water-interface depth are separate. Water samples opaque scene depth and writes its own interface depth for receiver selection; it does not replace the scene’s opaque depth buffer.
  • The surface uses meshlet geometry. Shade tessellates it on the GPU, with optional wave displacement. Water stays out of the opaque mesh table and its TLAS.
  • Edits have explicit notification rules. ior.set() and roughness.set() notify automatically. After changing a medium’s absorption, priority or lobe fields in place, set water.medium.needsUpdate = true. Announce pose edits with t64_announce_change(ecd, entity) as for other Transform64 entities.

Shape and placement

Water does not require a Terrain. Its system dependencies are [Water, MeshletGeometry, Transform64]; the mesh defines the extent and the transform defines the pose, including parent attachments. A body missing either geometry or transform does not link to the system. Separate entities can have different shapes, placements and optical media.

Creating a water body

import Water from "@woosh/meep-engine/src/engine/graphics/ecs/water/Water.js";
import WaterWaves from "@woosh/meep-engine/src/engine/graphics/ecs/water/WaterWaves.js";
import { FluidEffectorsComponent } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidEffectorsComponent.js";
import { GlobalFluidEffector } from "@woosh/meep-engine/src/engine/physics/fluid/effector/GlobalFluidEffector.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
import { make_plane_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_plane_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { MeshoptClusterizer } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet/build/meshoptimizer/meshopt_clusterizer.js";

const water = new Water();
water.ior.set(1.333);
water.roughness.set(0.02);

await MeshoptClusterizer.ready;
const plane = make_plane_geometry(40, 20);
const positions = plane.getAttribute(StandardAttributes.Position).data;
const normals = plane.getAttribute(StandardAttributes.Normal).data;
// Plane geometry starts in XY; author the rest surface in local XZ, facing up.
for (let i = 0; i < positions.length; i += 3) {
    positions[i + 2] = -positions[i + 1];
    positions[i + 1] = 0;
    normals[i] = 0; normals[i + 1] = 1; normals[i + 2] = 0;
}
const geometry = meshlet_geometry_build_from_geometry(plane);
const transform = new Transform64();
transform.setTranslation(20, 2, 10);
const waves = new WaterWaves();
waves.shoreReflection.set(0.3);

const entity = new Entity().add(transform).add(geometry).add(water).add(waves).build(ecd);

// The wind that raises the waves is the scene's: a GlobalFluidEffector, 3 m/s towards +X+Z.
// Needs FluidEffectorsSystem and GPUFluidSystem registered (EngineHarness's water setup adds the latter).
const weather = new GlobalFluidEffector();
weather.wind = [3 * Math.SQRT1_2, 0, 3 * Math.SQRT1_2];
weather.drag = 1;
const effectors = new FluidEffectorsComponent();
effectors.addEffector(weather);
new Entity().add(effectors).build(ecd);
PropertyTypeDefaultDescription
iorVector11.333Air-to-water index of refraction; the system clamps it to at least 1.
roughnessVector10.02Perceptual surface roughness before unresolved wave slope variance is added; clamped to [0, 1].
mediumParticipatingMediumopen_oceanRGB absorption and scattering lobes, with coefficients in inverse metres.
simulateWhenHiddenObservedBooleanfalseKeep wave particles advancing while no view sees the body. Ambient waves follow time either way.

Water.toJSON() / fromJSON() carry ior, roughness, the optical medium and simulateWhenHidden. WaterSerializationAdapter uses binary version 5; the 4 → 5 upgrader preserves the optics and sets simulateWhenHidden to false. Save geometry and placement as the other components on the entity. WaterWaves has its own JSON and binary adapter (version 2), storing upwind fetch and shore reflection. Its 1 → 2 upgrader drops the wind speed and direction that version 1 stored; the wind is saved with the scene’s GlobalFluidEffectors.

Legacy placement needs a scene migration. JSON containing level or size throws, and the binary 3 → 4 upgrader deliberately throws because a component upgrader cannot create the required geometry and transform. Convert the old water entity to Water + MeshletGeometry + Transform64 before loading it with this format. Old body and shore colours are not physical medium coefficients.

Refraction, reflection and absorption

For each visible water interface:

  1. Surface coverage comes from the transformed mesh and GPU tessellation, with optional simulated wave displacement.
  2. Refraction traces from the optical normal into the opaque scene using depth and the hierarchical depth buffer. The accepted receiver supplies a displaced colour lookup and an underwater path length. Refraction fades toward the original lookup near screen edges and uncertain coverage.
  3. Reflection traces screen-space geometry and falls back to the environment map. ior controls Fresnel reflection; roughness and unresolved wave slopes control its roughness.
  4. The medium supplies absorption, scattering and phase functions for transmission and in-scattered radiance. Choose a physical preset or author coefficients rather than tinting a deep-water or shore colour.
  5. Composition publishes that transmission and radiance into the shared AVBOIT accumulators, so other transparent content participates in the same resolve.

waterSystem.settings.enabled can disable the channel, and settings.reflections is an integer switch (1 by default). settings.max_distance defaults to 300 world units and limits tracing distance. These are renderer settings, separate from the serializable Water component.

Optical presets and waves

water_medium_preset({ preset }), exported by src/shade/renderer/medium/water/water_presets.js, builds a ParticipatingMedium for swimming_pool, clear_ocean, open_ocean, coastal, tropical_lagoon, green_lake or turbid_river. Assign the result to water.medium; replacement notifies the system. Preset fitting is an authoring operation, so do it once rather than every frame. A lagoon’s shallow sand bottom belongs to the scene, not its optical preset.

WaterWaves is optional. Without it, the body has no wind-driven waves, but can still receive splashes. With it, the wind drives a fetch-limited JONSWAP spectrum: the same wind raises different waves on a small pool and an open sea. Its fields are Vector1s; edit them with .set() to notify the system.

The wind is the scene’s, not the body’s. Every body reads the published wind field 10 m above its own origin, low-passed on the GPU, so direction changes blend over a few seconds. That field is the far field of the scene’s GlobalFluidEffectors, folded by FluidEffectorsSystem and published by GPUFluidSystem (see the scene’s wind); a scene without them has still air and no wind waves. Two bodies in one scene feel the same wind and differ by their fetch.

PropertyDefaultMeaning
upwindFetch0Metres of open water upwind of the mesh, added to its own extent along the wind. Leave at zero for a bounded pool or lake.
shoreReflection0.5Amplitude reflection coefficient at the mesh outline: 0 absorbs wave particles, 1 reflects them fully. Bodies without WaterWaves use 0.5.

waterSystem.splash(entity, [x, y, z], radius, strength) queues wave particles with a world-space radius (half their wavelength) and a vertical velocity kick in metres per second. The position is interpreted using the entity’s CPU-owned pose; use it while that pose is CPU authoritative. The GPU consumes at most sixteen queued splashes per body per frame.

Water that is one meshlet in all blanks the frame. In 3.57.0 the water renderer sizes its meshlet list for at least one meshlet, and the pass that fills it needs room for two, so a scene whose only water is a mesh of 128 triangles or fewer fails WebGPU validation every frame and nothing draws. Build a coarse outline from a few more segments (a 16 × 16 grid is enough).

By default, hidden bodies pause their wave particles. Set water.simulateWhenHidden.set(true) when those must continue off camera; this costs the body’s simulation work even while hidden.

Terrain overlays

TerrainOverlay is a per-cell RGBA image the size of the terrain grid, blended over the splat layers by TerrainShadeMaterial during G-buffer shading. 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 terrain material 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.