Flow Field
Open the flow field. Drag the red vortex handle or use arrow keys to move the disturbance center. Adjust vortex strength, toggle pause/play, or regenerate random initial streamline phases.
Vector Field Formulation
The particle motion is governed by an analytical 2D velocity field . The field combines a baseline horizontal laminar flow, a sinusoidal wave component, and an interactive vortex perturbation centered at source point :
- Squared distance with smoothing kernel: where prevents singularities when a particle passes through the exact vortex core.
- Horizontal and vertical velocities: where represents vortex strength and controls vertical wave phase.
- Normalized directional step: The velocity vector is normalized and scaled to a uniform integration step size of :
const field = (x, y) => {
const {source: s, strength: k, seed: currentSeed} = settings.current;
const dx = x - s.x;
const dy = y - s.y;
const radius = dx * dx + dy * dy + 0.025;
const vx = 0.8 - dy * 0.22 * k / radius;
const vy = 0.13 * Math.sin(x * 9 + currentSeed % 19) + dx * 0.22 * k / radius;
const length = Math.hypot(vx, vy) || 1;
return {x: vx / length * 0.009, y: vy / length * 0.009};
};
Two-Stage Rendering with Offscreen Buffers
Recomputing 62 streamlines with at most 250 integration steps each would require up to vector-field evaluations per frame. Paths stop early when they leave the bounds, so the actual count can be lower.
The renderer targets 24 frames per second and separates cached streamline curves from moving particle heads:
- Static streamline precomputation (
p5.Graphics): Only when parameters change or the canvas resizes, streamlines are integrated once and drawn into an offscreen graphics buffer (paper). Points along each streamline are retained in an in-memory trajectory array. - Live particle animation: On each frame, the canvas draws the cached offscreen image (
p.image(paper, 0, 0)) and renders small particle circles advancing along the precomputed point arrays using modular indexing:
const trace = () => {
const css = getComputedStyle(node);
paper.background(css.getPropertyValue('--instrument-panel').trim());
ink = css.getPropertyValue('--instrument-red').trim();
const metal = css.getPropertyValue('--instrument-metal').trim();
paper.noFill();
paper.strokeWeight(0.8);
p.randomSeed(settings.current.seed);
paths = [];
for (let row = 0; row < 62; row++) {
let x = -0.1 + p.random(-0.025, 0.025);
let y = row / 51 - 0.1;
const points = [];
const color = p.color(row % 6 === 0 ? ink : metal);
color.setAlpha(row % 6 === 0 ? 145 : 78);
paper.stroke(color);
paper.beginShape();
for (let step = 0; step < 250; step++) {
points.push({x: x * p.width, y: y * p.height});
paper.vertex(x * p.width, y * p.height);
const v = field(x, y);
x += v.x; y += v.y;
if (x > 1.1 || y < -0.3 || y > 1.3) break;
}
paper.endShape();
paths.push(points);
}
dirty = false;
};
p.draw = () => {
if (!paper || disposed) return;
if (dirty) trace();
p.image(paper, 0, 0); // Blit cached streamlines in one call
p.noStroke(); p.fill(ink);
paths.forEach((points, index) => {
const point = points[(frame + index * 17) % points.length];
p.circle(point.x, point.y, index % 6 === 0 ? 3 : 1.6);
});
if (settings.current.playing) frame++;
};
Interaction and Accessibility
- Pointer capture: The vortex handle captures pointer events (
setPointerCapture), allowing uninterrupted manipulation even when dragging beyond container bounds. - Keyboard navigation: The handle accepts arrow keys (
ArrowLeft,ArrowRight,ArrowUp,ArrowDown) to adjust coordinates in steps of 0.025. - Autoplay: The homepage preview and full experiment page start paused when the system requests reduced motion; otherwise they start playing. Both provide a pause/play button, so animation can also be started manually. Animation pauses while offscreen or while the browser tab is hidden.
- Cleanup: On unmount, event listeners, observers, offscreen graphics buffers (
paper.remove()), and the p5 instance (sketch.remove()) are thoroughly disposed.