Particle effects greatly spice up game visuals. They are usually not the main focus of a game, but many games rely on particle effects to increase their visual richness. They are everywhere: dust clouds, fire, water splashes, you name it. Particle effects are usually implemented with discrete emitter movement and discrete emission “bursts”. Most of the time, everything looks just fine; however, things break down when you have a fast-moving emitter and high emission rate. This is when sub-frame interpolation comes into play.
Demo
This Flash demo shows the difference between a common implementation of a fast-moving emitter and the sub-frame interpolation approach at different speeds.
Tip: Sub-frame interpolation is slightly more computationally expensive than regular implementation. So if your particle effects look just fine without sub-frame interpolation, it’s usually a good idea not to use sub-frame interpolation at all.A Common Implementation
First, let’s take a look at a common implementation of particle effects. I will present a very minimalistic implementation of a point emitter; on each frame, it creates new particles at its position, integrates existing particles, keeps track of each particle’s life, and removes dead particles.
For simplicity’s sake, I will not use object pools to reuse dead particles; also, I will use the Vector.splice
method to remove dead particles (you usually do not want to do this because Vector.splice
is a linear-time operation). The main focus of this tutorial is not efficiency, but how the particles are initialized.
Here are some helper functions we’ll need later:
// linear interpolation public function lerp(a:Number, b:Number, t:Number):Number { return a + (b - a) * t; } // returns a uniform random number public function random(average:Number, variation:Number):Number { return average + 2.0 * (Math.random() - 0.5) * variation; }
And below is the Particle
class. It defines some common particle properties, including lifetime, grow and shrink time, position, rotation, linear velocity, angular velocity, and scale. In the main update loop, position and rotation are integrated, and the particle data is finally dumped into the display object represented by the particle. The scale is updated based on the particle’s remaining life, compared to its grow and shrink time.
public class Particle { // display object represented by this particle public var display:DisplayObject; // current and initial life, in seconds public var initLife:Number; public var life:Number; // grow time in seconds public var growTime:Number; // shrink time in seconds public var shrinkTime:Number; // position public var x:Number; public var y:Number; // linear velocity public var vx:Number; public var vy:Number; // orientation angle in degrees public var rotation:Number; // angular velocity public var omega:Number; // initial & current scale public var initScale:Number; public var scale:Number; // constructor public function Particle(display:DisplayObject) { this.display = display; } // main update loop public function update(dt:Number):void { // integrate position x += vx * dt; y += vy * dt; // integrate orientation rotation += omega * dt; // decrement life life -= dt; // calculate scale if (life > initLife - growTime) scale = lerp(0.0, initScale, (initLife - life) / growTime); else if (life < shrinkTime) scale = lerp(initScale, 0.0, (shrinkTime - life) / shrinkTime); else scale = initScale; // dump particle data into display object display.x = x; display.y = y; display.rotation = rotation; display.scaleX = display.scaleY = scale; } }
And finally, we have the point emitter itself. In the main update loop, new particles are created, all particles are updated, and then dead particles are removed. The rest of this tutorial will focus on the particle initialization within the createParticles()
method.
public class PointEmitter { // particles per second public var emissionRate:Number; // position of emitter public var position:Point; // particle life & variation in seconds public var particleLife:Number; public var particleLifeVar:Number; // particle scale & variation public var particleScale:Number; public var particleScaleVar:Number; // particle grow & shrink time in lifetime percentage (0.0 to 1.0) public var particleGrowRatio:Number; public var particleShrinkRatio:Number; // particle speed & variation public var particleSpeed:Number; public var particleSpeedVar:Number; // particle angular velocity variation in degrees per second public var particleOmegaVar:Number; // the container new particles are added to private var container:DisplayObjectContainer; // the class object for instantiating new particles private var displayClass:Class; // vector that contains particle objects private var particles:Vector.<Particle>; // constructor public function PointEmitter ( container:DisplayObjectContainer, displayClass:Class ) { this.container = container; this.displayClass = displayClass; this.position = new Point(); this.particles = new Vector.<Particle>(); } // creates a new particle private function createParticles(numParticles:uint, dt:Number):void { for (var i:uint = 0; i < numParticles; ++i) { var p:Particle = new Particle(new displayClass()); container.addChild(p.display); particles.push(p); // initialize rotation & scale p.rotation = random(0.0, 180.0); p.initScale = p.scale = random(particleScale, particleScaleVar); // initialize life & grow & shrink time p.initLife = random(particleLife, particleLifeVar); p.growTime = particleGrowRatio * p.initLife; p.shrinkTime = particleShrinkRatio * p.initLife; // initialize linear & angular velocity var velocityDirectionAngle:Number = random(0.0, Math.PI); var speed:Number = random(particleSpeed, particleSpeedVar); p.vx = speed * Math.cos(velocityDirectionAngle); p.vy = speed * Math.sin(velocityDirectionAngle); p.omega = random(0.0, particleOmegaVar); // initialize position & current life p.x = position.x; p.y = position.y; p.life = p.initLife; } } // removes dead particles private function removeDeadParticles():void { // It's easy to loop backwards with splicing going on. // Splicing is not efficient, // but I use it here for simplicity's sake. var i:int = particles.length; while (--i >= 0) { var p:Particle = particles[i]; // check if particle's dead if (p.life < 0.0) { // remove from container container.removeChild(p.display); // splice it out particles.splice(i, 1); } } } // main update loop public function update(dt:Number):void { // calculate number of new particles per frame var newParticlesPerFrame:Number = emissionRate * dt; // extract integer part var numNewParticles:uint = uint(newParticlesPerFrame); // possibly add one based on fraction part if (Math.random() < newParticlesPerFrame - numNewParticles) ++numNewParticles; // first, create new particles createParticles(numNewParticles, dt); // next, update particles for each (var p:Particle in particles) p.update(dt); // finally, remove all dead particles removeDeadParticles(); } }
If we use this particle emitter and make it move in a circular motion, this is what we’ll get:
Let’s Make It Faster
Looks fine, right? Let’s see what happens if we increase the emitter’s movement speed:
See the discrete point “bursts”? These are due to how the current implementation assumes that the emitter is “teleporting” to discrete points across frames. Also, new particles within each frame are initialized as if they are created at the same time and bursted out at once.
Sub-Frame Interpolation to the Rescue!
Let’s now focus on the specific part of code that results in this artifact in the PointEmitter.createParticles()
method:
p.x = position.x; p.y = position.y; p.life = p.initLife;
To compensate for the discrete emitter movement and make it look as if the emitter movement is smooth, also simulating continuous particle emission, we are going to apply sub-frame interpolation.
In the PointEmitter
class, we’ll need a Boolean flag for turning on sub-frame interpolation, and an extra Point
for keeping track of the previous position:
public var useSubFrameInterpolation:Boolean; private var prevPosition:Point;
At the beginning of the PointEmitter.update()
method, we need a first-time initialization, which assigns the current position to prevPosition
. And at the end of the PointEmitter.update()
method, we will record the current position and save it to prevPosition
.
So this is what the new PointEmitter.update()
method looks like (the highlighted lines are new):
public function update(dt:Number):void { // first-time initialization if (!prevPosition) prevPosition = position.clone(); var newParticlesPerFrame:Number = emissionRate * dt; var numNewParticles:uint = uint(newParticlesPerFrame); if (Math.random() < newParticlesPerFrame - numNewParticles) ++numNewParticles; createParticles(numNewParticles, dt); for each (var p:Particle in particles) p.update(dt); removeDeadParticles(); // record previous position prevPosition = position.clone(); }
Finally, we’ll apply sub-frame interpolation to particle initialization in the PointEmitter.createParticles()
method. To simulate continuous emission, the initialization for particle position now linearly interpolates between the emitter’s current and previous position. The particle lifetime initialization also simulates the “time elapsed” since the last frame up till the particle’s creation. The “time elapsed” is a fraction of dt
and is also used to integrate the particle position.
We will therefore change the following code inside the for
loop in the PointEmitter.createParticles()
method:
p.x = position.x; p.y = position.y; p.life = p.initLife;
…to this (remember that i
is the loop variable):
if (useSubFrameInterpolation) { // sub-frame interpolation var t:Number = Number(i) / Number(numParticles); var timeElapsed:Number = (1.0 - t) * dt; p.x = lerp(prevPosition.x, position.x, t); p.y = lerp(prevPosition.y, position.y, t); p.x += p.vx * timeElapsed; p.y += p.vy * timeElapsed; p.life = p.initLife - timeElapsed; } else { // regular initialization p.x = position.x; p.y = position.y; p.life = p.initLife; }
Now, this is what it looks like when the particle emitter is moving at high speed with sub-frame interpolation:
Much better!
Sub-Frame Interpolation Is Not Perfect
Unfortunately, sub-frame interpolation using linear interpolation is still not perfect. If we further increase the speed of the emitter’s circular motion, this is what we’ll get:
This artifact is caused by trying to match the circular curve with linear interpolation. One way to remedy this is not to just keep track of the emitter’s position in the previous frame, but instead to keep track of previous position within multiple frames, and interpolate between these points using smooth curves (like Bezier curves).
In my opinion, however, linear interpolation is more than enough. Most of the time, you won’t have particle emitters moving fast enough to cause sub-frame interpolation with linear interpolation to break down.
Conclusion
Particle effects can break down when the particle emitter is moving at a high speed and has a high emission rate. The discrete nature of the emitter becomes visible. To improve the visual quality, use sub-frame interpolation to simulate smooth emitter movement and continuous emission. Without introducing too much overhead, linear interpolation is usually used.
However, a different artifact would start showing up if the emitter moves even faster. Smooth curve interpolation can be used to fix this problem, but linear interpolation usually works well enough and is a nice balance between efficiency and visual quality.