As part of my university project, which involved building a custom engine, I implemented a particle system. I found it to be a particularly satisfying feature to develop, so I decided to share my approach.
The result
Let's run it from the back and look at the final result first. This is how the particle system looked in the editor.
The particle system can also be seen in the game I developed using this engine. It significantly improved the visual appeal, even with a simple particle system like this.
The approach
One of the primary goals of the entire project was accustomization to the entity-component-system architecture, which has heavily reflected on the approach I took. My goal with this feature was to support tens of thousands of particles running seamlessly at 60+ FPS.
Particles
Let's start at the root with a particle. An individual particle is a very simple entity, which defines only a few values: its age, its lifetime, and its velocity. Generally speaking, we define an entity that has a direction of travel and a ticking timer before it dies.
I will be omitting velocity, since that has been handled in my RigidbodyComponent, therefore this is how a simple ParticleComponent looked like:
struct ParticleComponent
{
float age = 0.0f;
float lifetime = 1.0f;
};We will return to particles a bit later, but this simple explanation is enough for now.
Particle Profiles
The next concept I want to touch upon is a particle profile. In my particle system, I used particle profiles to modify the behaviour of individual particles. In other words, it was a set of properties that change the color, initial velocity, or size. The important point is that multiple particle systems can share one particle profile.
These are all the properties I found useful while creating all effects for my game. Most of them are self-explanatory, but I will explain a few of them:
Looping- defines whether the profile is one-shot or infinitely loopingDuration- ifLoopingis false, the effect will last forDurationsecondsEmission Rate- how many particles a second to emitLifetimeLifetime VarianceInitial VelocityVelocity VarianceStart ScaleEnd ScaleScale VarianceStart ColorEnd ColorColor VarianceStart RotationRotation VarianceAngular VelocityAngular Velocity VarianceUse Gravity
In my engine, I treated particle profiles as a resource, which meant they were serialized on disk in a .particle_profile file. The contents of this JSON file looked like this:
{
"m_looping": false,
"m_duration": 3.0,
"m_emissionRate": 2500.0,
"m_lifetime": 10.0,
"m_lifetimeVariance": 4.0,
"m_initialVelocity": [0.0, 18.0, 0.0],
"m_velocityVariance": [35.0, 18.0, 35.0],
"m_startScale": [0.8, 0.8, 0.8],
"m_endScale": [8.0, 8.0, 8.0],
"m_scaleVariance": [2.5, 2.5, 2.5],
"m_startColor": [1.0, 0.5, 0.1],
"m_endColor": [0.03, 0.03, 0.03],
"m_colorVariance": [0.5, 0.4, 0.2],
"m_startRotation": [0.0, 0.0, 0.0],
"m_rotationVariance": [6.28318, 6.28318, 6.28318],
"m_angularVelocity": [0.0, 0.0, 0.0],
"m_angularVelocityVariance": [12.0, 12.0, 12.0],
"m_useGravity": true,
"m_material": 12185720181781197242,
"m_mesh": 1797231269285335481
}There are two fields included that are missing in my earlier list of properties: m_mesh and m_material. When spawning a particle, we have to decide on how the particle is going to look like, which is exactly why these two values exist. In all of the particle effects I have shown earlier, I used a cube mesh and a basic material.
However, there is one more issue. If we look at the properties again, we have many start and end properties (start scale, end scale, start color, end color). These are generally fine, since we can use linear interpolation for them. We can divide the particle.age by particle.lifetime, which gives us a number in the range of 0 to 1. If we feed this to a linear interpolation of the Start Color and End Color, we get the correct particle color at this stage of its lifetime.
The issue comes with variance. If the properties are not uniform across the whole system, we have to store particle-specific properties in the ParticleComponent. Variant lifetime is already solved, since we store the lifetime field. Variant velocity only takes an effect when spawning, so that is also solved. With variant color, I only offset the startColor and then move towards a same color, so that is also solved. This is not per se optimal, but it was enough for my effects.
However scale variance requires some more work: I would like particles to start at a different scale and also end at a different scale, so that's data I had to add to the ParticleComponent. While we're at it, let's also store a reference to the parent system of the spawned particle, we will need it later:
struct ParticleComponent
{
UUID systemId = UUID::null;
float age = 0.0f;
float lifetime = 1.0f;
vec3 startScale = vec3(1.0f);
vec3 endScale = vec3(1.0f);
};Particle System
Connecting these concepts together is the particle system. The ParticleSystemComponent is the component that you attach to an entity, select a particle profile, hit play, and see the particles spawning. Let's start with the code:
struct ParticleSystemComponent
{
UUID id;
ResourceHandle<ParticleProfile> profile;
bool worldSpace = false;
// Runtime
float r_emissionAccumulator = 0.0f;
float r_durationAccumulator = 0.0f;
};We store a reference to the profile, which acts as the data template to create individual particles. We define whether the particle should operate in local-space or world-space. This flag plays a role when spawning the particles, but we will get to that later. Finally, we store a few runtime variables that are used in the ECS system.
With all this out of the way, we can get into the actual implementation of the Particle System Class.
Particle System Class
What I mean by Particle System Class is the actual ECS system that simulates particles. There is sadly a big ambiguity around the word system when it comes to working with an ECS. In theory, this system has a simple task:
foreach system in particleSystems
if (ShouldSpawnParticle(system))
SpawnParticle(system)
foreach particle in system.particles
Simulate(particle)This is heavily oversimplified and also sort of anti-ECS, but it conveys the main idea.
That is why I chose a bit of a different approach:
systemIdToProfile = []
foreach system : particleSystems
systemIdToProfile[system.id] = system.profile
foreach particle : particles
profile = systemIdToProfile[particle.systemId]
Simulate(particle, profile)I loop through all of the systems to cache their corresponding profiles. After that, I resolve the simulation of all particles in one pass, with referring back to the cached profile for simulation data.
When spawning: I create a new entity, decide whether to create in world-space or local-space, and then apply all the starting properties from the profile.
This is the implementation of my particle system, with some details being omitted and added comments for clarity:
Conclusion and improvements
In the end, I had about three days of work allocated for the whole particle system. It turned out to be a very good decision for the engine, as it made the game more engaging. In regards of the performance, it ran quite nice, though I did get into performance issues with huge particle effects. However, the primary reason was the lack of instancing, as a particle system is something that would benefit greatly from that.
If I had more time to work on this, I would definitely implement the following:
- GPU instancing
- More emitter shapes
- Billboards
- Support easing curves and color gradients
Thanks for reading. Until next time,
Seb