A glimpse into a world of 2D particles
This post is inspired by this beautiful youtube video from Tom Mohr (highly recommended watch). It does not really follow the modeling from Tom's video precisely, but it is quite similar. In the process of writing this post I learnt a lot! it felt like it'd been a long time since I did something this fun, therefore I am continuing this project hopefully in a couple of future parts (there is at least going to be a part 2). This series is all about efficiency, modeling, and performance when it comes to web based visualisations. This post is basically my attempt to share what I have learnt along the way and to also document it for myself.
Here's a table of contents for easy scroll to each section What are we looking for? How do we model it? How do we simulate it? (basic implementation) How does it look? (an interactive demo) Tips for better performance Example visualisations What's next? (Part 2)
# What’s the challenge here?
Picture this, you have a bunch of particles (tiny little circles) split into multiple colors. Each color represents a unique type, and the way these particles interact is governed (for the most part) by a fixed matrix, a kind of table where each cell tells you how strongly one type pulls (attraction) or pushes another (repulsion). The rows and columns correspond to the colors, and the number in each cell is how strongly a pair of particles with those colors pull or push one another. don't worry if it all sounds pretty abstract for now, I am going to explain all of this in more details very shortly. Our main goal is to bring this system to life visually and see how these particles interact with different configurations. In a second part to this post coming some time later I will dig deeper into how we can do this in scale with lots of particles and more efficientely in terms of performance and user experience as well.
In a nutshell our objective is to
- Model how particles move in every frame of time mathematically
- Design an algorithm that implements our modeling frame by frame
- Target a frame budget of 16 milliseconds per frame to maintain roughly a stable 60fps performance on Mac Mini M4 Pro hardware when simulating 1000 particles
For the most of this post and this part in particular we will focus on the first two objectives. towards the end of this post we cover the third objective and go even beyond our initial target and aim to make our demo efficient enough for a 60fps performance on a much less powerful hardware such an iPhone 13 Pro, or a Google Pixel 7a.
Modeling the Problem
So far we know we want particles that can move around, they have different colors, each color can attract or repel another color, and that's about it. It is entirely up to us how to mode and then implement these requirements. some modeling choices make it easier for us not only to communicate our final implementation with others (e.g. by use of familiar notations), but to even optimise it further down the road. for instance if we can model our problem in matrix notations, we could potentially use results from Linear Algebra to reduce number of computations in our simulation, or we can utilise GPUs to parallelise some computations. Anyways, in this section we clarify our modeling choices.
Modeling Motion
According to Newton's second law of motion, force is related to acceleration by
Let's assume all particles have unit mass (
The acceleration
This models how forces between particles drive their motion by connecting them to acceleration, velocity, and position updates.
In practice, we further apply a small damping factor
where
For more on modeling motion I highly recommend this amazing tutorial by Kenichi Yoneda. we can try and build upon this basic motion model later on but for now this works just fine.
Next we look into how we can model pairwise particle forces such that we get attraction and repulsion behaviour based on particle colors.
Modeling Forces
Earlier we briefly hinted at a table to represent pairwise forces. here we make it more explicit by considering it as a matrix called the force matrix. following is an example of this matrix for a system with three particle types
| Red | Green | Blue | |
|---|---|---|---|
| Red | 0.5 | 0.3 | -0.3 |
| Green | -0.5 | 0.2 | 0.5 |
| Blue | 0.2 | -0.2 | 0.3 |
Let's call this matrix
Each entry
means red particles attract each other by a force with a magnitude of
means red particles are attracted towards green particles by a force with a magnitude of. (a little weaker than the force from before)
means blue particles repel green ones by a force with a magnitude of
So far for a pair of particles we know how much force is determined by their types, but if this is the only source of force and assuming we have only 2 types that attract each other, after a while all particles collapse into each other. basically we need to model a global repulsive force as well so when particles are too close to one another they repel each other regardless of their type. there are so many possibilities but here we use a very simple one. we model a multiplier which is a function of the distance between particles. let's call it
- Min Cutoff distance
For particles any closer we clamp the repulsion force to -1 - Zero Force distance
A distance at which particles do not apply any force on one another, but any closer they repel and any farther they attract each other - Max Cutoff distance
For particles any farther we assume the force between them is negligible i.e. 0
Here's an illustration of this multiplier function
Using
With
Here
We can then define force vector
The net force on a particle is the sum of all such vectors i.e. forces coming from all the other particles towards
To understand the vector math above a little better imagine we have only 4 particles
Here
- Arrows with solid lines represent (difference) vectors between each pair of particles that include particle
. (these are the only pairs that affect ) - The arrow with dotted line represents the negated vector between particles
and , or more precisely . as you can see in the formula to compute this points towards the direction when or when . since we are are talking about forces applied to particle , this represents a repel force from particle - Dashed arrows represent vectors
and (to reduce visual clutter, arrow notation denoting vector quantities has been omitted from the graphics)
Let's further assume green and red both attract orange, but blue repels it (each can have a different constant factor). with these assumptions we can compute
Boundary Conditions
An important aspect of such a simulation is designing how particles behave at the edges of the 2D world. there are multiple options. Here are three well-known boundary options
- Reflecting
Particles bounce off the edges of the boundary - Fixed
Particles are stopped or pinned when they reach an edge of the boundary - Periodic (Wrap-around/toroidal/wrapping)
Particles leaving one side instantly reappear on the opposite side, creating a continuous loop
Each choice has implications for the resulting dynamics and can be selected based on our desired simulation behavior.
Implementation
To better understand how the theoretical model translates into a working simulation, we go one step closer towards the final visualisation by mapping out a flowchart of the simulation logic. this simulation is essentially governed by the modeling we already discussed. so for instance "Compute net force" step is a short way of saying we compute
Interactive Demo
In the following interactive demo you can see how this modeling and simulation logic all comes together towards realising our first two objectives. try playing around with different simulation parameters such as modifying entries of the color matrix, timestep, zero force distance, ... to see in real-time how these particles interact.
Thoughts on Performance
So far we saw the modeling and a demo, but didn't really discuss implementation details. the flowchart from earlier does give us an overal picture however. we have a long running loop and we want each iteration not to take any longer than 16 milliseconds or else it means our framerate drops to below 60fps. part of this has to do with the complexity of the problem itself i.e. the number of particles involved. no matter what we do there is always a large enough number of particles our implementation cannot handle efficiently. so let's focus on our 3rd objective (1k particles at 60fps on a modern Mac Mini) and see what we can do to support simulating that many particles.
Why 60 FPS and scale matter?
Our goal isn’t just to visualize particles, it’s to create a smooth 60fps simulation where you can watch complex behaviors emerge in real time. Think of it like a video game. If the framerate stutters, interactivity almost disappears. Every frame requires calculating forces between every pair of particles, which can quickly tank performance. So basically with each iteration of our implementation we are interested in knowing how many particles can we simulate before framerate drops below 60fps.
Let's briefly discuss the performance of a naive implementation of our modeling. this part requires some familiarity with typescript and p5js. (I am using p5js in instance mode so calls to p5 specific API look like p.createVector instead of createVector
In the spirit of OOP let's start with defining a couple of interfaces that we are going to use in our main code
interface Boundary {
left: number;
right: number;
top: number;
bottom: number;
}
interface PhysicsState {
position: {
x: number,
y: number
};
velocity: {
x: number,
y: number
};
}
interface Force {
x: number;
y: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
type: number;
velocityDampingMultiplier: number;
}
interface SimulationContext {
// this many particles are split into three types of colors (almost equally)
numParticles: number;
// radius of each particle
particleRadius: number;
// if two particles are closer than this we assume this is their distance
cutoffMinDistance: number;
// two particles farther than this do not apply any forces to one another
cutoffMaxDistance: number;
// distance at which particle pairs apply 0 force. any closer they repel each other regardless of their color and any farther they attract/repel each other according to their color.
zeroForceDistance: number;
// in seconds, used when computing velocity and position from applied force (F=ma)
simulationTimestep: number;
// matrix containing forces between particles based on color
colorForceMatrix: number[][];
// simulation boundary (2D box)
boundary: Boundary;
}
We can then define our update logic like the following
function updateParticles(ctxt: SimulationContext) {
// 1. Computing net force on each particle
let forcesX = new Array(particles.length).fill(0);
let forcesY = new Array(particles.length).fill(0);
for (let i = 0; i < particles.length; i++) {
let pi = particles[i];
for (let j = 0; j < particles.length; j++) {
if (i === j) continue;
let force = computeForceBetweenParticles(pi, particles[j], ctxt);
forcesX[i] += force.x;
forcesY[i] += force.y;
}
}
// 2. Applying motion model based on computed net forces
for (let i = 0; i < particles.length; i++) {
let pi = particles[i];
const currentState: PhysicsState = {
position: {
x: pi.x,
y: pi.y
},
velocity: {
x: pi.vx,
y: pi.vy
}
};
const appliedForce: Force = {
x: forcesX[i],
y: forcesY[i]
};
// Calculate next state (stat at t + 1)
const nextState = updatePhysics(currentState, appliedForce, pi.velocityDampingMultiplier, ctxt);
pi.x = nextState.position.x;
pi.y = nextState.position.y;
pi.vx = nextState.velocity.x;
pi.vy = nextState.velocity.y;
// Applying reflecting boundary conditions (mutates pi position and velocity if needed)
handleBoundaryConditions(pi, ctxt);
}
}
The three methods used within updateParticles are as follows
computeForceBetweenParticles
This is where we compute force on particlefrom particle updatePhysics
This is where we compute new velocity and position of a particle given a net force vector according to our modeling of motionhandleBoundaryConditions
This is where we constrain particle motion based on reflecting boundary conditions
Here's how they can be implemented
function computeForceBetweenParticles(pi: Particle, pj: Particle, ctxt: SimulationContext) {
let force = p.createVector(pj.x, pj.y).sub(p.createVector(pi.x, pi.y));
let distance = force.mag();
// treat any distance less than cutoffMinDistance as cutoffMinDistance
if (distance < ctxt.cutoffMinDistance) distance = ctxt.cutoffMinDistance;
// applying our modeling for attraction/repulsion
let strength = 1;
let multiplier = 1;
if (distance <= ctxt.zeroForceDistance) {
// cutoffMinDistance <= distance <= zeroForceDistance
// distance too close -> global repulsion to prevent collision
multiplier = 1 - p.map(distance, ctxt.cutoffMinDistance, ctxt.zeroForceDistance, 0, 1);
// fixed repulsion force with magnitude of 1 (regardless of particle colors)
strength = multiplier * -1;
} else if (distance <= ctxt.cutoffMaxDistance) {
// zeroForceDistance < distance <= cutoffMaxDistance
multiplier = p.map(distance, ctxt.zeroForceDistance, ctxt.cutoffMaxDistance, 0, 1);
strength = multiplier * ctxt.colorForceMatrix[pi.type][pj.type];
} else {
// distance > cutoffMaxDistance so force between the pair is considered negligible
strength = 0;
}
return force.setMag(strength);
}
Then we have
const updatePhysics = (
current: PhysicsState,
force: Force,
particleVelocityDampingMultiplier: number,
ctxt: SimulationContext
): PhysicsState => {
const dt = ctxt.simulationTimestep;
// 1. Calculate acceleration components (assuming unit mass i.e. m=1 so a=F/m=F)
const ax = force.x;
const ay = force.y;
// 2. Update velocity components (v = v0 + a*dt)
let vxNew = current.velocity.x + ax * dt;
let vyNew = current.velocity.y + ay * dt;
// 3. Apply damping to updated velocities to avoid runaway speeds
vxNew *= particleVelocityDampingMultiplier;
vyNew *= particleVelocityDampingMultiplier;
// 4. Update position components (x = x0 + v*dt)
const xNew = current.position.x
+ vxNew * dt;
const yNew = current.position.y
+ vyNew * dt;
return {
position: {
x: xNew,
y: yNew
},
velocity: {
x: vxNew,
y: vyNew,
}
};
}
There are several options how to define boundary conditions and each has its own consequences and meanings. In this post for simplicity we only consider reflecting boundary conditions (without any energy loss) which can be implemented simply as below
function handleBoundaryConditions(particle: Particle, ctxt: SimulationContext) {
// bouncing off the walls
if (particle.x > ctxt.boundary.right - ctxt.particleRadius) particle.vx *= -1;
if (particle.x < ctxt.boundary.left + ctxt.particleRadius) particle.vx *= -1;
if (particle.y > ctxt.boundary.bottom - ctxt.particleRadius) particle.vy *= -1;
if (particle.y < ctxt.boundary.top + ctxt.particleRadius) particle.vy *= -1;
// making sure particles stay within the boundary
particle.x = p.constrain(particle.x, ctxt.boundary.left + ctxt.particleRadius, ctxt.boundary.right - ctxt.particleRadius);
particle.y = p.constrain(particle.y, ctxt.boundary.top + ctxt.particleRadius, ctxt.boundary.bottom - ctxt.particleRadius);
}
In part 2 we will also explore implementation details of periodic boundary conditions.
Now that we have familiarised ourselves with the code, first point that comes to mind is that within updateParticles we are looking at every pair twice. once computing force from particle colorForceMatrix is not necessarily a symmetric one. However, one major (heavy) computation here is computing the distance between the two particles and in computing both updateParticles based on this observation (notice the highlighted lines)
// ...
for (let i = 0; i < particles.length - 1; i++) {
for (let j = i + 1; j < particles.length; j++) {
// we compute not just the force from j to i but also the one from i to j
// (this way we compute distance between i and j only once)
let force_ij = p.createVector(particles[j].x, particles[j].y).sub(p.createVector(particles[i].x, particles[i].y));
let force_ji = force_ij.copy();
let distance = force_ij.mag();
if (distance < ctxt.cutoffMinDistance) distance = ctxt.cutoffMinDistance;
let strength_ij = 1;
let strength_ji = 1;
let multiplier = 1;
if (distance <= ctxt.zeroForceDistance) {
multiplier = 1 - p.map(distance, ctxt.cutoffMinDistance, ctxt.zeroForceDistance, 0, 1);
strength_ij = -1 * multiplier; // we consider max repulsion to be -1
strength_ji = strength_ij;
} else if (distance <= ctxt.cutoffMaxDistance) {
multiplier = p.map(distance, ctxt.zeroForceDistance, ctxt.cutoffMaxDistance, 0, 1);
strength_ij = multiplier * ctxt.colorForceMatrix[particles[i].type][particles[j].type];
strength_ji = multiplier * ctxt.colorForceMatrix[particles[j].type][particles[i].type];
} else {
strength_ij = 0;
strength_ji = 0;
}
force_ij.setMag(strength_ij);
force_ji.setMag(strength_ji);
forcesX[i] += force_ij.x;
forcesY[i] += force_ij.y;
forcesX[j] -= force_ji.x; // opposite force direction
forcesY[j] -= force_ji.y;
}
}
// continue with applying motion model using forcesX[i] and forcesY[i] for every particle
// ...
With this simple change we reduce redundant force calculations by roughly half. This is just the start.
Now let's look at something a little more subtle. within updateParticles we use p.createVector and a couple of related methods such as .sub(), .mag(), and .setMag(). This essentialy incurs some object creation overhead and adds pressure to javascript engine's garbage collection. We could simply get rid of these and use direct numerical operations and primitive types. let's see how that looks
// ...
for (let i = 0; i < particles.length - 1; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[j].x - particles[i].x;
const dy = particles[j].y - particles[i].y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance < ctxt.cutoffMinDistance) distance = ctxt.cutoffMinDistance;
let strength_ij = 1;
let strength_ji = 1;
let multiplier = 1;
if (distance <= ctxt.zeroForceDistance) {
multiplier = 1 - p.map(distance, ctxt.cutoffMinDistance, ctxt.zeroForceDistance, 0, 1);
strength_ij = -1 * multiplier; // we consider max repulsion to be -1
strength_ji = strength_ij;
} else if (distance <= ctxt.cutoffMaxDistance) {
multiplier = p.map(distance, ctxt.zeroForceDistance, ctxt.cutoffMaxDistance, 0, 1);
strength_ij = multiplier * ctxt.colorForceMatrix[particles[i].type][particles[j].type];
strength_ji = multiplier * ctxt.colorForceMatrix[particles[j].type][particles[i].type];
} else {
strength_ij = 0;
strength_ji = 0;
}
forcesX[i] += (dx / distance) * strength_ij;
forcesY[i] += (dy / distance) * strength_ij;
forcesX[j] -= (dx / distance) * strength_ji;
forcesY[j] -= (dy / distance) * strength_ji;
}
}
// continue with applying motion model using forcesX[i] and forcesY[i] for every particle
// ...
Even though in the first step we directly targetted redundant computations, performance effects from this new update were actually more noticable. This shows how much time savings within nested loops can impact performance.
The next small but useful step (V3) is to hoist simulation constants (cutoff distances, the force matrix) out of the nested loop into local const variables before the loop starts. Accessing a local variable is cheaper than a property lookup inside a hot loop that runs millions of times per frame.
V4 then goes one step further and looks at how we store particle data itself. OOP is nice and all but to speed up further we can look for more memory lookup efficiency. This can be achieved by storing particle data in a flat array instead of an array of objects. Here's how it looks
const PARTICLE_INFO_SIZE = 6; // x, y, vx, vy, type, velocityDampingMultiplier
const X = 0, Y = 1, VX = 2, VY = 3, TYPE = 4, DAMPING_FACTOR = 5;
let particles = new Float32Array(ctxt.numParticles * PARTICLE_INFO_SIZE);
function updateParticles(ctxt: SimulationContext) {
// 0. we precompute frequently used constants within the loops
const minDist = ctxt.cutoffMinDistance;
const maxDist = ctxt.cutoffMaxDistance;
const zeroForceDist = ctxt.zeroForceDistance;
const colorForceMatrix = ctxt.colorForceMatrix;
const n = ctxt.numParticles;
// ...
for (let i = 0; i < n - 1; i++) {
let baseI = i * PARTICLE_INFO_SIZE;
let xi = particles[baseI + X];
let yi = particles[baseI + Y];
let typeI = particles[baseI + TYPE];
for (let j = i + 1; j < n; j++) {
let baseJ = j * PARTICLE_INFO_SIZE;
let xj = particles[baseJ + X];
let yj = particles[baseJ + Y];
let typeJ = particles[baseJ + TYPE];
const dx = xj - xi;
const dy = yj - yi;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance < minDist) distance = minDist;
let strength_ij = 1;
let strength_ji = 1;
let multiplier = 1;
if (distance <= zeroForceDist) {
multiplier = 1 - p.map(distance, minDist, zeroForceDist, 0, 1);
strength_ij = -1 * multiplier; // we consider max repulsion to be -1
strength_ji = strength_ij;
} else if (distance <= maxDist) {
multiplier = p.map(distance, zeroForceDist, maxDist, 0, 1);
strength_ij = multiplier * colorForceMatrix[typeI][typeJ];
strength_ji = multiplier * colorForceMatrix[typeJ][typeI];
} else {
strength_ij = 0;
strength_ji = 0;
}
forcesX[i] += (dx / distance) * strength_ij;
forcesY[i] += (dy / distance) * strength_ij;
forcesX[j] -= (dx / distance) * strength_ji;
forcesY[j] -= (dy / distance) * strength_ji;
}
}
// continue with applying motion model using forcesX[i] and forcesY[i] for every particle
// ...
}
Other methods of course need to be adapted as we no longer have objects of type Particle.
With these optimisations we can easily render slightly over 2000 particles at 60fps on a Mac Mini M4 Pro which was our initial objective.
Examples
In this section I want to share a couple of examples of how this simulation can be used to create interesting visual effects. in all of these examples the simulation parameters are kept the same except for the color force matrix. Initially particles are distrubuted around a circle and the boundary conditions make it so they are constrained within a white rectangular box.
What's coming in part 2?
In part 2 we break the