// 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 Transform 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,
// Transform) 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 Engine bootstrap render · physics · obstacle · fluid systems
// §3 Ground
// §4 The fluid volume
// §5 The trees cone on a stick × a grid
// §6 The jets + the pillars effectors that puff · obstacles that block
// §7 Per-frame pulse the jets, bend the trees, HUD
import * as THREE from "three";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.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";
// 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 Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.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 TRUNK_COLOR = 0x6b4f35;
const CANOPY_COLOR = 0x3f9460;
const PILLAR_COLOR = 0x39404a;
const JET_COLOR = 0x4ec8f0; // blue, on the active pulse
const JET_BASE_COLOR = 0x6b7079; // grey at rest — reads as part of the scenery
// Pulse indicator envelope. The nozzle warms from grey to blue, glows, and
// swells as its jet fires — all 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 = 2.2; // peak emissive intensity at full pulse
const PULSE_GROW = 0.45; // extra uniform scale at full pulse (×)
// ─── §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) => {
config.addSystem(new ShadedGeometrySystem(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());
// Screen-space ambient occlusion — grounds the cones, pillars and the
// ground plane 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, matching wake-field
cameraFarDistance: 300,
showFps: false,
});
const ecd = engine.entityManager.dataset;
// ─── §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.
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;
// ─── §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.
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 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 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 jets + the pillars ──────────────────────────────────────────────
//
// JETS. All three impulse effectors live in ONE FluidEffectorsComponent on a
// single entity with no Transform — 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 §7 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 §7
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 cone laid on its side pointing +X (the blow
// direction). At rest it is grey like the scenery; in §7 we warm it to blue,
// glow it, and swell it as its jet fires. ConeGeometry points +Y by default →
// rotate −90° about Z. We keep each material AND transform so §7 can drive the
// colour, emissive intensity, and scale.
const nozzleGeometry = new THREE.ConeGeometry(0.4, 1.1, 16);
nozzleGeometry.rotateZ(-Math.PI / 2);
const jetMaterials = [];
const jetTransforms = [];
for (let i = 0; i < jets.length; i++) {
const z = JET_LANES_Z[i];
const material = new THREE.MeshStandardMaterial({
color: JET_BASE_COLOR, // grey at rest
emissive: JET_COLOR, // blue, faded in on the pulse
emissiveIntensity: 0,
roughness: 0.5,
});
const t = new Transform();
t.position.set(JET_X, SAMPLE_Y, z);
new Entity()
.add(t)
.add(ShadedGeometry.from(nozzleGeometry, material))
.build(ecd);
jetMaterials.push(material);
jetTransforms.push(t);
}
// PILLARS. Each is a Static rigid body — Collider (a box), RigidBody, Transform
// — 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 = new THREE.BoxGeometry(PILLAR_W, PILLAR_H, PILLAR_D);
const pillarMaterial = new THREE.MeshStandardMaterial({ color: PILLAR_COLOR, roughness: 0.95 });
for (let i = 0; i < PILLAR_LANES_Z.length; i++) {
const z = PILLAR_LANES_Z[i];
const transform = new Transform();
transform.position.set(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);
}
// ─── §7 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();
// Endpoints for the nozzle colour lerp (grey at rest → blue on the pulse).
const NOZZLE_BASE = new THREE.Color(JET_BASE_COLOR);
const NOZZLE_ACTIVE = new THREE.Color(JET_COLOR);
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. Colour (grey→blue), emissive glow, and
// scale all ride the same `vis`.
const rate = gust > j.vis ? PULSE_ATTACK : PULSE_RELEASE;
j.vis += (gust - j.vis) * (1 - Math.exp(-rate * dt));
const mat = jetMaterials[i];
mat.color.copy(NOZZLE_BASE).lerp(NOZZLE_ACTIVE, j.vis);
mat.emissiveIntensity = PULSE_GLOW * j.vis;
const scale = 1 + PULSE_GROW * j.vis;
jetTransforms[i].scale.set(scale, scale, scale);
}
// — 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) {
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>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"
}
{
"name": "@meep-examples/impulse-jets",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Pulsing impulse effectors puff air through a tree plantation; stone pillars act as fluid obstacles casting a wind-shadow in their lee.",
"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"
}
}
# Impulse jets
Three stationary jets line the upwind edge of a 16×12 tree plantation and
PULSE — each fires a squared-sine puff of air down its lane every few seconds.
The puffs are `ImpulseFluidEffector`s: every fixed tick each one splats a
velocity impulse into a spherical region of a `FluidComponent` volume, and the
gust advects downwind. 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.
Two stone **pillars** stand mid-field. They are `FluidObstacle`s — the
`FluidObstacleSystem` voxelizes each collider into the fluid's solid mask every
tick, so the air stops at them. The trees in a pillar's lee barely move (a
visible wind-shadow) while the open centre lane ripples freely.
What it demonstrates:
- `ImpulseFluidEffector` — a fixed world `position` + a `force` vector + a
`radius`. Each tick it deposits `force·dt` over a quadratic-falloff sphere.
Three of them share one `FluidEffectorsComponent` on a Transform-less entity,
so each splats wherever its own `position` says.
- Pulsing an effector by rewriting `force` per-frame — a squared half-sine
`max(0, sin)²` gives clean puffs with rest gaps between firings.
- `FluidObstacle` + `FluidObstacleSystem` — tag any physics body (`Collider` +
`RigidBody` + `Transform`) as a wall and the obstacle system stamps it into
every overlapping field's solid mask. Here the bodies are `BodyKind.Static`.
- 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 a
thin pillar airtight.)
- `sampleVelocityAtWorld()` as a cheap read-side API: vegetation sway driven
straight off the simulation, with no coupling back into it.
- Trees as underdamped spring oscillators — a passing puff rings the canopy
rather than just leaning it, which is what makes the puff read as a wave.
## Run
```sh
npm install
npm run dev
```
## Build
`npm run build` emits the static demo into `public/examples/impulse-jets/`.
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({
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"));
}
},
},
],
});