// Wake field — a Meep example for the fluid simulation + wake effector.
//
// A ball streaks left-to-right through a grid of trees, towing a
// WakeFluidEffector that splats its momentum into a FluidComponent volume
// covering the field. Each tree samples the fluid velocity at canopy height
// every frame and bends with it — so the ball's wake rolls through the
// plantation as a visible ripple, spreading and fading as the pressure
// solver pushes the disturbance outward.
//
// The three fluid pieces and who does what:
//
// - FluidComponent the volume: a velocity grid + where it sits in the
// world. No Transform on its entity → static placement.
// - WakeFluidEffector the stirrer: rides the ball entity inside a
// FluidEffectorsComponent. FluidSystem syncs it from
// the ball's Transform each fixed tick, and it splats
// the swept segment's momentum into the field.
// - FluidSystem the pump: steps every field on the fixed timestep —
// effector splats, advection, pressure projection.
//
// Trees don't participate in the simulation at all — they just READ the
// field: sampleVelocityAtWorld() at the canopy, smooth it, tilt the cone.
//
// Sections:
// §1 Tuning constants
// §2 Engine bootstrap
// §3 Ground
// §4 The fluid volume
// §5 The trees cone on a stick × a grid
// §6 The ball + its wake
// §7 Per-frame fly the ball, bend the trees, HUD
import * as THREE from "three";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { TextureAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/texture/TextureAssetLoader.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import { AmbientOcclusionPostProcessEffect } from "@woosh/meep-engine/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js";
import Trail2D from "@woosh/meep-engine/src/engine/graphics/ecs/trail2d/Trail2D.js";
import Trail2DSystem from "@woosh/meep-engine/src/engine/graphics/ecs/trail2d/Trail2DSystem.js";
import { FluidComponent } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidComponent.js";
import { FluidEffectorsComponent } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidEffectorsComponent.js";
import { FluidSystem } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidSystem.js";
import { AdvectionScheme } from "@woosh/meep-engine/src/engine/physics/fluid/FluidSimulator.js";
import { WakeFluidEffector } from "@woosh/meep-engine/src/engine/physics/fluid/effector/WakeFluidEffector.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
// The plantation: TREES_X × TREES_Z cones on sticks.
const TREES_X = 14;
const TREES_Z = 10;
const TREE_SPACING = 1.7;
const TRUNK_HEIGHT = 1.1;
const CANOPY_HEIGHT = 1.4;
const CANOPY_RADIUS = 0.55;
// Trees sample the fluid at mid-canopy.
const SAMPLE_Y = TRUNK_HEIGHT + CANOPY_HEIGHT * 0.5;
// The ball skims just above the canopy tops — no clipping through cones.
const BALL_Y = TRUNK_HEIGHT + CANOPY_HEIGHT + 0.7;
// The fluid volume: a coarse grid is plenty — the ripple reads through the
// trees, not through fine vortices. ~10k cells steps comfortably each tick.
const CELL_SIZE = 0.6;
const FIELD_SIZE_X = 30; // metres, ball flies along X
const FIELD_SIZE_Y = 6;
const FIELD_SIZE_Z = 20;
// The ball.
const BALL_RADIUS = 0.5;
const BALL_SPEED = 34.6; // m/s along its heading — fast enough to leave a moving ripple
const BALL_X_LIMIT = 16; // wrap when past this — just outside the field
const MAX_HEADING = Math.PI / 6; // ±30° — cap on the random per-pass tilt off the X axis
// The ball flies ~1.4 m above the sampling plane, so the wake needs a wider
// tube and more strength for its (1 - d/R)² falloff to still reach the
// canopies with authority.
const WAKE_RADIUS = 2.2; // world-space influence of the swept tube
const WAKE_STRENGTH = 2;
const PASS_PAUSE = 2.2; // seconds of off-screen run-up between passes
// (the ball stays in flight) — lets each
// ripple play out and the field settle, so
// passes don't smear together
// Tree response: target bend = BEND_GAIN × sampled velocity (grid cells/s),
// capped at MAX_BEND — but trees don't snap to the target. Each canopy is an
// underdamped spring oscillator (ω ≈ 1.1 Hz, ζ = 0.25): a passing gust RINGS
// it rather than just leaning it, and that sway travelling tree-to-tree
// behind the wake is what reads as the ripple.
const BEND_GAIN = 0.06;
const MAX_BEND = 0.55; // rad — about 34°
const SWAY_OMEGA = 7; // rad/s — canopy natural frequency
const SWAY_ZETA = 0.35; // damping ratio; < 1 ⇒ wobbles past zero
const TRUNK_COLOR = 0x6b4f35;
const CANOPY_COLOR = 0x3f9460;
const BALL_COLOR = 0x4ef0a8;
// ─── §2 Engine bootstrap ────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine));
config.addSystem(new Trail2DSystem(engine));
config.addSystem(new FluidSystem());
// Trail2D loads its ribbon texture through the asset manager — register
// the texture loader, it's not part of the harness's minimal set.
config.addLoader(GameAssetType.Texture, new TextureAssetLoader());
// Screen-space ambient occlusion — grounds the cones and the ball
// visually, cheap contact shadows between canopies.
config.addPlugin(AmbientOcclusionPostProcessEffect);
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
focus: new Vector3(0, 1.2, 0),
distance: 28,
pitch: 0.9,
yaw: 0, // looking straight down the rows — the ball crosses the view
cameraFarDistance: 300,
showFps: false,
});
const ecd = engine.entityManager.dataset;
ecd.registerComponentType(Trail2D);
// ─── §3 Ground ──────────────────────────────────────────────────────────────
{
const t = new Transform();
t.position.set(0, -0.25, 0);
new Entity()
.add(t)
.add(ShadedGeometry.from(
new THREE.BoxGeometry(FIELD_SIZE_X + 10, 0.5, FIELD_SIZE_Z + 8),
new THREE.MeshStandardMaterial({ color: 0x161c24, roughness: 1 }),
))
.build(ecd);
}
// ─── §4 The fluid volume ────────────────────────────────────────────────────
//
// One FluidComponent = one velocity grid + its world placement. The entity
// has NO Transform, so the placement is static: we set `origin` (the world
// position of cell (0,0,0)'s centre) once and the system never re-anchors it.
// The grid spans the whole plantation with head-room above the canopies.
const fluid = new FluidComponent();
const RES_X = Math.round(FIELD_SIZE_X / CELL_SIZE) + 1;
const RES_Y = Math.round(FIELD_SIZE_Y / CELL_SIZE) + 1;
const RES_Z = Math.round(FIELD_SIZE_Z / CELL_SIZE) + 1;
fluid.field.setResolution(RES_X, RES_Y, RES_Z);
fluid.field.build();
fluid.cell_size = CELL_SIZE;
fluid.origin = [-FIELD_SIZE_X / 2, 0, -FIELD_SIZE_Z / 2];
new Entity().add(fluid).build(ecd);
const fluidSystem = engine.entityManager.getSystem(FluidSystem);
const sim = fluidSystem.simulator;
// The solver is incompressible — pressure adjusts instantly, so there is no
// propagating compression wave. What DOES travel outward is the return-flow
// vortex pair the wake sheds; damping is tuned so those vortices live just
// long enough to cross the plantation (~3 s) and then die.
//
// NOTE on the MAC-grid solver (2.158+): the staggered grid + MacCormack
// transport preserve vortices far better than the old collocated solver, so
// no vorticity confinement is needed — adding it now keeps the field churning
// indefinitely and the forest never settles.
sim.velocity_damping = 1.5;
sim.vorticity_confinement = 0;
// Default MacCormack transport is nearly energy-conserving — gusts ricochet
// around the volume long after the ball is gone. First-order semi-Lagrangian
// smears small structures away, which here is a feature: the ripple passes,
// the forest settles.
sim.advection_scheme = AdvectionScheme.SEMI_LAGRANGIAN;
// ─── §5 The trees ───────────────────────────────────────────────────────────
//
// Cone on a stick. The trunk is static; the canopy cone is its own entity
// whose geometry is shifted so the cone's BASE sits at the entity origin —
// tilting the transform then pivots the canopy around the top of the trunk,
// like a tree flexing at the crown.
const trunkGeometry = new THREE.CylinderGeometry(0.06, 0.09, TRUNK_HEIGHT, 6);
trunkGeometry.translate(0, TRUNK_HEIGHT / 2, 0);
const canopyGeometry = new THREE.ConeGeometry(CANOPY_RADIUS, CANOPY_HEIGHT, 8);
canopyGeometry.translate(0, CANOPY_HEIGHT / 2, 0);
const trunkMaterial = new THREE.MeshStandardMaterial({ color: TRUNK_COLOR, roughness: 0.9 });
const canopyMaterial = new THREE.MeshStandardMaterial({ color: CANOPY_COLOR, roughness: 0.8 });
// Per-tree bookkeeping for §7: world position, the canopy transform we tilt,
// and the smoothed velocity sample.
const trees = [];
for (let ix = 0; ix < TREES_X; ix++) {
for (let iz = 0; iz < TREES_Z; iz++) {
const x = (ix - (TREES_X - 1) / 2) * TREE_SPACING;
const z = (iz - (TREES_Z - 1) / 2) * TREE_SPACING;
const trunkTransform = new Transform();
trunkTransform.position.set(x, 0, z);
new Entity()
.add(trunkTransform)
.add(ShadedGeometry.from(trunkGeometry, trunkMaterial))
.build(ecd);
const canopyTransform = new Transform();
canopyTransform.position.set(x, TRUNK_HEIGHT, z);
new Entity()
.add(canopyTransform)
.add(ShadedGeometry.from(canopyGeometry, canopyMaterial))
.build(ecd);
// bx/bz: current bend vector (rad) · ux/uz: bend velocity (rad/s)
trees.push({ x, z, rotation: canopyTransform.rotation, bx: 0, bz: 0, ux: 0, uz: 0 });
}
}
// ─── §6 The ball + its wake ─────────────────────────────────────────────────
//
// The ball entity carries a Transform (we fly it manually in §7) and a
// FluidEffectorsComponent holding one WakeFluidEffector. Because Transform
// and effectors share an entity, FluidSystem calls syncFromTransform on the
// wake each fixed tick — the effector's swept segment is simply "where the
// ball moved since last tick", and it splats that momentum into the field.
const ballTransform = new Transform();
ballTransform.position.set(-BALL_X_LIMIT, BALL_Y, 0);
const wake = new WakeFluidEffector();
wake.radius = WAKE_RADIUS;
wake.strength = WAKE_STRENGTH;
const effectors = new FluidEffectorsComponent();
effectors.addEffector(wake);
// A ribbon trail makes the ball's path — the wake's source segment — visible.
// Trail2DSystem extrudes a camera-facing textured strip through the entity's
// recent positions; segments older than maxAge fade out.
const trail = new Trail2D();
trail.maxAge = 0.6;
trail.width = 0.84;
trail.textureURL = "./textures/trail/Circle_04.png";
trail.color.set(0.31, 0.94, 0.66, 0.8);
new Entity()
.add(ballTransform)
.add(effectors)
.add(trail)
.add(ShadedGeometry.from(
new THREE.SphereGeometry(BALL_RADIUS, 24, 18),
new THREE.MeshStandardMaterial({
color: BALL_COLOR,
emissive: BALL_COLOR,
emissiveIntensity: 0.45,
roughness: 0.35,
}),
))
.build(ecd);
// ─── §7 Per-frame: fly the ball, bend the trees, HUD ────────────────────────
document.getElementById("trees").textContent = String(trees.length);
document.getElementById("cells").textContent = String(RES_X * RES_Y * RES_Z);
const fpsEl = document.getElementById("fps");
const LANE_MAX = (TREES_Z - 1) / 2 * TREE_SPACING - 1;
let pass = 0;
let ballVx = BALL_SPEED;
let ballVz = 0;
// Launch a pass: enter at `lane` on the left edge with a random heading.
// Instead of rolling a raw angle and hoping, we pick where the pass should
// EXIT — a random z within the tree strip on the right edge — and derive the
// heading from entry → exit. Every trajectory crosses the whole forest by
// construction; the strip's proportions cap the tilt at ~±23°, and MAX_HEADING
// clamps it at ±30° regardless. `runUp` seconds of off-screen flight are
// prepended along the same heading.
function launchBall(lane, runUp) {
const exitZ = (Math.random() * 2 - 1) * LANE_MAX;
let heading = Math.atan2(exitZ - lane, 2 * BALL_X_LIMIT);
heading = Math.max(-MAX_HEADING, Math.min(MAX_HEADING, heading));
ballVx = BALL_SPEED * Math.cos(heading);
ballVz = BALL_SPEED * Math.sin(heading);
ballTransform.position.set(
-BALL_X_LIMIT - ballVx * runUp,
BALL_Y,
lane - ballVz * runUp,
);
}
// First pass: enter dead-centre, but already on a random heading.
launchBall(0, 0);
const sample = new Float32Array(3);
const bendAxis = new Vector3();
let fpsWindow = 0, fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = Math.min((nowMs - lastFrameMs) / 1000, 0.1);
lastFrameMs = nowMs;
// — Fly the ball. It NEVER stops: past the right edge it teleports to a
// run-up position far off-screen left, sized so PASS_PAUSE seconds of
// flight bring it back to the field edge — the previous ripple gets its
// settle time while the ball is approaching, already moving, never seen
// standing still. reset_trail() matters: without it the wake effector
// would treat the teleport as one giant swept segment and splat a wall of
// momentum across the field. Same idea for the ribbon's clear().
const p = ballTransform.position;
const x = p.x + ballVx * dt;
if (x > BALL_X_LIMIT) {
pass++;
// Golden-ratio lane hopping: fills the strip evenly, never repeats.
const lane = (((pass * 0.618) % 1) - 0.5) * 2 * LANE_MAX;
launchBall(lane, PASS_PAUSE);
wake.reset_trail();
trail.clear();
} else {
p.set(x, BALL_Y, p.z + ballVz * dt);
}
// — Bend the trees. Each samples the fluid velocity at mid-canopy (in
// grid-cells/s — the field's native unit, deliberately not rescaled: see
// FluidComponent.sampleVelocityAtWorld), maps it to a target bend vector,
// and integrates a damped spring toward it (semi-implicit Euler). The
// spring's overshoot is the sway; the tilt axis is ⟂ to the bend so the
// canopy leans downwind.
const w2 = SWAY_OMEGA * SWAY_OMEGA;
const friction = 2 * SWAY_ZETA * SWAY_OMEGA;
for (let i = 0; i < trees.length; i++) {
const tree = trees[i];
fluid.sampleVelocityAtWorld(sample, tree.x, SAMPLE_Y, tree.z);
let tx = sample[0] * BEND_GAIN;
let tz = sample[2] * BEND_GAIN;
const tm = Math.sqrt(tx * tx + tz * tz);
if (tm > MAX_BEND) {
tx *= MAX_BEND / tm;
tz *= MAX_BEND / tm;
}
tree.ux += (w2 * (tx - tree.bx) - friction * tree.ux) * dt;
tree.uz += (w2 * (tz - tree.bz) - friction * tree.uz) * dt;
tree.bx += tree.ux * dt;
tree.bz += tree.uz * dt;
const angle = Math.sqrt(tree.bx * tree.bx + tree.bz * tree.bz);
if (angle < 1e-3) {
tree.rotation.set(0, 0, 0, 1);
continue;
}
bendAxis.set(tree.bz / angle, 0, -tree.bx / angle);
tree.rotation.fromAxisAngle(bendAxis, Math.min(angle, MAX_BEND * 1.4));
}
// — HUD
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wake field · Meep</title>
<meta name="robots" content="noindex">
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #07090c;
color: #e6edf3;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.panel {
position: fixed;
z-index: 100;
background: rgba(7, 9, 12, 0.72);
border: 1px solid #1f2731;
border-radius: 10px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 12px 32px rgba(0,0,0,0.4);
}
.hud {
top: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.82rem;
line-height: 1.7;
color: #9aa5b1;
min-width: 180px;
}
.hud .label {
color: #6b7785;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.65rem;
margin-right: 0.5rem;
}
.hud .value { color: #4ef0a8; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
max-width: 430px;
color: #9aa5b1;
}
.legend strong { color: #e6edf3; }
</style>
</head>
<body>
<div class="panel hud">
<div><span class="label">fps</span><span class="value" id="fps">--</span></div>
<div><span class="label">trees</span><span class="value" id="trees">--</span></div>
<div><span class="label">fluid cells</span><span class="value" id="cells">--</span></div>
</div>
<div class="panel legend">
A ball tows a <strong>wake effector</strong> through a fluid volume covering
the plantation. Every tree samples the fluid velocity at canopy height each
frame and bends with it — watch the <strong>ripple</strong> roll outward
behind the ball and fade as the field settles. Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Wake field",
"description": "A ball tows a wake effector through a fluid volume covering a grid of trees; every canopy samples the velocity field and bends, so the wake rolls through the plantation as a visible ripple.",
"category": "Physics",
"status": "live",
"order": 7,
"tags": ["physics", "fluid", "wake", "wind", "vegetation", "ecs"],
"sourceHint": "examples-src/wake-field/",
"demoUrl": "/examples/wake-field/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/wake-field",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A ball tows a wake effector through a fluid volume; a grid of trees samples the velocity field and bends as the ripple rolls through.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "2.158.0",
"three": "0.136.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# Wake field
A ball streaks left-to-right through a 14×10 grid of trees, towing a
`WakeFluidEffector` that splats its momentum into a `FluidComponent` volume
covering the plantation. Every tree samples the fluid velocity at canopy
height each frame and bends with it — the wake rolls through the field as a
visible ripple, spreading and fading as the field settles.
What it demonstrates:
- `FluidComponent` — a static velocity grid placed in the world (no Transform
on its entity; `origin` + `cell_size` set once).
- `WakeFluidEffector` riding an entity Transform inside a
`FluidEffectorsComponent` — `FluidSystem` syncs it each fixed tick and the
swept segment splats momentum into every overlapping field.
- `reset_trail()` after teleporting the source, so the loop wrap doesn't
splat one giant wake across the entire field.
- `sampleVelocityAtWorld()` as a cheap read-side API: vegetation sway driven
straight off the simulation, no coupling back into it.
- `FluidSimulator.velocity_damping` + `vorticity_confinement` tuned as a
pair: damping low enough that the wake's shed vortex pair survives long
enough to travel across the plantation, confinement sharpening the rotating
cores the coarse grid would smear away. (The solver is incompressible — no
compression wave exists; the travelling vortices ARE the visible ripple.)
- Trees as underdamped spring oscillators — a passing gust rings the canopy
rather than just leaning it, which is what makes the wake read as a wave.
- `Trail2D` + `Trail2DSystem` — a ribbon trail streaming from the ball. Needs
`TextureAssetLoader` registered (`config.addLoader(GameAssetType.Texture, ...)`)
— the harness's minimal loader set doesn't include it. `trail.clear()` joins
`wake.reset_trail()` on the loop wrap so neither leaves a streak across the
teleport.
## Run
```sh
npm install
npm run dev
```
## Build
`npm run build` emits the static demo into `public/examples/wake-field/`.
import { defineConfig } from "vite";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
{
// Copy the committed source thumbnail into the generated gallery folder.
// public/examples/<id>/ is build output (gitignored); thumbnail.png is
// kept in source here and copied through on every build so the gallery
// (src/data/examples.ts) can resolve it.
name: "copy-thumbnail",
apply: "build",
closeBundle() {
const thumb = resolve(__dirname, "thumbnail.png");
const dst = resolve(__dirname, "../../public/examples/wake-field");
if (existsSync(thumb)) {
mkdirSync(dst, { recursive: true });
copyFileSync(thumb, resolve(dst, "thumbnail.png"));
}
},
},
],
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/wake-field"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});