Skip to content

Physics & particles

View .mdllms.txt

framefields includes deterministic rigid-body physics powered by Rapier (compiled to WebAssembly) and high-density GPU particle systems simulated in WebGPU compute shaders.

Physics simulates while the composition is built (or steps frame-by-frame in live mode) and replays as per-frame signals. Frame order does not affect the outcome: frame 80 always renders identically on every machine.

import { Composition, Layer, LayerAnimation, Physics } from "framefields";
const W = 1920, H = 1080, FPS = 30;
const comp = new Composition({ width: W, height: H, fps: FPS, durationFrames: 120, backgroundColor: "#0b0c10" });
const world = await Physics.world2d({ fps: FPS, frames: 120, gravity: 2400 });
world.walls({ width: W, height: H });
const cardLayer = Layer.box({
width: 320, height: 200, background: "#1f2937", borderRadius: 16,
position: "absolute", x: W / 2 - 160, y: 100,
});
const card = world.body(cardLayer, { restitution: 0.4, friction: 0.6 });
world.simulate();
// Flash on impact
const flash = Layer.box({ width: W, height: H, background: "#ffffff", opacity: 0 });
flash.animate(LayerAnimation.create().signal("opacity", card.impact, { multiplier: 0.8 }));
comp.add([flash, cardLayer]);

world.body(target, options) creates a body from any layer with an absolute position (x, y) and size, or from a plain rectangle { x, y, width, height } (useful for invisible funnels and deflectors).

world.simulate() runs the simulation once and automatically binds each body’s layer x, y, and rotation to the simulated signals.

By default (shape: "auto"), the collider is inferred from the layer:

Layer type Inferred collider
Layer.shape("circle", ...) Circle
Layer.box with borderRadius Rounded box matching corner radius
`Layer.shape(“polygon” “star”
Layer.text without explicit size Box measured automatically with FontManager
Layer.box / Layer.image / Layer.video Rectangular box

Force a specific shape with shape: "box" | "circle" | "hull" or override dimensions with size: [width, height].

Option Type Default Description
type `“dynamic” “fixed” “kinematic”`
restitution number 0.3 Bounciness, 0–1
friction number 0.6 Surface friction, 0–1
density number 1.0 Mass per unit area. Heavier bodies push lighter ones
velocity [vx, vy] [0, 0] Initial launch velocity in px/s
angularVelocity number 0 Initial spin in degrees/second
linearDamping number 0 Air resistance per second
angularDamping number 0 Spin resistance per second
gravityScale number 1.0 Multiplier for world gravity (0 makes body float)
lockRotation boolean false Disables rotation so the body remains upright
ccd boolean false Continuous collision detection for thin, fast-moving bodies
sensor boolean false Passes through bodies without collision while still reporting contacts
startFrame number 0 Simulation frame the body wakes up; holds start pose before
follow { x?, y?, rotation? } — Kinematic driver: position and rotation each frame
output `“signals” “keyframes” “none”`

Every body produces frame-rate signals sampled at composition time:

card.x; // layer top-left X every frame
card.y; // layer top-left Y every frame
card.rotation; // continuous degrees (no 360 wrap jump)
card.centerX; // body center X in px
card.centerY; // body center Y in px
card.vx; // velocity X in px/s
card.vy; // velocity Y in px/s
card.speed; // speed hypot(vx, vy) in px/s
card.angularVelocity; // degrees/s
card.sleeping; // 1 when settled, 0 when moving
card.impact; // exponential decay envelope on collision

body.impact jumps whenever the body suffers a collision and decays over impactDecay frames (default 8 frames). A 1500 px/s change in velocity produces an impact level of 1.0.

world.impact produces the loudest impact across all bodies on each frame — perfect for triggering camera shake, lens flares, or audio whooshes.

world.collisions and body.collisions return an array of first contact events:

for (const contact of world.collisions) {
// contact: { frame, body, with, speed }
console.log(`Hit at frame ${contact.frame} with speed ${contact.speed} px/s`);
}

Use world.collisions to place impact sound effects (renderSfx({ type: "impact", ... })) or particle explosions precisely on contact frames.

Create boundaries with built-in helpers:

// Ground floor sitting at y: 1000
world.ground({ y: 1000, friction: 0.8, restitution: 0.2 });
// Surrounding walls around the canvas box
world.walls({ width: 1920, height: 1080, thickness: 400, ceiling: true });

Connect bodies with joints:

// Pin to swing around a world point (default: body center)
world.pin(signpost, [960, 200]);
// Hinge two bodies together at a world pivot
world.hinge(bodyA, bodyB, [pivotX, pivotY]);
// Weld bodies rigidly together
world.weld(bodyParent, bodyChild);
// Damped spring between two bodies or body and point
world.spring(pendulum, [960, 100], { frequency: 2, dampingRatio: 0.2, length: 300 });
// Distance rope (slack when closer, taut at max length)
world.rope(weight, [960, 100], { length: 450 });

Beyond gravity and collisions, worlds can be influenced by forces, impulses, and spatial fields:

// Steady acceleration (px/s², mass-independent)
world.force(balloon, { y: -1200 }); // buoyant lift
// Signal-driven acceleration (e.g. wind gusts from audio)
world.force(banner, { x: audioSignal });
// Instant velocity kick at a specific frame
world.impulse(ball, 30, [400, -800]); // kick at frame 30

world.field(callback, options) is called every solver step for each awake body. The callback receives a FieldSample2D ({ x, y, vx, vy, frame, body }) and returns an acceleration [ax, ay] in px/s²:

// Radial vortex pulling bodies into center
world.field((s) => {
const dx = 960 - s.x;
const dy = 540 - s.y;
const dist = Math.hypot(dx, dy);
if (dist < 50 || dist > 600) return null;
const pull = 1500 / dist;
// Tangential swirl + inward pull
return [-dy * pull + (dx / dist) * 200, dx * pull + (dy / dist) * 200];
});

Physics.world3d simulates rigid bodies in 3D for Layer3D primitives (roundedBox, sphere, cylinder), extruded text, and 3D models (Layer.glb, convex hull):

import { Layer3D, Physics } from "framefields";
const world = await Physics.world3d({ fps: 30, frames: 150, gravity: 2600 });
world.ground({ y: 1000 });
const box = Layer3D.roundedBox({
width: 240, height: 160, depth: 32, radius: 12,
x: 960, y: 200, z: 0,
material: "glass",
});
const body = world.body(box, {
restitution: 0.3,
// Lock rotation to keep cards facing the camera while spinning in the image plane
lockRotation: { x: true, y: true, z: false },
});
world.simulate();

Without a camera layer, the 3D scene’s z = 0 plane maps 1:1 onto canvas pixels. Points from 2D video tracks or layout line up directly in the 3D simulation.

3D bodies expose x, y, z, rotateX, rotateY, rotateZ, vx, vy, vz, speed, and impact signals.

Normally, world.simulate() computes the entire timeline up front when the composition is constructed.

When bodies interact with performers tracked via comp.vision(clip), use world.live(comp) instead. Live mode advances the physics simulation frame-by-frame as each composition frame is requested:

const comp = new Composition({ width: W, height: H, fps: 30, durationFrames: 180 });
const dancer = comp.vision("assets/dancer.mp4");
const person = dancer.person.place({ width: W, height: H, fit: "cover" });
const world = await Physics.world3d({ fps: 30, frames: 180, gravity: 2400 });
world.ground({ y: H });
// Kinematic sphere colliders following dancer's wrists
world.body({ x: 0, y: 0, z: 0, width: 60, height: 60, depth: 60 }, {
type: "kinematic",
shape: "sphere",
follow: {
x: (f) => person.point("rightWrist", f).x,
y: (f) => person.point("rightWrist", f).y,
z: 0,
},
});
// Live mode: steps as frames render; preview starts instantly without up-front delay
world.live(comp);

The tracked performer’s silhouette can deflect rigid bodies using person.sample(frame, x, y):

world.field((s) => {
const sample = person.sample(s.frame, s.x, s.y);
if (sample.alpha <= 0.1) return null;
// Push outwards along the negative normal gradient
return [-sample.gx * 8000, -sample.gy * 8000];
});

To export plain keyframe animations (inspectable in JSON or usable without the live physics runtime), use bakeKeyframes instead of simulate:

world.bakeKeyframes({ tolerance: 0.25 });

This applies Ramer–Douglas–Peucker trajectory simplification across each channel, leaving the minimum linear keyframes that stay within tolerance pixels of the simulation.


Layer.particles simulates tens of thousands of particles in a WebGPU compute shader and draws them in a single instanced render call. Positions inside the particle layer are local layer pixels.

comp.add(Layer.particles({
width: W, height: H,
max: 20000,
seed: 42,
emitter: {
shape: "rect",
x: 0, y: H * 0.6,
width: W, height: 200,
rate: 3000,
},
lifetime: [4, 6],
speed: [20, 80],
direction: -90, spread: 180,
gravity: 30,
drag: 1.2,
noise: { strength: 240, scale: 160, speed: 0.3 }, // divergence-free curl noise
attractors: [{ x: W / 2, y: H / 2, strength: 1200, radius: 300 }],
floor: H - 40, bounce: 0.3,
size: [10, 2], sizeJitter: 0.4,
shape: "soft",
color: ["#ffdd00", "#ff5a1f", "#4a1205"],
blend: "add",
prewarm: 3, // starts already full
}));

Emitter position (emitter.x, emitter.y), emission rate, gravity, drag, noise.strength, attractor positions, and push forces can be driven by a static number, a signal, or a per-frame function (frame: number) => number.

This allows responsive particle effects:

  • Light painting: Move a point emitter with a tracked hand (f) => person.point("rightWrist", f).x. Births automatically interpolate along the path between frames to avoid gaps.
  • Audio bursts: Modulate emitter.rate with an audio drum signal.
  • Explosions: Trigger instant bursts at specific frames with emitter.bursts: [{ frame: 30, count: 5000 }].

Connect particles directly to tracked performers using push:

const dancer = comp.vision("assets/dancer.mp4");
const person = dancer.person.place({ width: W, height: H, fit: "cover" });
comp.add(Layer.particles({
width: W, height: H,
push: {
person,
strength: 9000,
sweep: 120000, // extra force imparted by fast-sweeping limbs
},
// ...
}));

Layer a video cutout of the same clip (Layer.video(src).withVision({ mode: "matte" })) above the particles so they appear behind the performer while being swept aside.

Prop Type Default Description
max number 20000 Maximum particle capacity. Only uses GPU memory, no cost when idle
emitter.shape `“rect” “point” “line”
emitter.rate PhysicsDriver 1000 Particles emitted per second
emitter.bursts Array<{ frame, count }> [] Instant particle bursts at specific frames
lifetime `number [min, max]` [2, 4]
speed `number [min, max]` [40, 160]
direction number -90 Launch direction angle in degrees (0 = right, -90 = up)
spread number 360 Angular spread around direction in degrees
gravity `PhysicsDriver [gx, gy]` 0
drag PhysicsDriver 0.4 Velocity damping per second
noise { strength?, scale?, speed? } — Curl noise flow for turbulent smoke and fluid drifts
attractors Array<{ x, y, strength, radius? }> [] Points that pull (positive strength) or repel (negative strength)
floor number — Floor line in layer px where particles bounce
bounce number 0.3 Bounciness at floor and bouncing bounds
bounds `“none” “kill” “bounce”
shape `“circle” “soft” “square”
blend `“normal” “add” “screen”`
color `string string[]` "#ffffff"
colorBySpeed number — When set, maps color gradient according to speed (px/s to reach final color)
prewarm number 0 Seconds simulated before frame 0 so the scene starts in mid-flight

The examples/28_physics_dancer reference film demonstrates 3D rigid-body physics, live vision tracking, and GPU compute particles working together in a single shot:

  • 3D glass cards: Layer3D.roundedBox({ material: "glass" }) cards thrown in on ballistic arcs that bounce off the performer.
  • Kinematic performer tracking: Kinematic sphere colliders follow arm and head keypoints from comp.vision(clip).person.
  • Performer particle push: 24,000 embers simulated with Layer.particles that part around the dancer’s silk dress via push: { person }.
  • Live execution: world.live(comp) advances physics as each frame renders, starting the preview immediately.

Watch the render on YouTube.