// Shift every vertex of an authoring-form `Geometry` — the equivalent of
// three's `BufferGeometry.translate`, which Shade's `Geometry` does not carry.
//
// Used here to move a shape's PIVOT: a cylinder or cone comes out of the
// primitive builders centred on the origin, and a tree wants its base there
// instead so that rotating the entity swings the canopy rather than spinning it
// about its own middle.
//
// Do this BEFORE `meshlet_geometry_build_from_geometry` — the meshlet build
// bakes the vertices (and the bounding box it derives from them) into the GPU
// form, and nothing reads the authoring geometry again afterwards.
import { GeometryFlags } from "@woosh/meep-engine/src/shade/renderer/geometry/Geometry.js";
import { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
/**
* @param {Geometry} geometry modified in place
* @param {number} x
* @param {number} y
* @param {number} z
* @returns {Geometry} the same geometry, for chaining
*/
export function geometry_translate(geometry, x, y, z) {
const position = geometry.getAttribute(StandardAttributes.Position);
const data = position.data;
for (let i = 0; i < data.length; i += 3) {
data[i] += x;
data[i + 1] += y;
data[i + 2] += z;
}
// The vertex arrays moved, so the cached bounding box is stale. Normals and
// tangents are unaffected — a translation doesn't rotate anything.
position.needsUpdate = true;
geometry.setFlag(GeometryFlags.BoundsNeedUpdate);
return geometry;
}
// 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 Transform64 on its entity → static placement.
// - WakeFluidEffector the stirrer: rides the ball entity inside a
// FluidEffectorsComponent. FluidSystem syncs it from
// the ball's Transform64 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 Shade materials
// §3 Engine bootstrap
// §4 Ground
// §5 The fluid volume
// §6 The trees cone on a stick × a grid
// §7 The ball + its wake
// §8 Per-frame fly the ball, bend the trees, HUD
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { Transform64 } from "@woosh/meep-engine/src/engine/ecs/transform/Transform64.js";
import { t64_announce_change } from "@woosh/meep-engine/src/engine/ecs/transform/t64_announce_change.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics3/ShadedGeometrySystem.js";
// Shade's primitive builders + the meshlet clusterer that turns an authoring
// `Geometry` into the GPU form a ShadedGeometry draws. Cluster once per shape,
// never per frame.
import { make_box_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_box_geometry.js";
import { make_cylinder_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_cylinder_geometry.js";
import { make_octahedron_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/primitives/make_octahedron_geometry.js";
import { meshlet_geometry_build_from_geometry } from "@woosh/meep-engine/src/shade/renderer/geometry/meshlet_geometry_build_from_geometry.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { geometry_translate } from "./lib/geometry_translate.js";
// The trail: a volumetric tube laid down behind a moving entity. The component
// lives in the graphics tree, the system that draws it under Shade lives in
// graphics3 and is a NAMED export taking the graphics facade.
import Trail3D from "@woosh/meep-engine/src/engine/graphics/ecs/trail3d/Trail3D.js";
import { Trail3DSystem } from "@woosh/meep-engine/src/engine/graphics3/Trail3DSystem.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 { Color } from "@woosh/meep-engine/src/core/color/Color.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import Quaternion from "@woosh/meep-engine/src/core/geom/Quaternion.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
// The visible trail behind the ball. maxAge sizes the tube: the system lays
// down 60 knots per second of life, so 0.6 s is 36 knots — at this speed a
// streak about 20 m long, two thirds of the field.
const TRAIL_MAX_AGE = 0.6;
const TRAIL_WIDTH = 0.6; // tube DIAMETER, world units
const TRAIL_RADIAL_SEGMENTS = 6; // the ring extrusion runs on the CPU, so this
// is knots × segments vertices written per frame
// 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 GROUND_COLOR = "#161c24";
const TRUNK_COLOR = "#6b4f35";
const CANOPY_COLOR = "#3f9460";
const BALL_COLOR = "#4ef0a8";
// ─── §2 Shade materials ─────────────────────────────────────────────────────
//
// Shade's PBR material. The colours above are written as sRGB hex (what a
// designer types); the renderer works in linear light, so decode once here.
function standardMaterial(hexColor, roughness, metalness = 0) {
const material = new StandardShadeMaterial();
material.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(hexColor)));
material.roughness_factor = roughness;
material.metallic_factor = metalness;
return material;
}
// ─── §3 Engine bootstrap ────────────────────────────────────────────────────
//
// ShadedGeometrySystem draws everything carrying a ShadedGeometry component; it
// takes the graphics facade and the one Shade scene the harness renders, which
// `EngineHarness.shadeScene` hands out. Trail3DSystem draws the ball's tube on
// Shade's dynamic-mesh path and needs only the facade. Ambient occlusion needs
// no wiring — Shade computes GTAO as part of the frame, on by default.
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
config.addSystem(new ShadedGeometrySystem(engine.graphics, EngineHarness.shadeScene(engine)));
config.addSystem(new Trail3DSystem(engine.graphics));
config.addSystem(new FluidSystem());
},
});
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,
});
// Exposure and key light, the same in every demo on this site. Shade's automatic
// exposure is off: these scenes are lit to be looked at, not adapted to, and an
// adapting frame makes two screenshots of the same demo disagree. That leaves
// the light doing the work - and a directional light's intensity is read as lux,
// where the harness's default of 0.9 is a value from the three.js era that only
// ever looked lit because auto-exposure opened up to meet it.
engine.graphics.renderer.feature_automatic_exposure_enabled = false;
engine.entityManager.dataset.traverseEntities([Light], (light) => {
if (light.type.getValue() === LightType.DIRECTION) {
light.intensity.set(6);
}
});
const ecd = engine.entityManager.dataset;
// ─── §4 Ground ──────────────────────────────────────────────────────────────
{
const t = new Transform64();
t.setTranslation(0, -0.25, 0);
new Entity()
.add(t)
.add(ShadedGeometry.from(
meshlet_geometry_build_from_geometry(
make_box_geometry(FIELD_SIZE_X + 10, 0.5, FIELD_SIZE_Z + 8),
),
standardMaterial(GROUND_COLOR, 1),
))
.build(ecd);
}
// ─── §5 The fluid volume ────────────────────────────────────────────────────
//
// One FluidComponent = one velocity grid + its world placement. The entity
// has NO Transform64, 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;
// ─── §6 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. Shade's primitives come out centred on the
// origin, so `geometry_translate` moves the pivot before the shape is
// clustered into its GPU form.
const trunkGeometry = meshlet_geometry_build_from_geometry(
geometry_translate(
make_cylinder_geometry(0.06, 0.09, TRUNK_HEIGHT, 6),
0, TRUNK_HEIGHT / 2, 0,
),
);
// A cone is a cylinder with a zero-radius top.
const canopyGeometry = meshlet_geometry_build_from_geometry(
geometry_translate(
make_cylinder_geometry(0, CANOPY_RADIUS, CANOPY_HEIGHT, 8),
0, CANOPY_HEIGHT / 2, 0,
),
);
const trunkMaterial = standardMaterial(TRUNK_COLOR, 0.9);
const canopyMaterial = standardMaterial(CANOPY_COLOR, 0.8);
// Per-tree bookkeeping for §8: 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 Transform64();
trunkTransform.setTranslation(x, 0, z);
new Entity()
.add(trunkTransform)
.add(ShadedGeometry.from(trunkGeometry, trunkMaterial))
.build(ecd);
const canopyTransform = new Transform64();
canopyTransform.setTranslation(x, TRUNK_HEIGHT, z);
const canopy = new Entity()
.add(canopyTransform)
.add(ShadedGeometry.from(canopyGeometry, canopyMaterial))
.build(ecd);
// The entity id travels with the transform: a Transform64 carries no
// signals, so §8 announces each bend against the entity it belongs to.
// bx/bz: current bend vector (rad) · ux/uz: bend velocity (rad/s)
trees.push({ x, z, transform: canopyTransform, entity: canopy, bx: 0, bz: 0, ux: 0, uz: 0 });
}
}
// ─── §7 The ball + its wake ─────────────────────────────────────────────────
//
// The ball entity carries a Transform64 (we fly it manually in §8) and a
// FluidEffectorsComponent holding one WakeFluidEffector. Because Transform64
// 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 Transform64();
ballTransform.setTranslation(-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 trail makes the ball's path — the wake's source segment — visible.
// Trail3DSystem lays a tube of knots through the entity's recent positions and
// re-extrudes a ring around each one every frame; knots fade to nothing as
// their age reaches maxAge. By default a knot is committed on a TIME cadence
// (maxAge / knot count), which at a constant speed spaces them evenly in space
// too. The tube is flat-shaded colour over the finished scene: the dynamic
// path has one pipeline and one vertex layout, so `textureURL` (this demo used
// to hang a soft ribbon texture on it), `depthWrite` and `lightingEnabled` are
// all inert in meep 3 — a trail is a colour, not a material.
const trail = new Trail3D();
trail.maxAge = TRAIL_MAX_AGE;
trail.width = TRAIL_WIDTH;
trail.radialSegments = TRAIL_RADIAL_SEGMENTS;
trail.color.set(0.31, 0.94, 0.66, 0.85);
// The ball itself. Shade has no sphere primitive — a subdivided octahedron is
// one, every vertex of it landing on the radius.
const ballMaterial = standardMaterial(BALL_COLOR, 0.35);
// The old three material carried `emissive` + `emissiveIntensity`; Shade folds
// both into one linear `emissive_factor`, multiplied against the (absent, so
// white) emissive texture.
ballMaterial.emissive_factor.copy(Color.from_sRGB_to_linear(Color.parse(BALL_COLOR)));
ballMaterial.emissive_factor.multiplyScalar(0.45);
const ballEntity = new Entity()
.add(ballTransform)
.add(effectors)
.add(trail)
.add(ShadedGeometry.from(
meshlet_geometry_build_from_geometry(make_octahedron_geometry(BALL_RADIUS, 12)),
ballMaterial,
))
.build(ecd);
// ─── §8 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.setTranslation(
-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();
const bendRotation = new Quaternion(); // scratch, reused by every tree every frame
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. trail.clear() is the same idea for the tube:
// it expires every knot and raises a reseed flag, so the next head update
// collapses the whole tube onto the new position instead of drawing a
// segment across the map.
const x = ballTransform.translation_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 {
ballTransform.setTranslation(x, BALL_Y, ballTransform.translation_z + ballVz * dt);
}
// A translation lands in the matrix as it is written, but the write itself
// is silent: the mesh follows only once the entity announces it. (The trail
// and the wake effector read the transform themselves each tick.)
t64_announce_change(ecd, ballEntity);
// — 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) {
bendRotation.set(0, 0, 0, 1);
} else {
bendAxis.set(tree.bz / angle, 0, -tree.bx / angle);
bendRotation.fromAxisAngle(bendAxis, Math.min(angle, MAX_BEND * 1.4));
}
// Rotation is not in the matrix until updateMatrix composes it, and the
// write is not visible to the renderer until the entity announces it.
tree.transform.setRotation(bendRotation.x, bendRotation.y, bendRotation.z, bendRotation.w);
tree.transform.updateMatrix();
t64_announce_change(ecd, tree.entity);
}
// — 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": "3.41.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 Transform64
on its entity; `origin` + `cell_size` set once).
- `WakeFluidEffector` riding an entity Transform64 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.
- `Trail3D` + `Trail3DSystem` — a tube trail streaming from the ball, marking
the moving source of the wake. The component lives in
`engine/graphics/ecs/trail3d/`, the system that draws it under Shade in
`engine/graphics3/` and takes the graphics facade. `trail.clear()` joins
`wake.reset_trail()` on the loop wrap so neither leaves a streak across the
teleport.
(`Trail3D`'s `textureURL`, `depthWrite` and `lightingEnabled` exist but are
inert, so the tube is a flat colour; there is no screen-facing ribbon path.)
## 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({
// Vite's dependency pre-bundling rewrites the engine's own asset imports and
// loses their URLs, so a dev server ends up serving ".../deps/undefined" for
// the renderer's data files. The engine is source-only ESM and needs no
// pre-bundling - but its CommonJS dependencies do, or the import of them from inside
// the excluded engine finds no default export.
optimizeDeps: {
exclude: ["@woosh/meep-engine"],
include: ["dat.gui", "opentype.js"],
},
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",
},
});