A Particle Is Eleven Numbers
Particle systems are a good test of whether someone can design for volume. Everything in one is cheap individually and there are tens of thousands of them, so every per-particle byte and every per-particle branch is multiplied by a number large enough to matter.
The system in my second engine is the code I submitted as a portfolio piece when I applied to EA in 2013. Going back through it, the decisions that make it work are all about pushing per-particle cost outward: into the emitter, into a precomputed table, into a seed.
This is the sixth post taking one system at a time out of my custom engines.
What a Particle Stores
The whole per-particle state:
class ManicParticle {
public:
Vector3 position;
Vector3 moveVector;
float age;
float sizeVariance;
int seed1;
int seed2;
float rotation;
};Position, velocity, age, a size variance, two seeds, and a rotation. No color. No current size. No lifetime. No reference to the emitter that spawned it.
The absences are the design. Color and size aren't stored because they're derivable from age. Lifetime isn't stored because it belongs to the emitter and is the same for every particle it spawns. Anything shared lives once on the emitter rather than ten thousand times on the particles.
Two Seeds Instead of Every Varied Value
The seeds are the part I'd point at as the neatest idea in here.
A particle system needs variation. Particles that behave identically look like a machine. The obvious way to get it is to roll random numbers at spawn time and store the results, which means a stored field for every property that varies.
Storing two seeds instead means any number of varied properties can be derived from them on demand, at the cost of two integers. Variation becomes a function of the seed rather than a set of remembered rolls.
That has a second benefit beyond memory. Because the seed is stored rather than the outcome, the same particle produces the same variation every time it's evaluated, so an effect is reproducible. A stored-outcome system that reseeds anywhere in its update loop drifts between runs, which makes an effect impossible to tune with confidence and impossible to reproduce in a bug report.
It's the same principle as resolving UI geometry at query time rather than caching it. Store the input, derive the output, and the derived value can never be stale or inconsistent.
The Curve Is Computed Once
Every particle in an emitter shares the same appearance curve: start color to end color, start size to end size, over the particle's life. The straightforward implementation interpolates that per particle per frame, which for ten thousand particles is four color interpolations and a size interpolation each, all computing points on the same curve.
So the curve is baked once into a table:
lookupSize = transitionTime / interval;
lookupTable = new ManicParticleState[lookupSize];
for (int i = 0; i < lookupSize; i++) {
float t = (float)i / (float)lookupSize;
lookupTable[i].color.r = linearInterpolation(start.color.r, end.color.r, t);
// g, b, a, size
}A particle's per-frame appearance cost becomes an index and a clamp:
int index = offset * lookupSize;
index = min(index, lookupSize - 1);
return &lookupTable[index];The interval is fixed at a sixtieth of a second, and the code says explicitly that this is so the interpolation rate stays independent of the frame rate. The table has the same resolution whether the game runs at 30 or 144 frames per second, so an effect looks the same on every machine rather than subtly smoother on a fast one.
The tradeoff is quantization. A particle's appearance steps through discrete table entries rather than varying continuously, and at a sixtieth of a second per step nobody has ever noticed. Trading exactness nobody can perceive for a cost multiplied by ten thousand is the correct trade, and knowing which quantity is which is most of the skill.
Rebuilding When the Inputs Change
A baked table is a cache, and a cache needs invalidation. This one keeps backup copies of its own inputs and compares:
if (start != startbk || end != endbk || life != lifebk) {
startbk = start; endbk = end; lifebk = life;
// rebuild
}That exists because of the editor. A designer dragging a color changes the start state, and the transition notices on its next update and rebuilds. No mutation site has to signal anything, no dirty flag can be forgotten, and the editor gets live feedback without any coupling between the tool and the cache.
Comparing against a known-good copy is a pattern I later used again in generated code, where deep equality answers whether an edited object has changed. It costs the memory of one extra copy and removes the entire class of bug where something changed and nothing noticed.
Forces as an Orthogonal Set
The force system is small and covers more than its size suggests:
enum ForceType { ForceDirectional, ForceRadialDeflector,
ForceRadialAttractor, ForceNoise, ForceDampener };
enum ApplyType { ApplyAccelerate, ApplyInitial, ApplyConstant };Five kinds of force, three ways to apply each. Directional is gravity or wind. Radial attracts or repels from a point. Noise adds turbulence. The dampener removes energy.
The application mode is the part that multiplies the value. The same directional force applied as acceleration is gravity, applied as an initial impulse is a launch velocity, and applied as a constant is a conveyor. One force type, three completely different behaviors, chosen by an enum rather than by three separate implementations.
That's what to aim for in any authored system. A small set of primitives times a small set of modifiers covers far more of the space than an equivalent number of specific features, and every combination works because the two axes are independent. The failure mode it avoids is a system that grows a new force type every time a designer wants something slightly different.
The Buffer Never Allocates
Particles live in a ring over a preallocated array:
out = buffer + end++;
if (end >= bufferLength) end = 0;
if (end == start) pop();
return out;push() hands back a pointer into the array, advances, wraps, and when the ring is full it drops the oldest particle to make room.
It never allocates during simulation, so there's no allocator contention and no fragmentation in the hottest loop in the engine. It can't fail, because there's always a slot once the oldest is evicted. And the memory is contiguous, so iterating particles walks linearly through memory, which is what the hardware prefers.
The eviction policy is the right one for the domain. Running out of particles by dropping the oldest degrades an effect gracefully, because the oldest particles are the most faded and least noticeable. The alternatives, refusing to spawn or allocating more, produce a visible gap or a frame spike.
What I'd Change
Particles are an array of structs, and this is the system where an array of arrays would pay. Updating position touches position and moveVector and never looks at rotation, age, or the seeds, but the whole 40-odd byte struct is pulled into cache regardless. Splitting into parallel arrays would let a position update stream only the data it uses. In 2012 I'd never heard the term data-oriented design. The ring buffer got the contiguity half of the idea by accident and the layout half not at all.
I'd also give the lookup table an explicit resolution rather than deriving its size from a lifetime and a fixed interval. A long-lived effect currently gets a large table it doesn't need, since the curve doesn't get more interesting just because the particles live longer.
And I'd make the emitter own the transition rather than the other way around. Two emitters sharing one appearance curve currently duplicate the table, which is the same per-instance-versus-shared question the design answers correctly everywhere else.