src/sim/boids.ts 7.1 K raw
1
export interface BoidParams {
2
	topologicalK: number;
3
	perception: number;
4
	sepRange: number;
5
	maxSpeed: number;
6
	minSpeed: number;
7
	maxForce: number;
8
	wSep: number;
9
	wAlign: number;
10
	wCoh: number;
11
	wAttract: number;
12
	wNoise: number;
13
}
14
15
export const DEFAULT_BOID_PARAMS: BoidParams = {
16
	topologicalK: 7,
17
	perception: 70,
18
	sepRange: 14,
19
	maxSpeed: 3.4,
20
	minSpeed: 2.2,
21
	maxForce: 0.12,
22
	wSep: 1.8,
23
	wAlign: 1.4,
24
	wCoh: 0.9,
25
	wAttract: 0.06,
26
	wNoise: 0.015,
27
};
28
29
export const boidParams: BoidParams = { ...DEFAULT_BOID_PARAMS };
30
31
export class Boid {
32
	x: number;
33
	y: number;
34
	vx: number;
35
	vy: number;
36
	ax = 0;
37
	ay = 0;
38
	lastCell = -1;
39
	lastSepMag = 0;
40
	lastAccentAt = 0;
41
42
	constructor(W: number, H: number) {
43
		this.x = Math.random() * W;
44
		this.y = Math.random() * H;
45
		const a = Math.random() * Math.PI * 2;
46
		const s =
47
			boidParams.minSpeed +
48
			Math.random() * (boidParams.maxSpeed - boidParams.minSpeed);
49
		this.vx = Math.cos(a) * s;
50
		this.vy = Math.sin(a) * s;
51
	}
52
53
	edges(W: number, H: number) {
54
		const m = 20;
55
		if (this.x < -m) this.x = W + m;
56
		else if (this.x > W + m) this.x = -m;
57
		if (this.y < -m) this.y = H + m;
58
		else if (this.y > H + m) this.y = -m;
59
	}
60
61
	flock(candidates: Boid[], attractor: { x: number; y: number }) {
62
		const p = boidParams;
63
		const perception2 = p.perception * p.perception;
64
		const sepRange2 = p.sepRange * p.sepRange;
65
66
		const dists: { o: Boid; d2: number; dx: number; dy: number }[] = [];
67
		for (let i = 0; i < candidates.length; i++) {
68
			const o = candidates[i];
69
			if (o === this) continue;
70
			const dx = o.x - this.x;
71
			const dy = o.y - this.y;
72
			const d2 = dx * dx + dy * dy;
73
			if (d2 < perception2 && d2 > 0) {
74
				dists.push({ o, d2, dx, dy });
75
			}
76
		}
77
		dists.sort((a, b) => a.d2 - b.d2);
78
		const k = Math.min(p.topologicalK, dists.length);
79
80
		let alignX = 0,
81
			alignY = 0;
82
		let cohX = 0,
83
			cohY = 0;
84
		let sepX = 0,
85
			sepY = 0;
86
		let sepCount = 0;
87
88
		for (let i = 0; i < k; i++) {
89
			const { o, d2, dx, dy } = dists[i];
90
			alignX += o.vx;
91
			alignY += o.vy;
92
			cohX += o.x;
93
			cohY += o.y;
94
95
			if (d2 < sepRange2) {
96
				const d = Math.sqrt(d2);
97
				const f = 1 / (d + 0.001);
98
				sepX -= (dx / d) * f;
99
				sepY -= (dy / d) * f;
100
				sepCount++;
101
			}
102
		}
103
104
		let fx = 0,
105
			fy = 0;
106
107
		if (k > 0) {
108
			alignX /= k;
109
			alignY /= k;
110
			const m = Math.hypot(alignX, alignY);
111
			if (m > 0) {
112
				alignX = (alignX / m) * p.maxSpeed - this.vx;
113
				alignY = (alignY / m) * p.maxSpeed - this.vy;
114
				const sm = Math.hypot(alignX, alignY);
115
				if (sm > p.maxForce) {
116
					alignX = (alignX / sm) * p.maxForce;
117
					alignY = (alignY / sm) * p.maxForce;
118
				}
119
				fx += alignX * p.wAlign;
120
				fy += alignY * p.wAlign;
121
			}
122
123
			cohX = cohX / k - this.x;
124
			cohY = cohY / k - this.y;
125
			const cm = Math.hypot(cohX, cohY);
126
			if (cm > 0) {
127
				cohX = (cohX / cm) * p.maxSpeed - this.vx;
128
				cohY = (cohY / cm) * p.maxSpeed - this.vy;
129
				const sm = Math.hypot(cohX, cohY);
130
				if (sm > p.maxForce) {
131
					cohX = (cohX / sm) * p.maxForce;
132
					cohY = (cohY / sm) * p.maxForce;
133
				}
134
				fx += cohX * p.wCoh;
135
				fy += cohY * p.wCoh;
136
			}
137
		}
138
139
		if (sepCount > 0) {
140
			const m = Math.hypot(sepX, sepY);
141
			if (m > 0) {
142
				sepX = (sepX / m) * p.maxSpeed - this.vx;
143
				sepY = (sepY / m) * p.maxSpeed - this.vy;
144
				const sm = Math.hypot(sepX, sepY);
145
				if (sm > p.maxForce * 2) {
146
					sepX = (sepX / sm) * p.maxForce * 2;
147
					sepY = (sepY / sm) * p.maxForce * 2;
148
				}
149
				fx += sepX * p.wSep;
150
				fy += sepY * p.wSep;
151
				this.lastSepMag = Math.hypot(sepX, sepY);
152
			} else {
153
				this.lastSepMag = 0;
154
			}
155
		} else {
156
			this.lastSepMag = 0;
157
		}
158
159
		const adx = attractor.x - this.x;
160
		const ady = attractor.y - this.y;
161
		const ad = Math.hypot(adx, ady);
162
		if (ad > 0) {
163
			fx += (adx / ad) * p.wAttract;
164
			fy += (ady / ad) * p.wAttract;
165
		}
166
167
		fx += (Math.random() - 0.5) * p.wNoise;
168
		fy += (Math.random() - 0.5) * p.wNoise;
169
170
		this.ax += fx;
171
		this.ay += fy;
172
	}
173
174
	update() {
175
		const p = boidParams;
176
		this.vx += this.ax;
177
		this.vy += this.ay;
178
		const sp = Math.hypot(this.vx, this.vy);
179
		if (sp > p.maxSpeed) {
180
			this.vx = (this.vx / sp) * p.maxSpeed;
181
			this.vy = (this.vy / sp) * p.maxSpeed;
182
		} else if (sp < p.minSpeed && sp > 0) {
183
			this.vx = (this.vx / sp) * p.minSpeed;
184
			this.vy = (this.vy / sp) * p.minSpeed;
185
		}
186
		this.x += this.vx;
187
		this.y += this.vy;
188
		this.ax = 0;
189
		this.ay = 0;
190
	}
191
}
192
193
export function flockCoherence(boids: Boid[]): number {
194
	let sumX = 0,
195
		sumY = 0,
196
		sumMag = 0;
197
	for (let i = 0; i < boids.length; i++) {
198
		const b = boids[i];
199
		sumX += b.vx;
200
		sumY += b.vy;
201
		sumMag += Math.hypot(b.vx, b.vy);
202
	}
203
	if (sumMag === 0) return 0;
204
	return Math.hypot(sumX, sumY) / sumMag;
205
}
206
207
export function drawBoid(ctx: CanvasRenderingContext2D, b: Boid) {
208
	const angle = Math.atan2(b.vy, b.vx);
209
	const cos = Math.cos(angle);
210
	const sin = Math.sin(angle);
211
	const len = 3.2;
212
	ctx.beginPath();
213
	ctx.moveTo(b.x - cos * len, b.y - sin * len);
214
	ctx.lineTo(b.x + cos * len * 0.6, b.y + sin * len * 0.6);
215
	ctx.stroke();
216
}
217
218
export type Sim = ReturnType<typeof createSim>;
219
220
export function createSim(W: number, H: number) {
221
	const state = {
222
		W,
223
		H,
224
		boids: [] as Boid[],
225
		attractor: {
226
			x: W / 2,
227
			y: H / 2,
228
			t: Math.random() * 1000,
229
		},
230
	};
231
232
	function defaultCount(W: number, H: number) {
233
		return Math.min(420, Math.floor((W * H) / 3200));
234
	}
235
236
	function spawn(count: number) {
237
		state.boids.length = 0;
238
		const cx0 = state.W / 2,
239
			cy0 = state.H / 2;
240
		for (let i = 0; i < count; i++) {
241
			const b = new Boid(state.W, state.H);
242
			const r = Math.random() * Math.min(state.W, state.H) * 0.25;
243
			const a = Math.random() * Math.PI * 2;
244
			b.x = cx0 + Math.cos(a) * r;
245
			b.y = cy0 + Math.sin(a) * r;
246
			state.boids.push(b);
247
		}
248
	}
249
250
	spawn(defaultCount(W, H));
251
252
	const grid = new Map<string, Boid[]>();
253
254
	function buildGrid() {
255
		grid.clear();
256
		const cellSize = boidParams.perception;
257
		for (let i = 0; i < state.boids.length; i++) {
258
			const b = state.boids[i];
259
			const cx = Math.floor(b.x / cellSize);
260
			const cy = Math.floor(b.y / cellSize);
261
			const k = cx + ',' + cy;
262
			let arr = grid.get(k);
263
			if (!arr) {
264
				arr = [];
265
				grid.set(k, arr);
266
			}
267
			arr.push(b);
268
		}
269
	}
270
271
	function neighbors(b: Boid): Boid[] {
272
		const cellSize = boidParams.perception;
273
		const cx = Math.floor(b.x / cellSize);
274
		const cy = Math.floor(b.y / cellSize);
275
		const out: Boid[] = [];
276
		for (let dx = -1; dx <= 1; dx++) {
277
			for (let dy = -1; dy <= 1; dy++) {
278
				const arr = grid.get(cx + dx + ',' + (cy + dy));
279
				if (arr) for (let i = 0; i < arr.length; i++) out.push(arr[i]);
280
			}
281
		}
282
		return out;
283
	}
284
285
	function updateAttractor() {
286
		const a = state.attractor;
287
		a.t += 0.003;
288
		const cx = state.W / 2,
289
			cy = state.H / 2;
290
		const rx = state.W * 0.32,
291
			ry = state.H * 0.28;
292
		a.x = cx + Math.sin(a.t * 1.3) * rx + Math.cos(a.t * 0.7) * rx * 0.3;
293
		a.y = cy + Math.cos(a.t * 1.1) * ry + Math.sin(a.t * 1.7) * ry * 0.25;
294
	}
295
296
	function step() {
297
		updateAttractor();
298
		buildGrid();
299
		for (let i = 0; i < state.boids.length; i++) {
300
			state.boids[i].flock(neighbors(state.boids[i]), state.attractor);
301
		}
302
		for (let i = 0; i < state.boids.length; i++) {
303
			state.boids[i].update();
304
			state.boids[i].edges(state.W, state.H);
305
		}
306
	}
307
308
	function resize(W: number, H: number) {
309
		state.W = W;
310
		state.H = H;
311
	}
312
313
	return { state, step, resize, spawn };
314
}