Skip to content

Embers Physics

Stopped
The complete example source and integration notes are available below.

Read as Markdown

Full-page experience · Open playground

Opt into local gravity, wind, and real collider bounces for ember transitions.

Burn or materialize, reverse mid-flight, and compare with physics off.

  • Connect a transition’s particles to batched WASM collision sweeps.
  • Preserve airborne motion through reversal and the effect tail.
  • Particle collisions are one-way point sweeps; obstacles are not pushed.
  • The world and transition receiver share local coordinates.

This is a Pibbl Canvas example. Its mount owns its runtime resources; call its controller’s dispose method when removing it. The preview starts automatically unless reduced motion is enabled; Stop disposes the mount. Preserve the source’s logical canvas size and aspect ratio. Native HTML is not captured by Canvas filters.

Host setup for this source: use a canvas with width 800 and height 600, compile with jsx: "react-jsx" and jsxImportSource: "@pibbl/core", import its default mount, and call const controller = mount(canvas) after attaching the canvas. Call controller.dispose() before removing it.

import {
pibbl,
Group,
Rectangle,
Circle,
Text,
embers,
useVisibilityTransition,
useSignal,
useConst,
type PibblController,
type PibblVisibilityTransition,
} from "@pibbl/core";
import {
PhysicsWorld2D,
pibblPhysicsWorld2D,
staticBody2D,
physicsObstacles2D,
} from "@pibbl/core/physics/2d";
function Chamber({
enabled,
register,
}: {
enabled: boolean;
register: (handle: PibblVisibilityTransition) => void;
}) {
const world = pibblPhysicsWorld2D();
const effect = useConst(() =>
embers({
region: { x: 180, y: 35, width: 440, height: 110 },
seed: 9,
linger: 1800,
wind: 65,
physics: enabled
? {
gravity: [0, 650],
wind: [55, 0],
drag: 2.5,
restitution: 0.72,
obstacles: physicsObstacles2D(world),
}
: undefined,
}),
);
const transition = useVisibilityTransition({ enter: effect, duration: 900 });
register(transition);
return (
<Group>
<Rectangle
style={{
left: 25,
top: 5,
width: 750,
height: 370,
cornerRadius: 16,
fill: "#101f2d",
}}
/>
<Group style={{ transition: transition.binding }}>
<Rectangle
style={{
left: 180,
top: 35,
width: 440,
height: 110,
cornerRadius: 14,
fill: "#32677b",
}}
/>
<Text
style={{
left: 400,
top: 85,
textAlign: "center",
textBaseline: "middle",
font: "600 30px sans-serif",
fill: "#fff2d6",
}}
>
FROM FIRE TO ASH
</Text>
<Text
style={{
left: 400,
top: 116,
textAlign: "center",
font: "14px sans-serif",
fill: "#badbdc",
}}
>
Ordinary Canvas content. Physical fragments.
</Text>
</Group>
<Circle
physics={staticBody2D()}
style={{
cx: 440,
cy: 260,
radius: 46,
fill: "#305068",
stroke: "#81acbc",
strokeWidth: 2,
}}
/>
<Rectangle
physics={staticBody2D()}
style={{
left: 140,
top: 235,
width: 160,
height: 18,
cornerRadius: 3,
fill: "#305068",
stroke: "#81acbc",
strokeWidth: 2,
}}
/>
<Rectangle
physics={staticBody2D()}
style={{ left: 40, top: 350, width: 720, height: 15, fill: "#466273" }}
/>
<Rectangle
physics={staticBody2D()}
style={{ left: 25, top: 10, width: 15, height: 355, fill: "#263f51" }}
/>
<Rectangle
physics={staticBody2D()}
style={{ left: 760, top: 10, width: 15, height: 355, fill: "#263f51" }}
/>
<Text
style={{ left: 70, top: 333, font: "12px monospace", fill: "#86a7b4" }}
>
LOCAL COLLIDERS · GRAVITY · WIND · BOUNCE
</Text>
</Group>
);
}
function Demo() {
const enabled = useSignal(true),
status = useSignal("Burn it away, then watch the fragments bounce.");
let handle: PibblVisibilityTransition | undefined;
const run = async () => {
if (!handle) return;
const current = handle;
status.set(current.visible.get() ? "Burning away…" : "Materializing…");
const result = await (current.visible.get()
? current.hide()
: current.show());
if (result.status === "finished")
status.set("Settled. Toggle again, or compare with physics off.");
};
return (
<Group>
<Rectangle style={{ width: 800, height: 600, fill: "#091722" }} />
<Text
style={{ left: 36, top: 42, font: "12px monospace", fill: "#77cabc" }}
>
EMBERS / LOCAL PHYSICS
</Text>
<Text
style={{
left: 36,
top: 84,
font: "600 30px sans-serif",
fill: "#f4f3e9",
}}
>
A little fire. A little gravity.
</Text>
<Text
style={{ left: 36, top: 113, font: "14px sans-serif", fill: "#96b4c1" }}
>
Fragments collide with the shelf, sphere, and floor. Click mid-flight to
reverse.
</Text>
<Group style={{ translateY: 130 }}>
<PhysicsWorld2D
key={String(enabled.get())}
space="local"
gravity={[0, 650]}
>
<Chamber
enabled={enabled.get()}
register={(value) => {
handle = value;
}}
/>
</PhysicsWorld2D>
</Group>
<Rectangle
style={{
left: 36,
top: 520,
width: 220,
height: 42,
cornerRadius: 7,
fill: "#d48439",
cursor: "pointer",
}}
onClick={() => {
void run();
}}
/>
<Text
pointerEvents="none"
style={{
left: 146,
top: 541,
textAlign: "center",
textBaseline: "middle",
font: "600 14px sans-serif",
fill: "#101e29",
}}
>
BURN / MATERIALIZE
</Text>
<Rectangle
style={{
left: 275,
top: 520,
width: 210,
height: 42,
cornerRadius: 7,
fill: "#294454",
cursor: "pointer",
}}
onClick={() => {
enabled.update((value) => !value);
status.set("Fresh subject. Toggle to compare the motion.");
}}
/>
<Text
pointerEvents="none"
style={{
left: 380,
top: 541,
textAlign: "center",
textBaseline: "middle",
font: "600 14px sans-serif",
fill: "#e8f6f0",
}}
>
PHYSICS: {enabled.get() ? "ON" : "OFF"}
</Text>
<Text
style={{ left: 36, top: 588, font: "13px monospace", fill: "#8be3c7" }}
>
{status.get()}
</Text>
</Group>
);
}
export default function mount(canvas: HTMLCanvasElement): PibblController {
return pibbl(canvas, <Demo />);
}

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.