Skip to content

Fire and Flow

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

Read as Markdown

Full-page experience · Open playground

Animated fire and buoyant smoke flowing around shapes you can move, resize, and rotate.

Drag a shape, use its resize and rotation handles, then click empty space to deselect.

  • Share a flow field between smoke particles and solid obstacles.
  • Manipulate solid shapes through decorative particles.
  • Flow sources drive velocity and heat independently of particle emission.
  • The obstacle source supplies packed committed geometry; particles are not rigid bodies.

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 720 and height 480, 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 {
PhysicsWorld2D,
pibblPhysicsWorld2D,
physicsObstacles2D,
kinematicBody2D,
collider2D,
} from "@pibbl/core/physics/2d";
import {
Group,
Rectangle,
Ellipse,
Polygon,
Text,
pibbl,
definePrimitive,
useConst,
useComputed,
usePlayback,
useSignal,
useManipulation,
ManipulationFrame,
DragHandle,
ResizeHandles,
RotationHandle,
type WritableSignal,
type PibblManipulationBox,
repeat,
sequence,
wait,
marker,
type PibblController,
} from "@pibbl/core";
import {
Particles2D,
defineParticleEffect2D,
particleCurve,
particleRange,
useParticleSystem,
useFlowField2D,
useFlowSource2D,
type PibblParticleEmitter2D,
} from "@pibbl/core/particles";
// Original procedural artwork; buffers live with this mounted example.
function smokeSprite(variant: number): OffscreenCanvas {
const canvas = new OffscreenCanvas(96, 96);
const context = canvas.getContext("2d")!;
// Overlapping shaded lobes give each soft sprite internal volume, rather
// than a single bright dot. Three silhouettes keep the plume irregular.
for (let i = 0; i < 7; i++) {
const angle = i * 2.4 + variant;
const x = 48 + Math.cos(angle) * (10 + (i % 3) * 4),
y = 48 + Math.sin(angle) * 13;
const radius = 22 + ((i + variant) % 3) * 3;
const body = context.createRadialGradient(x - 5, y - 7, 0, x, y, radius);
body.addColorStop(0, "rgba(193,204,211,0.52)");
body.addColorStop(0.28, "rgba(156,171,183,0.48)");
body.addColorStop(0.6, "rgba(84,102,119,0.34)");
body.addColorStop(0.82, "rgba(61,78,94,0.14)");
body.addColorStop(1, "rgba(61,78,94,0)");
context.fillStyle = body;
context.fillRect(0, 0, 96, 96);
}
return canvas;
}
function createFlameSurface() {
const canvas = new OffscreenCanvas(112, 160);
const context = canvas.getContext("2d")!;
const pixels = context.createImageData(canvas.width, canvas.height);
const lattice = new Float32Array(4096);
let seed = 7919;
for (let i = 0; i < lattice.length; i++) {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
lattice[i] = seed / 4294967296;
}
const noise = (x: number, y: number) => {
const ix = Math.floor(x),
iy = Math.floor(y);
let fx = x - ix,
fy = y - iy;
fx = fx * fx * (3 - 2 * fx);
fy = fy * fy * (3 - 2 * fy);
const a = lattice[((iy & 63) << 6) | (ix & 63)];
const b = lattice[((iy & 63) << 6) | ((ix + 1) & 63)];
const c = lattice[(((iy + 1) & 63) << 6) | (ix & 63)];
const d = lattice[(((iy + 1) & 63) << 6) | ((ix + 1) & 63)];
return a + (b - a) * fx + (c - a + (a - b - c + d) * fx) * fy;
};
let lastTime = -1,
lastIntensity = -1;
return {
canvas,
update(time: number, intensity: number) {
if (time === lastTime && intensity === lastIntensity)
return;
lastTime = time;
lastIntensity = intensity;
for (let y = 0; y < canvas.height; y++) {
const height = 1 - y / (canvas.height - 1);
for (let x = 0; x < canvas.width; x++) {
const u = (x / (canvas.width - 1) - 0.5) * 2;
// Height increases upward; subtract time so the noise rises with it.
const lift = height * 5 - time * 2.4;
const curl = noise(u * 2.4 + 8, lift * 0.65) - 0.5;
const bent =
u - curl * height * 0.75;
const grain =
noise(bent * 5 + 20, lift) * 0.62 +
noise(bent * 11 + 40, lift * 1.8) * 0.26 +
noise(bent * 23 + 30, lift * 3.5) * 0.12;
const taper = 0.5 * Math.pow(1 - height, 0.65);
const envelope = 1 - Math.abs(bent) / Math.max(0.025, taper);
const density = Math.max(
0,
Math.min(
1,
(envelope * 0.65 + grain * 0.9 - height * 0.7 - 0.2) * 3.4,
),
);
const hot = Math.min(1, density * 0.76 + (1 - height) * 0.27);
const offset = (y * canvas.width + x) * 4;
pixels.data[offset] = 255;
pixels.data[offset + 1] = Math.round(55 + 196 * hot * hot);
pixels.data[offset + 2] = Math.round(8 + 170 * Math.pow(hot, 5));
pixels.data[offset + 3] = Math.round(
255 * density * Math.min(1, (1 - height) * 14),
);
}
}
context.putImageData(pixels, 0, 0);
},
};
}
// A small effect primitive, composed in normal Pibbl paint order. The shared Pibbl
// clock changes time; rendering reuses one raster, with no separate frame loop.
const Flame = definePrimitive<{
surface: ReturnType<typeof createFlameSurface>;
time: number;
intensity: number;
x: number;
y: number;
pointerEvents: "none";
}>((props, _style, context) => {
props.surface.update(props.time, props.intensity);
const height = 100 + props.intensity * 45;
context.drawImage(
props.surface.canvas,
props.x - 66,
props.y - height + 6,
132,
height,
);
});
const clockProgram = repeat(sequence(wait(40), marker("emit")), {
iterations: Infinity,
});
function Obstacle({
id,
kind,
initial,
selected,
}: {
id: string;
kind: "rectangle" | "ellipse" | "triangle";
initial: PibblManipulationBox;
selected: WritableSignal<string | null>;
}) {
const box = useSignal(initial);
const rotation = useSignal(0);
const controller = useManipulation({
box,
rotation,
move: { bounds: { left: 30, top: 92, width: 660, height: 338 } },
resize: { minWidth: 36, minHeight: 30, maxWidth: 250, maxHeight: 200 },
rotate: {},
onStart: () => selected.set(id),
});
const position = useComputed(() => {
const b = box.get(),
angle = rotation.get(),
c = Math.cos(angle),
sn = Math.sin(angle);
return [
b.left + b.width / 2 - (c * b.width) / 2 + (sn * b.height) / 2,
b.top + b.height / 2 - (sn * b.width) / 2 - (c * b.height) / 2,
] as const;
});
const angleDegrees = useComputed(() => (rotation.get() * 180) / Math.PI);
const { width, height } = box.get();
const active = selected.get() === id;
const paint = {
fill:
kind === "ellipse"
? "#657e9b"
: kind === "triangle"
? "#997254"
: "#397b83",
stroke: active ? "#f2d6a1" : "#b3c7c755",
strokeWidth: active ? 2 : 1,
cursor: "grab" as const,
};
const select = () => selected.set(id);
return (
<ManipulationFrame {...controller.frameProps}>
<DragHandle controller={controller}>
<Group
physics={kinematicBody2D({
collider: false,
target: { position, rotationDegrees: angleDegrees },
})}
>
{kind === "rectangle" ? (
<Rectangle
keyboardFocusable
onPointerDown={select}
physics={collider2D()}
style={{
width,
height,
fill: paint.fill,
stroke: paint.stroke,
strokeWidth: paint.strokeWidth,
cursor: paint.cursor,
}}
/>
) : kind === "ellipse" ? (
<Ellipse
keyboardFocusable
onPointerDown={select}
physics={collider2D()}
style={{
cx: width / 2,
cy: height / 2,
radiusX: width / 2,
radiusY: height / 2,
fill: paint.fill,
stroke: paint.stroke,
strokeWidth: paint.strokeWidth,
cursor: paint.cursor,
}}
/>
) : (
<Polygon
keyboardFocusable
onPointerDown={select}
physics={collider2D()}
style={{
coords: [
[width / 2, 0],
[width, height],
[0, height],
],
fill: paint.fill,
stroke: paint.stroke,
strokeWidth: paint.strokeWidth,
cursor: paint.cursor,
}}
/>
)}
</Group>
</DragHandle>
{active && <ResizeHandles controller={controller} />}
{active && (
<RotationHandle
controller={controller}
style={{ arc: true, readout: true }}
/>
)}
</ManipulationFrame>
);
}
function FireAndFlow() {
const burning = useSignal(true);
const intensity = useSignal(0.8);
const origin = useSignal<readonly [number, number]>([360, 370]);
const drag = useSignal<number | null>(null);
const fuelGesture = useConst(() => ({ x: 0, y: 0, moved: false }));
const selected = useSignal<string | null>(null);
const obstacles = physicsObstacles2D(pibblPhysicsWorld2D());
const flow = useFlowField2D({
bounds: { width: 720, height: 480 },
resolution: [96, 64],
obstacles,
});
const force = useComputed(
() => [0, -180 * intensity.get()] as const,
);
const heat = useComputed(() => 4 * intensity.get());
useFlowSource2D(flow, {
position: origin,
radius: 40,
acceleration: force,
heat,
enabled: burning,
});
const effects = useConst(() => {
const smokeEmitter = (variant: number): PibblParticleEmitter2D => ({
bounds: { min: [-720, -480], max: [720, 480] },
name: `smoke${variant}`,
capacity: 360,
overflow: "recycle-oldest" as const,
emission: [{ type: "manual" as const, count: 1 }],
shape: {
type: "line" as const,
from: [-18, -30] as const,
to: [18, -30] as const,
},
initial: {
lifetimeMilliseconds: particleRange(6500, 9000),
rotationDegrees: particleRange(0, 360),
},
appearance: {
size: particleCurve([
[0, 20 + variant * 3],
[0.25, 44 + variant * 5],
[1, 85 + variant * 8],
]),
opacity: particleCurve([
[0, 0],
[0.08, 0.32],
[0.6, 0.2],
[1, 0],
]),
},
renderer: { type: "sprite" as const, source: smokeSprite(variant) },
});
return {
smoke: defineParticleEffect2D({
emitters: [smokeEmitter(0), smokeEmitter(1), smokeEmitter(2)],
}),
fire: defineParticleEffect2D({
emitters: [
{
name: "embers",
capacity: 80,
overflow: "recycle-oldest",
emission: [{ type: "manual", count: 1 }],
shape: { type: "line", from: [-22, -10], to: [22, -10] },
initial: {
lifetimeMilliseconds: particleRange(1300, 2600),
velocity: [particleRange(-30, 30), particleRange(-160, -90)],
color: "#ffb85c",
size: [2, 4],
},
motion: [{ type: "sway", amplitude: 8, frequency: [0.3, 0.8] }],
appearance: {
opacity: particleCurve([
[0, 0],
[0.1, 1],
[0.55, 0.6],
[1, 0],
]),
},
renderer: { type: "rectangle", blend: "additive" },
},
],
}),
};
});
const smoke = useParticleSystem(effects.smoke, { flow, seed: 42 });
const fire = useParticleSystem(effects.fire, { seed: 74 });
const tick = useConst(() => ({ value: 0 }));
const flameSurface = useConst(createFlameSurface);
const flameTime = useSignal(0);
usePlayback(clockProgram, {
autoplay: true,
onEvent: (event) => {
if (event.type !== "marker") return;
if (!burning.get()) return;
const position = origin.get();
flameTime.update((time) => time + 0.04);
tick.value++;
if (tick.value % 4 === 0) fire.emit("embers", { position });
smoke.emit(`smoke${tick.value % 3}`, {
position,
count: Math.ceil(3 * intensity.get()),
});
},
});
const [x, y] = origin.get();
return (
<Group
onKeyDown={(event) => {
if (event.key === "Escape") selected.set(null);
}}
>
<Rectangle
onPointerDown={() => selected.set(null)}
style={{ width: 720, height: 480, fill: "#111820" }}
/>
<Rectangle
pointerEvents="none"
style={{
left: 24,
top: 80,
width: 672,
height: 360,
fill: "#18232d",
stroke: "#2d3b47",
}}
/>
<Text
pointerEvents="none"
style={{
left: 32,
top: 34,
fill: "#f4e9d5",
font: "700 24px sans-serif",
}}
>
FIRE & FLOW
</Text>
<Text
pointerEvents="none"
style={{ left: 32, top: 59, fill: "#9eafbd", font: "13px sans-serif" }}
>
Drag a shape. Select to resize or rotate. Click empty space to deselect.
</Text>
<Rectangle
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drag.set(e.pointerId);
fuelGesture.x = e.x;
fuelGesture.y = e.y;
fuelGesture.moved = false;
}}
onPointerMove={(e) => {
if (drag.get() === e.pointerId) {
fuelGesture.moved ||=
Math.hypot(e.x - fuelGesture.x, e.y - fuelGesture.y) > 3;
if (fuelGesture.moved)
origin.set([
Math.max(90, Math.min(630, e.x)),
Math.max(320, Math.min(395, e.y)),
]);
}
}}
onPointerUp={(e) => {
if (drag.get() === e.pointerId) {
e.currentTarget.releasePointerCapture(e.pointerId);
drag.set(null);
}
}}
onPointerCancel={() => drag.set(null)}
onClick={() => {
if (!fuelGesture.moved) burning.update((v) => !v);
}}
style={{
left: x - 40,
top: y,
width: 80,
height: 54,
fill: burning.get() ? "#954526" : "#4d5660",
stroke: "#c78958",
cursor: drag.get() === null ? "grab" : "grabbing",
}}
/>
<Particles2D
system={smoke}
pointerEvents="none"
style={{ width: 720, height: 480 }}
/>
{burning.get() && (
<Flame
surface={flameSurface}
time={flameTime.get()}
intensity={intensity.get()}
x={x}
y={y}
pointerEvents="none"
/>
)}
<Particles2D
system={fire}
pointerEvents="none"
style={{ width: 720, height: 480 }}
/>
<Rectangle
pointerEvents="none"
style={{
left: x - 45,
top: y + 39,
width: 90,
height: 15,
fill: "#39434b",
stroke: "#76808a",
}}
/>
<Obstacle
id="rectangle"
kind="rectangle"
initial={{ left: 285, top: 245, width: 100, height: 55 }}
selected={selected}
/>
<Obstacle
id="ellipse"
kind="ellipse"
initial={{ left: 385, top: 145, width: 125, height: 65 }}
selected={selected}
/>
<Obstacle
id="triangle"
kind="triangle"
initial={{ left: 190, top: 125, width: 90, height: 85 }}
selected={selected}
/>
<Rectangle
onClick={() => intensity.update((v) => (v >= 1.2 ? 0.4 : v + 0.4))}
style={{
left: 32,
top: 447,
width: 180,
height: 26,
fill: "#2d3d49",
cursor: "pointer",
}}
/>
<Text
pointerEvents="none"
style={{ left: 44, top: 465, fill: "#d5e1e8", font: "12px sans-serif" }}
>
Intensity {Math.round(intensity.get() * 100)}%
</Text>
<Rectangle
onClick={() => selected.set(null)}
style={{
left: 226,
top: 447,
width: 462,
height: 26,
fill: "#2d3d49",
cursor: "pointer",
}}
/>
<Text
pointerEvents="none"
style={{
left: 239,
top: 465,
fill: "#d5e1e8",
font: "12px sans-serif",
}}
>
{selected.get() ? "Deselect shape" : "Select a shape"}
</Text>
</Group>
);
}
export default function mount(canvas: HTMLCanvasElement): PibblController {
return pibbl(
canvas,
<PhysicsWorld2D space="local" gravity={[0, 0]}>
<FireAndFlow />
</PhysicsWorld2D>,
{ pixelRatio: 1 },
);
}

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