2025.07.21
In the previous post, I implemented a physics system for a single particle. In this post, I'll explain how I randomly placed multiple particles without overlap.
There's an algorithm perfectly suited for this: Poisson disk sampling. This algorithm generates random points (particles) while guaranteeing a minimum distance between each, ensuring they don't overlap.
For more details on Poisson disk sampling, see:
I adapted this algorithm for my use case. Here's the core code:
/**
* random point generation using poisson disk sampling
*
* @param options sampling options
* @returns array of generated points
*/
export function poissonDiskSampling(
options: PoissonDiskSamplingOptions,
): (Vector | undefined)[] {
const { width, height } = options;
const centerX = width / 2;
const centerY = height / 2;
const minDistance = ENTROPY_SYSTEM_CONSTANTS.MIN_DISTANCE;
const attempts = ENTROPY_SYSTEM_CONSTANTS.MAX_ATTEMPTS;
const cellSize = minDistance / Math.sqrt(2);
const grid: (Vector | undefined)[] = [];
const active: Vector[] = [];
const centerVector = new Vector(centerX, centerY);
// initialize grid cell
const colCnt = Math.floor(width / cellSize);
const rowCnt = Math.floor(height / cellSize);
for (let i = 0; i < colCnt * rowCnt; i++) {
grid[i] = undefined;
}
// start point
const x = centerX + ENTROPY_SYSTEM_CONSTANTS.MIN_THRESHOLD;
const y = centerY + ENTROPY_SYSTEM_CONSTANTS.MIN_THRESHOLD;
const colIdx = Math.floor(x / cellSize);
const rowIdx = Math.floor(y / cellSize);
const pos = new Vector(x, y);
grid[colIdx + rowIdx * colCnt] = pos;
active.push(pos);
while (active.length > 0) {
// select random point from active list
const randIdx = Math.floor(Math.random() * active.length);
const basePos = active[randIdx];
let found = false;
// attempt k times to find a valid sample
for (let n = 0; n < attempts; n++) {
// generate random vector
const sample = Vector.random2D();
const randMagnitude = minDistance + Math.random() * minDistance;
sample.setMagnitude(randMagnitude);
sample.addVector(basePos);
const col = Math.floor(sample.x / cellSize);
const row = Math.floor(sample.y / cellSize);
const distFromCenter = Vector.dist(sample, centerVector);
if (
col < 0 ||
col >= colCnt ||
row < 0 ||
row >= rowCnt ||
grid[col + row * colCnt] ||
distFromCenter > ENTROPY_SYSTEM_CONSTANTS.MAX_THRESHOLD ||
distFromCenter < ENTROPY_SYSTEM_CONSTANTS.MIN_THRESHOLD
) {
continue;
}
const remainDistance =
ENTROPY_SYSTEM_CONSTANTS.MAX_THRESHOLD - distFromCenter;
const currentR = Math.max(0.2, remainDistance * 0.2);
let neighborDistOk = true;
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
const neighborColIdx = col + i;
const neighborRowIdx = row + j;
if (
neighborColIdx < 0 ||
neighborColIdx >= colCnt ||
neighborRowIdx < 0 ||
neighborRowIdx >= rowCnt
) {
continue;
}
const neighborGridIdx = neighborColIdx + neighborRowIdx * colCnt;
const neighborPos = grid[neighborGridIdx];
if (neighborPos === undefined) {
continue;
}
const distFromNeighbor = Vector.dist(sample, neighborPos);
if (distFromNeighbor < currentR) {
neighborDistOk = false;
break;
}
}
if (!neighborDistOk) {
break;
}
}
if (neighborDistOk) {
found = true;
grid[col + row * colCnt] = sample;
active.push(sample);
break;
}
}
// tried k times, but failed to find a valid sample
if (!found) {
active.splice(randIdx, 1);
}
}
return grid;
}
Here's a detailed explanation of the algorithm:
const minDistance = ENTROPY_SYSTEM_CONSTANTS.MIN_DISTANCE;
const attempts = ENTROPY_SYSTEM_CONSTANTS.MAX_ATTEMPTS;
const cellSize = minDistance / Math.sqrt(2);
const grid: (Vector | undefined)[] = [];
const active: Vector[] = [];
minDistance
: Minimum distance between particlesattempts
: Maximum number of attempts per active particle (k)cellSize
: Size of each grid cellgrid
: The grid storing particle positionsactive
: Array of currently active particles (used to generate new ones nearby)First, all cells in grid
are initialized to undefined
.
// initialize grid cell
const colCnt = Math.floor(width / cellSize);
const rowCnt = Math.floor(height / cellSize);
for (let i = 0; i < colCnt * rowCnt; i++) {
grid[i] = undefined;
}
// start point
const x = centerX + ENTROPY_SYSTEM_CONSTANTS.MIN_THRESHOLD;
const y = centerY + ENTROPY_SYSTEM_CONSTANTS.MIN_THRESHOLD;
const colIdx = Math.floor(x / cellSize);
const rowIdx = Math.floor(y / cellSize);
const pos = new Vector(x, y);
grid[colIdx + rowIdx * colCnt] = pos;
active.push(pos);
The starting position is offset from the center (centerX
, centerY
) by at least MIN_THRESHOLD
.
MIN_THRESHOLD
: Minimum distance from the centerThis prevents a particle from always being generated at the exact center.
Particles are then generated until the active
array is empty.
while (active.length > 0) {
// select random point from active list
const randIdx = Math.floor(Math.random() * active.length);
const basePos = active[randIdx];
let found = false;
active
contains particles that can generate new neighbors. This is the efficiency of Poisson disk sampling: to guarantee the minimum distance between all particles, you don't need to compare every particle to every other. You only need to check the closest neighbors. The active
array manages this process.
// find a valid sample
for (let n = 0; n < attempts; n++) {
// generate random vector
const sample = Vector.random2D();
const randMagnitude = minDistance + Math.random() * minDistance;
sample.setMagnitude(randMagnitude);
sample.addVector(basePos);
const col = Math.floor(sample.x / cellSize);
const row = Math.floor(sample.y / cellSize);
const distFromCenter = Vector.dist(sample, centerVector);
if (
col < 0 ||
col >= colCnt ||
row < 0 ||
row >= rowCnt ||
grid[col + row * colCnt] ||
distFromCenter > ENTROPY_SYSTEM_CONSTANTS.MAX_THRESHOLD ||
distFromCenter < ENTROPY_SYSTEM_CONSTANTS.MIN_THRESHOLD
) {
continue;
}
For each basePos
, the algorithm tries up to attempts
times to find a valid new sample. The direction and magnitude are randomized. The sample's distance from the center must be within the allowed range (MIN_THRESHOLD
to MAX_THRESHOLD
).
Neighboring particles are also checked:
let neighborDistOk = true;
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
const neighborColIdx = col + i;
const neighborRowIdx = row + j;
if (
neighborColIdx < 0 ||
neighborColIdx >= colCnt ||
neighborRowIdx < 0 ||
neighborRowIdx >= rowCnt
) {
continue;
}
const neighborGridIdx = neighborColIdx + neighborRowIdx * colCnt;
const neighborPos = grid[neighborGridIdx];
if (neighborPos === undefined) {
continue;
}
const distFromNeighbor = Vector.dist(sample, neighborPos);
if (distFromNeighbor < currentR) {
neighborDistOk = false;
break;
}
}
if (!neighborDistOk) {
break;
}
}
The sample is compared to all particles in the surrounding 3x3 grid. If any are closer than currentR
(the minimum allowed distance), the sample is rejected and another is tried.
Here's the result:
I wanted to go further and create a denser, more 3D effect at the edge of the particle cluster. So I wrote a function to generate points along the edge at regular angles (stepAngle
).
This function is relatively simple:
export function generateEdgeParticles(
options: GenerateEdgeParticlesOptions,
): Vector[] {
const { centerX, centerY, threshold, stepAngle, randomOffset } = options;
const centerVector = new Vector(centerX, centerY);
const edgeParticles: Vector[] = [];
for (let i = 0; i < 360; i += stepAngle) {
const radians = (i * Math.PI) / 180;
const vector = new Vector(Math.cos(radians), Math.sin(radians));
vector.setMagnitude(threshold);
vector.addVector(centerVector);
// add random offset
const randomOffsetX = Math.random() * randomOffset;
const randomOffsetY = Math.random() * randomOffset;
vector.x += randomOffsetX;
vector.y += randomOffsetY;
edgeParticles.push(vector);
}
return edgeParticles;
}
It places particles every stepAngle
degrees around the circle, with optional random offset.
You can see the 3D sphere effect is well represented.
With this, I completed the core entropy particle feature of the app. From the physics system to the particle placement algorithm, there was a lot to learn, but it was a fascinating process. Thank you.