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. Keyboard: Space runs or pauses, . steps, r resets.
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
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | const 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 | */ |
| 11 | export 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.