Last Update:
Flexible particle system - Updaters
Table of Contents
In the previous particle post the particle generation system was introduced. But after a new particle is created we need to have a way to update its parameters. This time we will take a look at updaters - those are the classes that, actually, makes things moving and living.
The Series
- Initial Particle Demo
- Introduction
- Particle Container 1 - problems
- Particle Container 2 - implementation
- Generators & Emitters
- Updaters (this post)
- Renderer
- Introduction to Optimization
- Tools Optimizations
- Code Optimizations
- Renderer Optimizations
- Summary
Introduction
Updaters also follow SRP principle. They are used only to update particle’s parameters and finally decide if the particle is alive or not. We could also go further and create ‘killers’ - that would kill particles, but probably it would be too exaggerated design.
The gist is located here: fenbf / BasicParticleUpdaters
The Updater Interface
class ParticleUpdater
{
public:
ParticleUpdater() { }
virtual ~ParticleUpdater() { }
virtual void update(double dt, ParticleData *p) = 0;
};
Updater gets delta time and all the particle data. It iterates through alive particles and does some things. The class is quite ‘broad’ and gives a lot of possibilities. Someone might even point out that it gives too much options. But at this time I do not think we should restrict this behaviour.
Ideally an updater should focus only on one set of params. For instance EulerUpdater or ColorUpdater.
Particle Updaters Implementation
Let’s have a look at EulerUpdater:
Here is an example of BoxPosGen
class EulerUpdater : public ParticleUpdater
{
public:
glm::vec4 m_globalAcceleration{ 0.0f };
public:
virtual void update(double dt, ParticleData *p) override;
};
void EulerUpdater::update(double dt, ParticleData *p)
{
const glm::vec4 globalA{ dt * m_globalAcceleration.x,
dt * m_globalAcceleration.y,
dt * m_globalAcceleration.z,
0.0 };
const float localDT = (float)dt;
const unsigned int endId = p->m_countAlive;
for (size_t i = 0; i < endId; ++i)
p->m_acc[i] += globalA;
for (size_t i = 0; i < endId; ++i)
p->m_vel[i] += localDT * p->m_acc[i];
for (size_t i = 0; i < endId; ++i)
p->m_pos[i] += localDT * p->m_vel[i];
}
Pretty simple! As with generators we can mix different updaters to create desired effect. In my old particle system I would usually have one huge ‘updater’ (although the whole system was totally different). Then, when I wanted to have a slightly modified effect I needed to copy and paste common code again and again. This was definitely not a best pattern! You might treat this like an antipattern :)
Other updaters:
FloorUpdater
- can bounce particle off the floor.AttractorUpdater
- attractors in a gravity system.BasicColorUpdater
- generate current particle color based on time and min and max color.PosColorUpdater
- current color comes from position.VelColorUpdater
- current color comes from velocity.BasicTimeUpdater
- measures the time of life of a particle. It kills a particle if its time is over.
Example updater composition
For ‘floor effect’ I use the following code:
auto timeUpdater = std::make_shared<particles::updaters::BasicTimeUpdater>();
m_system->addUpdater(timeUpdater);
auto colorUpdater = std::make_shared<particles::updaters::BasicColorUpdater>();
m_system->addUpdater(colorUpdater);
m_eulerUpdater = std::make_shared<particles::updaters::EulerUpdater>();
m_eulerUpdater->m_globalAcceleration = glm::vec4{ 0.0, -15.0, 0.0, 0.0 };
m_system->addUpdater(m_eulerUpdater);
m_floorUpdater = std::make_shared<particles::updaters::FloorUpdater>();
m_system->addUpdater(m_floorUpdater);
You can see it here in action - from 39 sec:
Cache Usage
Mixing different updaters is a great thing of course. But please notice that it is also quite efficient. Since we use SOA container each updater uses cache in a smart way.
For instance ColorUpdater
uses only three arrays: currentColor
, startColor
and endColor
. During the computation the processor cache will be filled with only those three arrays. Remember that CPU does not read individual bytes from the memory - it reads whole cache lines - usually 64bytes.
On the other hand, if we had AOS container each particle would be ‘huge’ - one object contains all the parameters. Color updater would use only three fields. So all in all cache would be used quite ineffectively because it would have to store fields that are not involved in the update process.
Look here
and here
In the second option cache stores also members that are not used during the update process.
The problem: of course our solution is not ideal! Sometimes you might have some advanced effect that uses all parameters of a particle. For instance all parameters are used to compute final color. In this case cache will try to load all the params (from AOS) and performance can go down… but I will describe this later when we move to optimization part.
Please share any doubts about this design!
What’s Next
We have all the systems for particle creation, update and the storage… but what about rendering? Next time I will describe current, but actually simple, rendering system for particles.
Read next: Renderer
I've prepared a valuable bonus for you!
Learn all major features of recent C++ Standards on my Reference Cards!
Check it out here: