// Raining rigid bodies — a Meep example for the rigid-body PhysicsSystem.
//
// Spheres, capsules and boxes rain onto a squashed-box floor, pile up, tumble
// off the edges and are recycled when they fall away below the world. It is the
// smallest interesting thing you can build on top of the new physics system:
// one Static body for the ground, a steady stream of Dynamic bodies above it,
// and a despawn rule to keep the simulation bounded.
//
// Sections:
// §1 Tuning constants world size, spawn rate, despawn floor
// §2 Geometry + inertia helpers capsule mesh, inertia tensors, random spin
// §3 Body palette shared geometry / material / collider shape
// §4 The rain system spawn on a timer, despawn what falls away
// §5 Engine bootstrap register the four systems
// §6 Ground one Static, squashed box
// §7 HUD + frame loop
//
// ─── The physics contract, in one paragraph ─────────────────────────────────
//
// A rigid body is three components on one entity:
//
// - `Transform` — pose (position + rotation). The PhysicsSystem WRITES this
// every fixed step; the renderer READS it. Scale is NOT part
// of the simulation — colliders carry their own size.
// - `RigidBody` — the dynamic state that is not pose: mass, velocity, inertia,
// damping, gravity scale, and the Static / Dynamic `kind`.
// - `Collider` — an immutable `*Shape3D` plus its surface material
// (friction, restitution).
//
// `PhysicsSystem` links the (RigidBody, Transform) pair into the solver;
// `ColliderObserverSystem` watches for the Collider on the same entity and
// attaches it to that body. Register both — and register PhysicsSystem first,
// so a body's slot exists before its collider tries to attach to it.
// ─── Imports ────────────────────────────────────────────────────────────────
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 { System } from "@woosh/meep-engine/src/engine/ecs/System.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 { CapsuleGeometry } from "@woosh/meep-engine/src/engine/graphics/geometry/CapsuleGeometry.js";
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 { CapsuleShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/CapsuleShape3D.js";
import { SphereShape3D } from "@woosh/meep-engine/src/core/geom/3d/shape/SphereShape3D.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { randomFloatBetween } from "@woosh/meep-engine/src/core/math/random/randomFloatBetween.js";
import {
AmbientOcclusionPostProcessEffect
} from "@woosh/meep-engine/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js";
// ─── §1 Tuning constants ────────────────────────────────────────────────────
// The ground is a single squashed box. Full side lengths, in metres. The
// footprint is kept only a little wider than the spawn column, so the pile
// quickly reaches the edges and bodies start tumbling off.
const GROUND_WIDTH = 20;
const GROUND_HEIGHT = 1.5;
const GROUND_DEPTH = 20;
// New bodies appear in a square column centred on the origin, between
// SPAWN_Y and SPAWN_Y + SPAWN_Y_JITTER, so they don't all share a height.
const SPAWN_HALF_EXTENT = 9; // < half the ground width, so they land on it
const SPAWN_Y = 20;
const SPAWN_Y_JITTER = 4;
// One body every SPAWN_INTERVAL seconds, capped at MAX_BODIES live at once.
// The cap plus the despawn rule give the scene a steady state: as fast as the
// pile sheds bodies off its edges, new ones drop in to replace them.
const SPAWN_INTERVAL = 0.08;
const MAX_BODIES = 240;
// A body is recycled once it falls this far below the world. The ground's top
// face sits at y = 0, so this is ~10 m of clear air below it — comfortably
// off-screen. Despawning is just `dataset.removeEntity`, which unlinks the
// body from the solver and the collider from the broadphase for free.
const DESPAWN_Y = -10;
// ─── §2 Inertia helpers ─────────────────────────────────────────────────────
// The RigidBody default for `inverseInertiaLocal` is (0,0,0), which LOCKS
// rotation — a body with that inertia slides but never tumbles. To let bodies
// spin we hand the solver the inverse of each shape's principal moments of
// inertia (the diagonal, in the body's local frame). The formulae below are
// the standard solid-body tensors.
function sphereInverseInertia(mass, radius) {
const i = 0.4 * mass * radius * radius; // (2/5) m r²
return new Vector3(1 / i, 1 / i, 1 / i);
}
function boxInverseInertia(mass, width, height, depth) {
const k = mass / 12;
const ix = k * (height * height + depth * depth);
const iy = k * (width * width + depth * depth);
const iz = k * (width * width + height * height);
return new Vector3(1 / ix, 1 / iy, 1 / iz);
}
function capsuleInverseInertia(mass, radius, height) {
// Approximated as a solid cylinder of radius `radius` and length
// `height + 2·radius`. Close enough that the tumble reads correctly; the
// exact capsule tensor is a distraction in a getting-started example.
const length = height + 2 * radius;
const iy = 0.5 * mass * radius * radius;
const ixz = (mass / 12) * (3 * radius * radius + length * length);
return new Vector3(1 / ixz, 1 / iy, 1 / ixz);
}
// ─── §3 Body palette ────────────────────────────────────────────────────────
//
// Every spawned body is drawn from one of these templates. The trick to keeping
// the render cheap is SHARING: a `*Shape3D` is immutable and reused by every
// collider built from it, and a (geometry, material) pair maps to a single draw
// batch in ShadedGeometrySystem no matter how many entities reference it. So a
// few hundred falling bodies cost only a handful of templates' worth of GPU
// state — vary the sizes here, not per-spawn, and batching stays intact. Each
// collider is sized to match its render mesh (`SphereShape3D.from(r)`, etc.).
function makeTemplate({ geometry, shape, mass, inverseInertia, color }) {
const material = new THREE.MeshStandardMaterial({ color, roughness: 0.65, metalness: 0.05 });
return {
shape, mass, inverseInertia, // A fresh ShadedGeometry per entity (it owns per-entity bounds state),
// but both args are shared, so all of a template's bodies batch together.
makeRenderable: () => ShadedGeometry.from(geometry, material),
};
}
function buildPalette() {
const templates = [];
// — Spheres —
templates.push(makeTemplate({
geometry: new THREE.SphereGeometry(1, 24, 16),
shape: SphereShape3D.from(1),
mass: 1.6,
inverseInertia: sphereInverseInertia(1.6, 1),
color: 0x4ef0a8,
}));
// — Boxes (a handful of sizes) —
const boxSizes = [[1.3, 1.3, 1.3], [2.1, 1.0, 1.4], [1.6, 1.6, 0.9],];
const boxColors = [0x5aa9ff, 0x7c8cff, 0x49c7e0];
for (let i = 0; i < boxSizes.length; i++) {
const [w, h, d] = boxSizes[i];
const mass = 0.7 * w * h * d;
templates.push(makeTemplate({
geometry: new THREE.BoxGeometry(w, h, d),
shape: BoxShape3D.from_size(w, h, d),
mass,
inverseInertia: boxInverseInertia(mass, w, h, d),
color: boxColors[i],
}));
}
// — Capsules (radius, cylinder height) —
const capsuleSizes = [[0.6, 1.2], [0.5, 1.8],];
const capsuleColors = [0xffb454, 0xff7a8a];
for (let i = 0; i < capsuleSizes.length; i++) {
const [r, h] = capsuleSizes[i];
const mass = 2.2 * r * r * (h + 1.33 * r); // ≈ density × volume
templates.push(makeTemplate({
// meep's CapsuleGeometry takes the same (radius, height) convention
// as CapsuleShape3D — height is the cylinder section only — so the
// watertight render mesh lines up with the collider exactly.
geometry: new CapsuleGeometry(r, h, 8, 18),
shape: CapsuleShape3D.from(r, h),
mass,
inverseInertia: capsuleInverseInertia(mass, r, h),
color: capsuleColors[i],
}));
}
return templates;
}
// ─── §4 The rain system ─────────────────────────────────────────────────────
//
// `update(dt)` runs once per rendered frame and does two jobs: drop new bodies
// on a timer, and recycle any that have fallen past the despawn floor. It
// queries the dataset directly rather than acting on the linked tuple — but a
// System must still declare at least one dependency, so we name `Transform`
// (its link / unlink hooks stay no-ops; only `update` matters here).
class RainSystem extends System {
dependencies = [Transform];
constructor(templates) {
super();
this.templates = templates;
this._spawnAccumulator = 0;
this._deadScratch = [];
// Read by the HUD.
this.liveBodies = 0;
this.totalSpawned = 0;
}
update(dt) {
const ecd = this.entityManager.dataset;
// — Despawn pass — collect every fallen Dynamic body, then remove. We
// gather ids first and mutate after, so we never delete out from under
// the in-flight traversal. The same loop counts the live population.
const dead = this._deadScratch;
dead.length = 0;
let liveDynamic = 0;
ecd.traverseEntities([RigidBody, Transform], (body, transform, entity) => {
if (body.kind === BodyKind.Static) {
return; // the ground — never counted, never recycled
}
liveDynamic++;
if (transform.position.y < DESPAWN_Y) {
dead.push(entity);
}
});
for (let i = 0; i < dead.length; i++) {
ecd.removeEntity(dead[i]);
}
this.liveBodies = liveDynamic - dead.length;
// — Spawn pass — one body per SPAWN_INTERVAL of elapsed time, while
// we're under the cap. The accumulator keeps the rate frame-rate
// independent without spawning a burst after a stall.
this._spawnAccumulator += dt;
while (this._spawnAccumulator >= SPAWN_INTERVAL) {
this._spawnAccumulator -= SPAWN_INTERVAL;
if (this.liveBodies < MAX_BODIES) {
this._spawnBody(ecd);
this.liveBodies++;
this.totalSpawned++;
}
}
}
_spawnBody(ecd) {
const template = this.templates[(Math.random() * this.templates.length) | 0];
const transform = new Transform();
transform.position.set(randomFloatBetween(Math.random, -SPAWN_HALF_EXTENT, SPAWN_HALF_EXTENT), SPAWN_Y + Math.random() * SPAWN_Y_JITTER, randomFloatBetween(Math.random, -SPAWN_HALF_EXTENT, SPAWN_HALF_EXTENT),);
// Quaternion.random orients the body uniformly in place.
transform.rotation.random();
const body = new RigidBody();
body.kind = BodyKind.Dynamic;
body.mass = template.mass;
// Unlock rotation by giving the body a real inertia tensor.
body.inverseInertiaLocal.copy(template.inverseInertia);
// A touch of damping so piles settle instead of jittering forever.
body.linearDamping = 0.02;
body.angularDamping = 0.05;
// Seed a little spin and horizontal drift so the rain looks alive.
body.angularVelocity.set(randomFloatBetween(Math.random, -3, 3), randomFloatBetween(Math.random, -3, 3), randomFloatBetween(Math.random, -3, 3),);
body.linearVelocity.set(randomFloatBetween(Math.random, -1.5, 1.5), 0, randomFloatBetween(Math.random, -1.5, 1.5),);
const collider = new Collider();
collider.shape = template.shape;
collider.friction = 0.6;
collider.restitution = 0.2; // a small, lively bounce
// RigidBody links the body; the collider is picked up by the observer.
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(template.makeRenderable())
.build(ecd);
}
}
// ─── §5 Engine bootstrap ────────────────────────────────────────────────────
const palette = buildPalette();
const rainSystem = new RainSystem(palette);
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
// Draws anything carrying a ShadedGeometry.
config.addSystem(new ShadedGeometrySystem(engine));
config.addPlugin(AmbientOcclusionPostProcessEffect)
// The rigid-body solver, and the observer that wires colliders onto
// bodies. Order matters: PhysicsSystem must link a body's slot before
// ColliderObserverSystem tries to attach that body's collider.
const physics = new PhysicsSystem();
config.addSystem(physics);
config.addSystem(new ColliderObserverSystem(physics));
// gravity already defaults to (0, -9.81, 0); shown here for the curious:
// physics.setGravity({ x: 0, y: -9.81, z: 0 });
config.addSystem(rainSystem);
},
});
// Camera, sun + ambient lights, and shadow maps so the falling bodies cast onto
// the floor and each other. `cameraController: true` (the default) gives the
// orbital drag-to-look / wheel-to-zoom controls.
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 2048,
focus: new Vector3(0, 1, 0), // Wheel zoom adds ±1 world unit to `distance` per notch, so this sits
// ~20 clicks further back than the in-the-action default of 30 — far
// enough that the whole platform fits in frame.
distance: 50,
pitch: 0.95,
yaw: 0.6,
cameraFarDistance: 200,
showFps: false,
});
const ecd = engine.entityManager.dataset;
// ─── §6 Ground ──────────────────────────────────────────────────────────────
//
// A single Static body: a squashed box. Static bodies have effectively infinite
// mass and never integrate, so they need no inertia — they just sit in the
// broadphase as something for the Dynamic bodies to land on. We sink it so its
// TOP face lands exactly at y = 0.
{
const groundShape = BoxShape3D.from_size(GROUND_WIDTH, GROUND_HEIGHT, GROUND_DEPTH);
const transform = new Transform();
transform.position.set(0, -GROUND_HEIGHT / 2, 0);
const body = new RigidBody();
body.kind = BodyKind.Static;
const collider = new Collider();
collider.shape = groundShape;
collider.friction = 0.8;
collider.restitution = 0.1;
const groundGeometry = new THREE.BoxGeometry(GROUND_WIDTH, GROUND_HEIGHT, GROUND_DEPTH);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x2a3340, roughness: 0.95, metalness: 0 });
new Entity()
.add(transform)
.add(body)
.add(collider)
.add(ShadedGeometry.from(groundGeometry, groundMaterial))
.build(ecd);
}
// ─── §7 HUD + frame loop ────────────────────────────────────────────────────
const fpsEl = document.getElementById("fps");
const liveEl = document.getElementById("live");
const totalEl = document.getElementById("total");
let fpsWindow = 0;
let fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = (nowMs - lastFrameMs) / 1000;
lastFrameMs = nowMs;
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
liveEl.textContent = String(rainSystem.liveBodies);
totalEl.textContent = rainSystem.totalSpawned.toLocaleString();
}
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Raining rigid bodies · 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: 420px;
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">live</span><span class="value" id="live">0</span></div>
<div><span class="label">spawned</span><span class="value" id="total">0</span></div>
</div>
<div class="panel legend">
<strong>Spheres, capsules and boxes</strong> rain onto a squashed-box floor
under the rigid-body <code>PhysicsSystem</code>, pile up, and are recycled
once they tumble off the edge. Drag to orbit · scroll to zoom.
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>
{
"title": "Raining rigid bodies",
"description": "Spheres, capsules and boxes rain onto a squashed-box floor under the rigid-body PhysicsSystem, pile up, and are recycled once they tumble off the edge.",
"category": "Physics",
"status": "live",
"order": 1,
"tags": ["physics", "rigid-body", "collision", "ecs", "shadows"],
"sourceHint": "examples-src/physics-rain/",
"demoUrl": "/examples/physics-rain/demo.html",
"defaultFile": "src/main.js"
}
{
"name": "@meep-examples/physics-rain",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Rigid bodies — spheres, capsules and boxes — raining onto a squashed-box ground via the PhysicsSystem.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@woosh/meep-engine": "2.146.0",
"three": "0.136.0"
},
"devDependencies": {
"@rollup/plugin-strip": "^3.0.4",
"vite": "^8.0.13"
}
}
# physics-rain
Spheres, capsules and boxes rain onto a squashed-box floor under Meep's
rigid-body `PhysicsSystem`. They pile up, tumble off the edges, and are recycled
once they fall past a despawn floor below the world — a steady, self-renewing
stream of a few hundred bodies.
## Run locally
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
Output goes to `../../public/examples/physics-rain/demo.html`.
## What this demonstrates
- **`PhysicsSystem`** — the rigid-body solver. Register it, register
`ColliderObserverSystem` (which attaches colliders to bodies), and bodies
start falling. `PhysicsSystem` writes each body's pose back onto its
`Transform` every fixed step; the renderer reads the same `Transform`.
- **The three-component body** — every body is `Transform` (pose) + `RigidBody`
(mass, velocity, inertia, `Static`/`Dynamic` kind) + `Collider` (an immutable
`*Shape3D` plus friction / restitution) on one entity.
- **`BodyKind.Static` vs `BodyKind.Dynamic`** — the ground is one Static box
with infinite mass that never integrates; everything raining on it is Dynamic.
- **Collision shapes** — `SphereShape3D.from(r)`, `BoxShape3D.from_size(w, h, d)`
and `CapsuleShape3D.from(radius, height)`. Sizes live on the *shape*, not the
`Transform` (the simulation ignores `Transform.scale`).
- **Unlocking rotation** — a body's default `inverseInertiaLocal` is `(0,0,0)`,
which locks rotation. The example computes a real inertia tensor per shape so
bodies actually tumble.
- **Spawning + despawning at runtime** — a small `System` subclass drops a body
on a timer (rate-capped, frame-rate independent) and recycles fallen bodies
with `dataset.removeEntity`, which unlinks them from the solver and broadphase.
- **Batching-friendly authoring** — collider shapes and `(geometry, material)`
pairs are shared across all bodies of a template, so a few hundred bodies cost
only a handful of draw batches.
- **Shadows** — `EngineHarness.buildBasics({ enableShadows: true })`; every
`ShadedGeometry` casts and receives by default.
import { defineConfig } from "vite";
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({
base: "./",
build: {
outDir: resolve(__dirname, "../../public/examples/physics-rain"),
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, "demo.html"),
plugins: [
{
// this will remove all assert statements from the production build
...strip(),
apply: 'build'
}
],
},
target: "es2022",
},
});