Skip to content

Vector Fields

Flow from a formula, particle by particle.

Beginner / field / particles / flow

A uniform flow, a source, a sink, and a vortex sum into one velocity field; particles ride it exactly as the formula dictates. Raise the vortex and watch circulation appear; drop the source below zero and it becomes a sink that drinks the flow. The pointer is an extra source you can steer.

Tagged: source, sink, vortex, streamlines

Live simulation

Hold the pointer to inject an extra source; the arrows sample the same field.

Speed

Hold the pointer to inject an extra source; the arrows sample the same field.

Mathematics

A vector field assigns a velocity to every point. This one sums four analytic ingredients: a uniform flow, a source (radial outflow decaying as 1/r), a vortex (tangential 1/r flow), and the pointer as an extra source you can steer.

uniform: v = U (cos alpha, sin alpha)

source of strength m: v = m (r - r0) / (2 pi |r - r0|^2)

vortex of strength G: v = G (-dy, dx) / (2 pi |r - r0|^2)

The 1/r decay is the signature of potential flow in 2-D: the induced speed halves every doubling of distance, which is why the structures you see stay local. Setting the source negative turns outflow into inflow - a sink that the particles visibly fall into - and the vortex slider alone produces pure circulation.

How it works

Seven hundred particles advect through the analytic velocity each tick with edge-respawn keeping density even; previous positions are stored so each particle renders as a motion streak whose alpha scales with speed. The arrow overlay samples the same velocity function on a grid - particles and arrows can never disagree.

Everything is closed-form: no integration error exists in the field itself, only in the particle advection step.

Implementation

src/engine/experiments/vector-field.ts · 187 lines · the module running above

TypeScript · fixed-timestep engine

1import { BaseExperiment } from "../core/base-experiment";
2import type { ParameterDef, PointerState, ThemeColors } from "../core/types";
3
4const PARTICLES = 700;
5
6/**
7 * A composed vector field: uniform flow, a source, a sink, and a vortex,
8 * each with a strength slider. Particles advect through the exact field
9 * (drawn from its analytic formula); the pointer injects a temporary source.
10 */
11export class VectorFieldExperiment extends BaseExperiment {
12 readonly id = "vector-field";
13
14 private x = new Float32Array(PARTICLES);
15 private y = new Float32Array(PARTICLES);
16 private px = new Float32Array(PARTICLES);
17 private py = new Float32Array(PARTICLES);
18 private time = 0;
19 private pointer = { x: 0, y: 0, down: false, inside: false };
20
21 protected params(): ParameterDef[] {
22 return [
23 { key: "flowAngle", label: "Uniform flow angle", min: -180, max: 180, step: 5, defaultValue: 0, unit: "°" },
24 { key: "flowSpeed", label: "Uniform flow speed", min: 0, max: 120, step: 5, defaultValue: 40, unit: "px/s" },
25 { key: "source", label: "Source strength", min: -6000, max: 6000, step: 200, defaultValue: 3000 },
26 { key: "vortex", label: "Vortex strength", min: -8000, max: 8000, step: 200, defaultValue: 3500 },
27 {
28 key: "arrows",
29 label: "Show field arrows",
30 options: [
31 { value: "off", label: "Off" },
32 { value: "on", label: "On" },
33 ],
34 defaultValue: "off",
35 },
36 ];
37 }
38
39 protected onReset(): void {
40 for (let i = 0; i < PARTICLES; i++) {
41 this.x[i] = this.rng() * this.width;
42 this.y[i] = this.rng() * this.height;
43 this.px[i] = this.x[i];
44 this.py[i] = this.y[i];
45 }
46 this.time = 0;
47 }
48
49 resize(width: number, height: number): void {
50 this.width = width;
51 this.height = height;
52 this.reset();
53 }
54
55 onPointer(state: PointerState): void {
56 this.pointer = state;
57 }
58
59 /** The field at (x, y), components in px/s. */
60 private velocity(x: number, y: number): [number, number] {
61 const angle = (this.num("flowAngle") * Math.PI) / 180;
62 let vx = Math.cos(angle) * this.num("flowSpeed");
63 let vy = Math.sin(angle) * this.num("flowSpeed");
64 // Source/sink at the left-center, vortex at the right-center.
65 const sx = this.width * 0.32;
66 const sy = this.height * 0.5;
67 let dx = x - sx;
68 let dy = y - sy;
69 let d2 = dx * dx + dy * dy + 400;
70 const src = this.num("source") / (2 * Math.PI * d2);
71 vx += src * dx;
72 vy += src * dy;
73 dx = x - this.width * 0.68;
74 dy = y - this.height * 0.5;
75 d2 = dx * dx + dy * dy + 400;
76 const vor = this.num("vortex") / (2 * Math.PI * d2);
77 vx += -dy * vor;
78 vy += dx * vor;
79 // Pointer as a temporary source.
80 if (this.pointer.inside) {
81 dx = x - this.pointer.x;
82 dy = y - this.pointer.y;
83 const pd2 = dx * dx + dy * dy + 400;
84 const strength = this.pointer.down ? 9000 : 3500;
85 const s = strength / (2 * Math.PI * pd2);
86 vx += s * dx;
87 vy += s * dy;
88 }
89 return [vx, vy];
90 }
91
92 protected onUpdate(dt: number): void {
93 this.time += dt;
94 for (let i = 0; i < PARTICLES; i++) {
95 this.px[i] = this.x[i];
96 this.py[i] = this.y[i];
97 const [vx, vy] = this.velocity(this.x[i], this.y[i]);
98 this.x[i] += vx * dt;
99 this.y[i] += vy * dt;
100 // Respawn on edges to keep density even.
101 if (this.x[i] < -4 || this.x[i] > this.width + 4 || this.y[i] < -4 || this.y[i] > this.height + 4 || this.rng() < 0.002) {
102 const edge = Math.floor(this.rng() * 4);
103 if (edge === 0) {
104 this.x[i] = 0;
105 this.y[i] = this.rng() * this.height;
106 } else if (edge === 1) {
107 this.x[i] = this.width;
108 this.y[i] = this.rng() * this.height;
109 } else if (edge === 2) {
110 this.x[i] = this.rng() * this.width;
111 this.y[i] = 0;
112 } else {
113 this.x[i] = this.rng() * this.width;
114 this.y[i] = this.height;
115 }
116 this.px[i] = this.x[i];
117 this.py[i] = this.y[i];
118 }
119 }
120 }
121
122 render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void {
123 ctx.fillStyle = theme.bg;
124 ctx.fillRect(0, 0, this.width, this.height);
125 ctx.lineWidth = 1.1;
126 for (let i = 0; i < PARTICLES; i++) {
127 const speed = Math.hypot(this.x[i] - this.px[i], this.y[i] - this.py[i]);
128 ctx.strokeStyle = i % 30 === 0 ? theme.accent : theme.fgSecondary;
129 ctx.globalAlpha = Math.min(0.9, 0.15 + speed / 4);
130 ctx.beginPath();
131 ctx.moveTo(this.px[i], this.py[i]);
132 ctx.lineTo(this.x[i], this.y[i]);
133 ctx.stroke();
134 }
135 ctx.globalAlpha = 1;
136 if (this.str("arrows") === "on") {
137 const step = 46;
138 ctx.strokeStyle = theme.fgTertiary;
139 ctx.lineWidth = 1;
140 for (let gy = step / 2; gy < this.height; gy += step) {
141 for (let gx = step / 2; gx < this.width; gx += step) {
142 const [vx, vy] = this.velocity(gx, gy);
143 const mag = Math.hypot(vx, vy);
144 if (mag < 1) continue;
145 const len = Math.min(14, 4 + mag * 0.08);
146 const nx = vx / mag;
147 const ny = vy / mag;
148 ctx.beginPath();
149 ctx.moveTo(gx - nx * len * 0.5, gy - ny * len * 0.5);
150 ctx.lineTo(gx + nx * len * 0.5, gy + ny * len * 0.5);
151 ctx.stroke();
152 ctx.beginPath();
153 ctx.arc(gx + nx * len * 0.5, gy + ny * len * 0.5, 1.2, 0, Math.PI * 2);
154 ctx.fill();
155 }
156 }
157 }
158 // Sources of the field
159 ctx.fillStyle = theme.viz[2];
160 ctx.beginPath();
161 ctx.arc(this.width * 0.32, this.height * 0.5, 3.4, 0, Math.PI * 2);
162 ctx.fill();
163 ctx.strokeStyle = theme.accent;
164 ctx.beginPath();
165 ctx.arc(this.width * 0.68, this.height * 0.5, 3.4, 0, Math.PI * 2);
166 ctx.stroke();
167 }
168
169 getMetrics() {
170 const [vx, vy] = this.velocity(this.width / 2, this.height / 2);
171 return {
172 centerSpeed: `${Math.hypot(vx, vy).toFixed(1)} px/s`,
173 sourceStrength: this.num("source").toString(),
174 vortexStrength: this.num("vortex").toString(),
175 particles: PARTICLES,
176 };
177 }
178
179 describe(): string {
180 return `${PARTICLES} particles advecting through a composed field: uniform flow, a source at the left, a vortex at the right, and the pointer as an extra source.`;
181 }
182
183 entities(): number {
184 return PARTICLES;
185 }
186}
187

Performance

Time per tick
O(particles + arrows) per tick
Space
Two position arrays
Notes
Balance source against flow and a stagnation point appears - the point where the velocity is exactly zero.

Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.