boids.pyΒΆ
Part of Deep Sea Aquarium.
1"""Vectorised numpy boids flocking: separation, alignment, cohesion.
2
3Every rule is a masked reduction over the pairwise distance matrix, so a
4school costs one pass of numpy work per frame rather than a Python loop
5per fish.
6"""
7
8import numpy as np
9
10
11def compute_boids(
12 positions: np.ndarray,
13 velocities: np.ndarray,
14 separation_radius: float = 2.5,
15 alignment_radius: float = 5.0,
16 cohesion_radius: float = 7.0,
17 separation_weight: float = 3.0,
18 alignment_weight: float = 0.8,
19 cohesion_weight: float = 0.5,
20 bounds: float = 12.0,
21 bounds_weight: float = 1.0,
22) -> np.ndarray:
23 """Compute boids acceleration for all agents.
24
25 Args:
26 positions: (N, 3) array of world positions.
27 velocities: (N, 3) array of current velocities.
28
29 Returns:
30 (N, 3) acceleration array to add to velocities.
31 """
32 n = len(positions)
33 if n < 2:
34 return np.zeros_like(positions)
35
36 # Pairwise displacement and distance
37 diff = positions[:, None, :] - positions[None, :, :] # (N, N, 3)
38 dist = np.linalg.norm(diff, axis=2) # (N, N)
39 np.fill_diagonal(dist, 1e10) # Ignore self
40
41 accel = np.zeros_like(positions)
42
43 # Separation: steer away from neighbours too close. diff[i, j] already points
44 # FROM neighbour j TO self, weighted by inverse square distance.
45 falloff = np.where(dist < separation_radius, 1.0 / (dist * dist + 0.01), 0.0) # (N, N)
46 accel += (falloff[:, :, None] * diff).sum(axis=1) * separation_weight
47
48 # Alignment: match the mean velocity of nearby neighbours
49 align_mask = (dist < alignment_radius).astype(positions.dtype) # (N, N)
50 align_count = align_mask.sum(axis=1, keepdims=True)
51 avg_vel = (align_mask @ velocities) / np.maximum(align_count, 1.0)
52 accel += np.where(align_count > 0, avg_vel - velocities, 0.0) * alignment_weight
53
54 # Cohesion: steer toward the centre of the nearby flock
55 coh_mask = (dist < cohesion_radius).astype(positions.dtype)
56 coh_count = coh_mask.sum(axis=1, keepdims=True)
57 centre = (coh_mask @ positions) / np.maximum(coh_count, 1.0)
58 accel += np.where(coh_count > 0, centre - positions, 0.0) * cohesion_weight
59
60 # Boundary avoidance: soft steering near aquarium walls
61 for axis in range(3):
62 over = positions[:, axis] > bounds
63 under = positions[:, axis] < -bounds
64 accel[over, axis] -= bounds_weight * (positions[over, axis] - bounds)
65 accel[under, axis] -= bounds_weight * (positions[under, axis] + bounds)
66
67 # Vertical bias: keep fish roughly horizontal (-3 to 3 Y range)
68 y_high = positions[:, 1] > 3.0
69 y_low = positions[:, 1] < -2.0
70 accel[y_high, 1] -= 1.0
71 accel[y_low, 1] += 1.0
72
73 return accel