// Impulse jets — a Meep example for the fluid simulation + impulse effector +
// solid obstacles.
//
// Three stationary jets line the upwind edge of a tree plantation and PULSE,
// each firing a puff of air down its lane every couple of seconds. The puffs
// are ImpulseFluidEffectors: every fixed tick each one splats a velocity
// impulse into a spherical region of a FluidComponent volume, and the gust
// then advects downwind through the field. Every tree samples the fluid
// velocity at canopy height and bends with it — so each puff reads as a wave
// of sway rolling away from its jet.
//
// Standing mid-field are two stone PILLARS. They are FluidObstacles: the
// FluidObstacleSystem voxelizes each collider into the fluid's solid mask
// every tick, so the air genuinely stops at them. The trees in a pillar's lee
// barely move — a visible wind-shadow — while the open centre lane ripples
// freely.
//
// 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.
// - ImpulseFluidEffector the puffer: a fixed world position + a force vector.
// Each tick it splats `force·dt` into a sphere of
// `radius`. We pulse `force` on/off 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.
//
// Sections:
// §1 Tuning constants
// §2 Shade materials
// §3 Engine bootstrap render · physics · obstacle · fluid systems
// §4 Ground
// §5 The fluid volume
// §6 The trees cone on a stick × a grid
// §7 The jets + the pillars effectors that puff · obstacles that block
// §8 Per-frame pulse the jets, 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 { 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";
// 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 { 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 { ImpulseFluidEffector } from "@woosh/meep-engine/src/engine/physics/fluid/effector/ImpulseFluidEffector.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. Jets blow along +X, so
// rows run along X and the lanes are indexed by Z.
const TREES_X = 16;
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. A coarse grid is plenty — the puffs read through the trees,
// not through fine vortices. ~12k cells steps comfortably each tick.
const CELL_SIZE = 0.6;
const FIELD_SIZE_X = 30; // metres; jets puff along +X
const FIELD_SIZE_Y = 6;
const FIELD_SIZE_Z = 22;
// The jets. Each sits at the upwind edge in its own lane and fires a squared-
// sine puff of air in +X. The phases are staggered so the three lanes pulse
// out of step and the field never looks periodic. Force is in WORLD units/s²;
// radius is the world-space splat sphere.
const JET_X = -13; // just inside the left edge of the field
const JET_STRENGTH = 3000; // peak force magnitude (world units/s²)
const JET_RADIUS = 2.6; // world-space splat sphere
const JET_PERIOD = 6.0; // seconds between a lane's puffs
const JET_LANES_Z = [-6, 0, 6]; // one jet per lane
const JET_PHASES = [0.0, 2.0, 4.0]; // seconds — stagger the three lanes
// The pillars: thin across the flow, wide across the lanes, tall enough to be
// solid at canopy height. One sits mid-field in each OUTER lane (z = ±6); the
// CENTRE lane (z = 0) is left open, so the contrast is unmistakable — open
// lane ripples, sheltered lanes stay calm.
const PILLAR_X = -3; // downwind of the jets, upwind of most trees
const PILLAR_LANES_Z = [-6, 6];
const PILLAR_W = 1.0; // X — thin, across the flow
const PILLAR_D = 3.2; // Z — wide, shadows ~2 tree rows
const PILLAR_H = 3.6; // Y — spans canopy height
// Grow the voxel footprint half a cell so a pillar barely thicker than a cell
// still stamps an airtight wall (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. Trees don't snap to the target — each canopy is an
// underdamped spring (ω, ζ): a passing puff RINGS it rather than just leaning
// it, and that ring travelling tree-to-tree is what reads as a wave.
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 PILLAR_COLOR = "#39404a";
const JET_COLOR = "#4ec8f0"; // blue, the colour of a firing jet
const JET_BASE_COLOR = "#6b7079"; // grey nozzle — reads as part of the scenery
// Pulse indicator envelope. Each nozzle swells as its jet fires, and a glowing
// blue FLARE cone blooms out of its mouth — both driven off one `vis` value in
// [0, 1] that SNAPS up fast on firing and fades SLOW afterwards. The asymmetry
// (attack ≫ release) is what makes each puff catch the eye. Rates are per-second.
const PULSE_ATTACK = 22; // 1/s — near-instant rise when the jet fires
const PULSE_RELEASE = 2.2; // 1/s — slow afterglow
const PULSE_GLOW = 1.2; // emissive intensity of the flare cone
const PULSE_GROW = 0.45; // extra uniform scale on the nozzle at full pulse (×)
const FLARE_MIN_SCALE = 0.001; // never exactly zero — a degenerate transform is not a shape
// ─── §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.
//
// A material's parameters are packed into a GPU record the first time the
// renderer sees it and are NOT re-read afterwards — unlike a three.js material,
// you cannot animate `diffuse_color` or `emissive_factor` per frame. Anything
// that has to change every frame has to change on the TRANSFORM instead, which
// is why the pulse indicator in §8 is built out of scale rather than colour.
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 ────────────────────────────────────────────────────
//
// 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.
//
// 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. 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)));
// 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());
},
});
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;
// ─── §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.
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, no propagating
// compression wave. What travels is the advected puff. Damping is tuned so a
// puff crosses a few rows then dies, leaving the forest to settle before the
// next pulse — passes don't smear together.
sim.velocity_damping = 1.6;
sim.vorticity_confinement = 0;
// First-order semi-Lagrangian transport smears small structure away, which
// here is a feature: the puff 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. 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 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 §9 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 jets + the pillars ──────────────────────────────────────────────
//
// JETS. All three impulse effectors live in ONE FluidEffectorsComponent on a
// single entity with no Transform64 — each effector carries its own world
// `position`, so the system splats each where it stands. We keep references in
// `jets[]` and rewrite `force` per-frame in §8 to make them pulse.
const effectors = new FluidEffectorsComponent();
const jets = [];
for (let i = 0; i < JET_LANES_Z.length; i++) {
const z = JET_LANES_Z[i];
const jet = new ImpulseFluidEffector();
jet.position = [JET_X, SAMPLE_Y, z];
jet.radius = JET_RADIUS;
jet.force = [0, 0, 0]; // driven each frame in §8
effectors.addEffector(jet);
jets.push({ effector: jet, phase: JET_PHASES[i], vis: 0 });
}
new Entity().add(effectors).build(ecd);
// A nozzle marker per jet — a grey cone laid on its side pointing +X (the blow
// direction) — plus a glowing blue FLARE cone sitting in its mouth. Shade's
// cones point +Y, so both transforms carry a fixed −90° roll about Z; §8 then
// drives the two SCALES off the pulse, swelling the nozzle and blooming the
// flare out of nothing. Scale is the whole indicator because a Shade material
// is immutable once uploaded (§2) — there is no per-frame colour to lerp.
const CONE_AXIS = new Vector3(0, 0, 1); // roll about Z takes +Y to +X
const nozzleGeometry = meshlet_geometry_build_from_geometry(
make_cylinder_geometry(0, 0.4, 1.1, 16),
);
const nozzleMaterial = standardMaterial(JET_BASE_COLOR, 0.5);
// The flare is longer and thinner than the nozzle and sits just past its mouth,
// so at full pulse it reads as a jet of air leaving the cone.
const FLARE_LENGTH = 1.6;
const FLARE_OFFSET = 0.55 + FLARE_LENGTH / 2;
const flareGeometry = meshlet_geometry_build_from_geometry(
make_cylinder_geometry(0, 0.34, FLARE_LENGTH, 16),
);
const flareMaterial = standardMaterial(JET_COLOR, 0.4);
// Emissive is a colour × a white default texture, so the factor alone lights it.
flareMaterial.emissive_factor.copy(Color.from_sRGB_to_linear(Color.parse(JET_COLOR)));
flareMaterial.emissive_factor.multiplyScalar(PULSE_GLOW);
const nozzleTransforms = []; // { transform, entity } — the entity id is what a write is announced against
const flareTransforms = [];
const coneRotation = new Quaternion();
for (let i = 0; i < jets.length; i++) {
const z = JET_LANES_Z[i];
coneRotation.fromAxisAngle(CONE_AXIS, -Math.PI / 2);
const nozzle = new Transform64();
nozzle.setTranslation(JET_X, SAMPLE_Y, z);
nozzle.setRotation(coneRotation.x, coneRotation.y, coneRotation.z, coneRotation.w);
// a Transform64 does not maintain its matrix; compose it once the pose is set
nozzle.updateMatrix();
const nozzleEntity = new Entity()
.add(nozzle)
.add(ShadedGeometry.from(nozzleGeometry, nozzleMaterial))
.build(ecd);
const flare = new Transform64();
flare.setTranslation(JET_X + FLARE_OFFSET, SAMPLE_Y, z);
flare.setRotation(coneRotation.x, coneRotation.y, coneRotation.z, coneRotation.w);
flare.setScale(FLARE_MIN_SCALE, FLARE_MIN_SCALE, FLARE_MIN_SCALE);
flare.updateMatrix();
const flareEntity = new Entity()
.add(flare)
.add(ShadedGeometry.from(flareGeometry, flareMaterial))
.build(ecd);
nozzleTransforms.push({ transform: nozzle, entity: nozzleEntity });
flareTransforms.push({ transform: flare, entity: flareEntity });
}
// PILLARS. Each is 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 exactly.
const pillarGeometry = meshlet_geometry_build_from_geometry(
make_box_geometry(PILLAR_W, PILLAR_H, PILLAR_D),
);
const pillarMaterial = standardMaterial(PILLAR_COLOR, 0.95);
for (let i = 0; i < PILLAR_LANES_Z.length; i++) {
const z = PILLAR_LANES_Z[i];
const transform = new Transform64();
transform.setTranslation(PILLAR_X, PILLAR_H / 2, z);
const body = new RigidBody();
body.kind = BodyKind.Static;
const collider = new Collider();
collider.shape = BoxShape3D.from_size(PILLAR_W, PILLAR_H, PILLAR_D);
const obstacle = new FluidObstacle();
obstacle.inflation = OBSTACLE_INFLATION;
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(obstacle)
.add(ShadedGeometry.from(pillarGeometry, pillarMaterial))
.build(ecd);
}
// ─── §8 Per-frame: pulse the jets, 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 JET_OMEGA = (2 * Math.PI) / JET_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;
// — Pulse the jets. A squared half-sine gives a clean puff with a rest gap
// between firings: g = max(0, sin)² ∈ [0, 1]. We write `force` (world
// units/s²) along +X; the next fixedUpdate splats `force·dt` into the sphere.
for (let i = 0; i < jets.length; i++) {
const j = jets[i];
const s = Math.sin(JET_OMEGA * elapsed - j.phase * JET_OMEGA);
const gust = s > 0 ? s * s : 0;
j.effector.force[0] = JET_STRENGTH * gust;
// Drive the nozzle indicator off a fast-attack / slow-release envelope
// of the gust: vis snaps toward the gust when it's rising (PULSE_ATTACK)
// and eases down when it's falling (PULSE_RELEASE), so each firing flares
// on instantly and then lingers. The nozzle swells and the glowing flare
// grows out of its mouth, both riding the same `vis`.
const rate = gust > j.vis ? PULSE_ATTACK : PULSE_RELEASE;
j.vis += (gust - j.vis) * (1 - Math.exp(-rate * dt));
// Scale lives outside the matrix until updateMatrix composes it, and the
// renderer only re-reads the transform once the entity announces it.
const nozzleScale = 1 + PULSE_GROW * j.vis;
nozzleTransforms[i].transform.setScale(nozzleScale, nozzleScale, nozzleScale);
nozzleTransforms[i].transform.updateMatrix();
t64_announce_change(ecd, nozzleTransforms[i].entity);
const flareScale = Math.max(j.vis, FLARE_MIN_SCALE);
flareTransforms[i].transform.setScale(flareScale, flareScale, flareScale);
flareTransforms[i].transform.updateMatrix();
t64_announce_change(ecd, flareTransforms[i].entity);
}
// — 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.
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>Impulse jets · 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: #4ec8f0; }
.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">
Three <strong>impulse jets</strong> on the left puff air down their lanes
into a fluid volume; every tree samples the velocity field and bends, so
each puff rolls out as a wave of sway. Two stone <strong>pillars</strong>
are fluid obstacles — watch the trees in their <strong>lee</strong> stay
calm while the open centre lane ripples. Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Impulse jets",
"description": "Pulsing impulse effectors puff air down the lanes of a tree plantation; every canopy samples the velocity field and bends, and two stone pillars act as fluid obstacles that cast a calm wind-shadow in their lee.",
"category": "Physics",
"status": "live",
"order": 8,
"tags": ["physics", "fluid", "impulse", "obstacle", "wind", "vegetation", "ecs"],
"sourceHint": "examples-src/impulse-jets/",
"demoUrl": "/examples/impulse-jets/demo.html",
"defaultFile": "src/main.js"
}
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/impulse-jets");
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"));
}
},
},
],
});