// Prevailing wind — a Meep example for the fluid simulation + global effector +
// solid obstacles.
//
// One GlobalFluidEffector blows a steady breeze across a tree plantation: every
// fluid face in the volume relaxes toward the same ambient wind each tick, so
// the whole field leans coherently. The wind slowly wheels and gusts, and the
// forest sways with it.
//
// Standing in the field are a long WINDBREAK wall and two BOULDERS. They are
// FluidObstacles — the FluidObstacleSystem voxelizes each collider into the
// fluid's solid mask every tick, so the breeze genuinely stops at them and has
// to flow around. Trees in the wall's lee stay upright in a sheltered pocket
// while the open forest leans; the boulders trail their own small calm wakes.
//
// The fluid pieces and who does what:
//
// - FluidComponent the volume: a velocity grid + where it sits in the
// world. No Transform64 on its entity → static.
// - GlobalFluidEffector the atmosphere: position-free. Each tick it relaxes
// EVERY unpinned face toward `wind` at rate `drag`.
// We steer `wind` per-frame in §7.
// - FluidObstacle the wall marker: rides a (Collider, RigidBody,
// Transform64) body. FluidObstacleSystem stamps the
// collider into every overlapping field's solid mask.
// - FluidSystem the pump: steps every field on the fixed timestep.
//
// Trees don't participate — they only READ: sampleVelocityAtWorld() at the
// canopy, smoothed through a spring, tilts the cone. Pinned faces inside and
// beside the obstacles never receive the wind, and the projection deflects the
// flow around them — that deflected, slackened flow in the lee is the shelter.
//
// Sections:
// §1 Tuning constants
// §2 Engine bootstrap render · physics · obstacle · fluid systems
// §3 Ground
// §4 The fluid volume
// §5 The trees cone on a stick × a grid
// §6 The wind + the shelter the global effector · the wall + boulders
// §7 Per-frame steer the wind, 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. `make_*_geometry` returns
// an authoring-form `Geometry`; `meshlet_geometry_build_from_geometry` clusters
// it into the GPU form the renderer draws — 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 { StandardAttributes } from "@woosh/meep-engine/src/shade/renderer/geometry/StandardAttributes.js";
import { StandardShadeMaterial } from "@woosh/meep-engine/src/shade/renderer/material/StandardShadeMaterial.js";
import { Color } from "@woosh/meep-engine/src/core/color/Color.js";
// Rigid-body plumbing — obstacles ARE physics bodies (the same tuple the
// solver simulates), so the obstacle system can read their colliders and
// velocities. Here every body is Static, so the solver just holds them.
import { PhysicsSystem } from "@woosh/meep-engine/src/engine/physics/ecs/PhysicsSystem.js";
import { ColliderObserverSystem } from "@woosh/meep-engine/src/engine/physics/ecs/ColliderObserverSystem.js";
import { RigidBody } from "@woosh/meep-engine/src/engine/physics/ecs/RigidBody.js";
import { Collider } from "@woosh/meep-engine/src/engine/physics/ecs/Collider.js";
import { BodyKind } from "@woosh/meep-engine/src/engine/physics/ecs/BodyKind.js";
import { BoxShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/BoxShape3D.js";
import { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.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 { FluidObstacle } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidObstacle.js";
import { FluidObstacleSystem } from "@woosh/meep-engine/src/engine/physics/fluid/ecs/FluidObstacleSystem.js";
import { AdvectionScheme } from "@woosh/meep-engine/src/engine/physics/fluid/FluidSimulator.js";
import { GlobalFluidEffector } from "@woosh/meep-engine/src/engine/physics/fluid/effector/GlobalFluidEffector.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. The wind blows mainly
// along +X, so the wall's shelter falls on the +X side of it.
const TREES_X = 18;
const TREES_Z = 12;
const TREE_SPACING = 1.6;
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 fluid volume.
const CELL_SIZE = 0.6;
const FIELD_SIZE_X = 32; // metres; the prevailing wind runs along +X
const FIELD_SIZE_Y = 6;
const FIELD_SIZE_Z = 22;
// The wind. `wind` is the ambient velocity the air relaxes toward (world
// units/s); `drag` is the relaxation rate (1/s) — higher re-asserts the breeze
// faster after the obstacles slacken it, lower lets the sheltered pockets
// deepen. We steer the wind in §7: its heading wheels slowly and its speed
// gusts, so the forest is never still and the lee shifts with the heading.
const WIND_SPEED = 6; // base ambient speed (world units/s)
const WIND_DRAG = 1.1; // relaxation rate toward `wind` (1/s)
const WIND_SWING = 0.34; // rad — how far the heading wheels off +X
const WIND_SWING_PERIOD = 16; // seconds for one full heading sweep
const WIND_GUST = 0.7; // ± fraction the speed gusts by
const WIND_GUST_PERIOD = 3.5; // seconds per gust cycle
// The windbreak wall: thin across the flow, long across the lanes, tall enough
// to be solid at canopy height. Sat upwind of centre so a broad sheltered lee
// falls across the middle of the forest.
const WALL_X = -5;
const WALL_W = 0.7; // X — thin, across the flow
const WALL_D = 7.0; // Z — long, the windbreak span
const WALL_H = 3.8; // Y — spans canopy height
// Two boulders downwind of the wall, off to the sides, each trailing a small
// calm wake of its own. [x, z, radius].
const BOULDERS = [
[5.5, 3.0, 1.5],
[8.5, -3.5, 1.3],
];
// Grow each voxel footprint half a cell so a thin wall still stamps an airtight
// solid (see FluidObstacle.inflation).
const OBSTACLE_INFLATION = CELL_SIZE * 0.5;
// Tree response: target bend = BEND_GAIN × sampled velocity (grid cells/s),
// capped at MAX_BEND. Each canopy is an underdamped spring (ω, ζ), so gusts
// ring it rather than just leaning it.
const BEND_GAIN = 0.055;
const MAX_BEND = 0.6; // rad — about 34°
const SWAY_OMEGA = 6; // rad/s — canopy natural frequency
const SWAY_ZETA = 0.4; // damping ratio; < 1 ⇒ wobbles past zero
const GROUND_COLOR = "#161c24";
const TRUNK_COLOR = "#6b4f35";
const CANOPY_COLOR = "#3f9460";
const WALL_COLOR = "#4a4640";
const BOULDER_COLOR = "#6b7079";
// ─── §1b Shade helpers ──────────────────────────────────────────────────────
//
// Colours above are written as sRGB hex (what a designer types); the renderer
// works in linear light, so decode once here rather than at every use.
function standardMaterial(hexColor, roughness) {
const material = new StandardShadeMaterial();
material.diffuse_color.copy(Color.from_sRGB_to_linear(Color.parse(hexColor)));
material.roughness_factor = roughness;
material.metallic_factor = 0;
return material;
}
// Shade's `Geometry` has no `translate()` the way three's BufferGeometry did,
// so shift the position attribute by hand. This is authoring-form data and we
// do it once per shape, before clustering — never on the GPU form.
function translateGeometryY(geometry, dy) {
const data = geometry.getAttribute(StandardAttributes.Position).data;
for (let i = 1; i < data.length; i += 3) {
data[i] += dy;
}
return geometry;
}
// ─── §2 Engine bootstrap ────────────────────────────────────────────────────
//
// System registration ORDER matters. The obstacle system must run AFTER the
// physics system (to read this tick's body poses/velocities) and BEFORE the
// fluid system (so the simulation step sees this tick's walls). Engine systems
// run in registration order, so we add them physics → obstacle → fluid.
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
// ShadedGeometrySystem draws every entity carrying a ShadedGeometry.
// It takes the graphics facade and the Shade scene the harness renders,
// which `EngineHarness.shadeScene` hands out (one per engine).
config.addSystem(new ShadedGeometrySystem(engine.graphics, EngineHarness.shadeScene(engine)));
// Rigid bodies + the observer that wires colliders onto them. Our
// obstacles are Static, but they still need the full body tuple.
const physics = new PhysicsSystem();
config.addSystem(physics);
config.addSystem(new ColliderObserverSystem(physics));
// Voxelizes obstacle colliders into every fluid field's solid mask.
config.addSystem(new FluidObstacleSystem());
// Steps every fluid field on the fixed timestep.
config.addSystem(new FluidSystem());
// Ambient occlusion is no longer an opt-in post effect: Shade computes
// GTAO as part of the frame and has it on by default, so the cones, the
// wall and the boulders get their contact shading for free.
},
});
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, matching wake-field
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;
// ─── §3 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);
}
// ─── §4 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.
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 global effector's `drag` already relaxes the whole field, so the solver's
// own velocity_damping stays low — just enough to settle numerical noise. With
// drag carrying the calming, the obstacle wakes persist long enough to read.
sim.velocity_damping = 0.2;
sim.vorticity_confinement = 0;
// First-order semi-Lagrangian transport smears small structure away, keeping
// the steady wind clean rather than churning.
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.
//
// Shade builds both shapes with the same generator: a cone is a cylinder with a
// zero top radius. Both come out centred on the origin, so each is shifted up
// half its height before clustering.
const trunkGeometry = meshlet_geometry_build_from_geometry(
translateGeometryY(make_cylinder_geometry(0.06, 0.09, TRUNK_HEIGHT, 6), TRUNK_HEIGHT / 2),
);
const canopyGeometry = meshlet_geometry_build_from_geometry(
translateGeometryY(make_cylinder_geometry(0, CANOPY_RADIUS, CANOPY_HEIGHT, 8), CANOPY_HEIGHT / 2),
);
const trunkMaterial = standardMaterial(TRUNK_COLOR, 0.9);
const canopyMaterial = standardMaterial(CANOPY_COLOR, 0.8);
// Per-tree bookkeeping for §7: world position, the canopy transform we tilt,
// and the smoothed bend/velocity state.
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 });
}
}
// ─── §6 The wind + the shelter ──────────────────────────────────────────────
//
// THE WIND. One GlobalFluidEffector, position-free, in a FluidEffectorsComponent
// on a Transform64-less entity. `wind` + `drag` makes the air relax toward the
// ambient breeze every tick (the fixed point is exactly `wind`), so any
// disturbance — like the obstacle wakes — decays back to the prevailing flow.
// We rewrite `wind` per-frame in §7 to wheel and gust it.
const wind = new GlobalFluidEffector();
wind.wind = [WIND_SPEED, 0, 0];
wind.drag = WIND_DRAG;
const effectors = new FluidEffectorsComponent();
effectors.addEffector(wind);
new Entity().add(effectors).build(ecd);
// THE WINDBREAK WALL. A Static rigid body — Collider (a box), RigidBody,
// Transform64 — tagged with FluidObstacle so the obstacle system voxelizes it
// into the field's solid mask every tick. The render box matches the collider.
{
const transform = new Transform64();
transform.setTranslation(WALL_X, WALL_H / 2, 0);
const body = new RigidBody();
body.kind = BodyKind.Static;
const collider = new Collider();
collider.shape = BoxShape3D.from_size(WALL_W, WALL_H, WALL_D);
const obstacle = new FluidObstacle();
obstacle.inflation = OBSTACLE_INFLATION;
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(obstacle)
.add(ShadedGeometry.from(
meshlet_geometry_build_from_geometry(make_box_geometry(WALL_W, WALL_H, WALL_D)),
standardMaterial(WALL_COLOR, 0.95),
))
.build(ecd);
}
// THE BOULDERS. Static sphere bodies, also FluidObstacles — same recipe, a
// SphereShape3D collider instead of a box. Shade has no UV sphere: a
// subdivided octahedron is the sphere, and detail 3 is smooth at this size.
const boulderMaterial = standardMaterial(BOULDER_COLOR, 0.9);
for (let i = 0; i < BOULDERS.length; i++) {
const [x, z, r] = BOULDERS[i];
const transform = new Transform64();
transform.setTranslation(x, r, z);
const body = new RigidBody();
body.kind = BodyKind.Static;
const collider = new Collider();
collider.shape = SphereShape3D.from(r);
const obstacle = new FluidObstacle();
obstacle.inflation = OBSTACLE_INFLATION;
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(obstacle)
.add(ShadedGeometry.from(
meshlet_geometry_build_from_geometry(make_octahedron_geometry(r, 12)),
boulderMaterial,
))
.build(ecd);
}
// ─── §7 Per-frame: steer the wind, 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 SWING_OMEGA = (2 * Math.PI) / WIND_SWING_PERIOD;
const GUST_OMEGA = (2 * Math.PI) / WIND_GUST_PERIOD;
const sample = new Float32Array(3);
const bendAxis = new Vector3();
const bendRotation = new Quaternion(); // scratch, reused by every tree every frame
let elapsed = 0;
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;
elapsed += dt;
// — Steer the wind. The heading wheels ±WIND_SWING off +X and the speed
// gusts ±WIND_GUST, both on slow sinusoids. Writing `wind` here means the
// next fixedUpdate relaxes the whole field toward the new breeze — frame-
// rate independent, because the effector integrates the EXACT ODE solution.
const heading = WIND_SWING * Math.sin(SWING_OMEGA * elapsed);
const speed = WIND_SPEED * (1 + WIND_GUST * Math.sin(GUST_OMEGA * elapsed));
wind.wind[0] = speed * Math.cos(heading);
wind.wind[2] = speed * Math.sin(heading);
// — Bend the trees. Each samples the fluid velocity at mid-canopy (in
// grid-cells/s — the field's native unit, deliberately not rescaled),
// maps it to a target bend vector, and integrates a damped spring toward
// it (semi-implicit Euler). The tilt axis is ⟂ to the bend so the canopy
// leans downwind; the spring's overshoot is the sway. Trees in the wall's
// lee sample a slackened field and barely lean — the visible shelter.
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>Prevailing wind · 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: #6fd08c; }
.legend {
bottom: 1rem; left: 1rem;
padding: 0.8rem 1rem;
font-size: 0.82rem;
line-height: 1.55;
max-width: 440px;
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 <strong>global wind</strong> effector blows a steady, wheeling breeze
across the fluid volume; every tree samples the velocity field and leans
with it. A long <strong>windbreak wall</strong> and two
<strong>boulders</strong> are fluid obstacles — the air flows around them,
so the trees in their <strong>lee</strong> stay upright in a sheltered
pocket while the open forest sways. Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Prevailing wind",
"description": "A global wind effector blows a steady, wheeling breeze across a tree plantation; every canopy samples the velocity field and leans, while a windbreak wall and two boulders act as fluid obstacles that shelter a calm pocket in their lee.",
"category": "Physics",
"status": "live",
"order": 9,
"tags": ["physics", "fluid", "global", "wind", "obstacle", "vegetation", "ecs"],
"sourceHint": "examples-src/prevailing-wind/",
"demoUrl": "/examples/prevailing-wind/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/prevailing-wind",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A global wind effector blows a wheeling breeze across a tree plantation; a windbreak wall and boulders act as fluid obstacles sheltering a calm lee.",
"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"
}
}
# Prevailing wind
One `GlobalFluidEffector` blows a steady breeze across an 18×12 tree
plantation: every fluid face in the `FluidComponent` volume relaxes toward the
same ambient `wind` each tick, so the whole field leans coherently. The wind
slowly wheels and gusts (steered per-frame), and every tree samples the fluid
velocity at canopy height and sways with it.
A long **windbreak wall** and two **boulders** stand in the field. They are
`FluidObstacle`s — the `FluidObstacleSystem` voxelizes each collider into the
fluid's solid mask every tick, so the breeze stops at them and flows around.
Trees in the wall's lee stay upright in a sheltered pocket while the open
forest leans; the boulders trail their own small calm wakes.
What it demonstrates:
- `GlobalFluidEffector` in its `wind` + `drag` regime — position-free, it
relaxes every unpinned face toward the ambient wind at `drag` per second
(the fixed point is exactly `wind`), so obstacle wakes decay back into the
prevailing flow. Pinned faces (inside and beside the solids) are skipped, so
the projection deflects the flow around the obstacles — that slackened,
deflected flow in the lee is the shelter.
- Steering a global wind by rewriting `wind` per-frame; the effector integrates
the exact ODE solution, so the result is frame-rate independent.
- `FluidObstacle` + `FluidObstacleSystem` with two shapes — a `BoxShape3D`
windbreak and `SphereShape3D` boulders — tagged on `BodyKind.Static` bodies.
- System registration ORDER: `PhysicsSystem` → `FluidObstacleSystem` →
`FluidSystem`, so the obstacle voxelization sees this tick's poses and the
fluid step sees this tick's walls. (`inflation = cell_size / 2` keeps the
thin wall airtight.)
- `velocity_damping` left low because the effector's own `drag` carries the
calming — so the obstacle wakes persist long enough to read.
- `sampleVelocityAtWorld()` as a cheap read-side API: vegetation sway driven
straight off the simulation, with no coupling back into it.
## Run
```sh
npm install
npm run dev
```
## Build
`npm run build` emits the static demo into `public/examples/prevailing-wind/`.
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import strip from "@rollup/plugin-strip";
const __dirname = dirname(fileURLToPath(import.meta.url));
const outDir = resolve(__dirname, "../../public/examples/prevailing-wind");
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"],
},
base: "./",
build: {
outDir,
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
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 src = resolve(__dirname, "thumbnail.png");
if (existsSync(src)) {
mkdirSync(outDir, { recursive: true });
copyFileSync(src, resolve(outDir, "thumbnail.png"));
}
},
},
],
});