Advertisement
  1. Game Development
  2. Programming

Creating Smooth Particle Emission With Sub-Frame Interpolation

Scroll to top
Read Time: 10 min

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.


Click to switch between different implementations of the interpolation 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:

1
2
// linear interpolation

3
public function lerp(a:Number, b:Number, t:Number):Number
4
{
5
  return a + (b - a) * t;
6
}
7
8
// returns a uniform random number

9
public function random(average:Number, variation:Number):Number
10
{
11
  return average + 2.0 * (Math.random() - 0.5) * variation;
12
}

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.

1
2
public class Particle 
3
{
4
  // display object represented by this particle

5
  public var display:DisplayObject;
6
  
7
  // current and initial life, in seconds

8
  public var initLife:Number;
9
  public var life:Number;
10
  
11
  // grow time in seconds

12
  public var growTime:Number;
13
  
14
  // shrink time in seconds

15
  public var shrinkTime:Number;
16
  
17
  // position

18
  public var x:Number;
19
  public var y:Number;
20
  
21
  // linear velocity

22
  public var vx:Number;
23
  public var vy:Number;
24
  
25
  // orientation angle in degrees

26
  public var rotation:Number;
27
  
28
  // angular velocity

29
  public var omega:Number;
30
  
31
  // initial & current scale

32
  public var initScale:Number;
33
  public var scale:Number;
34
  
35
  // constructor

36
  public function Particle(display:DisplayObject)
37
  {
38
    this.display = display;
39
  }
40
  
41
  // main update loop

42
  public function update(dt:Number):void
43
  {
44
    // integrate position

45
    x += vx * dt;
46
    y += vy * dt;
47
    
48
    // integrate orientation

49
    rotation += omega * dt;
50
    
51
    // decrement life

52
    life -= dt;
53
    
54
    // calculate scale

55
    if (life > initLife - growTime)
56
      scale = lerp(0.0, initScale, (initLife - life) / growTime);
57
    else if (life < shrinkTime)
58
      scale = lerp(initScale, 0.0, (shrinkTime - life) / shrinkTime);
59
    else
60
      scale = initScale;
61
    
62
    // dump particle data into display object

63
    display.x = x;
64
    display.y = y;
65
    display.rotation = rotation;
66
    display.scaleX = display.scaleY = scale;
67
  }
68
}

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.

1
2
public class PointEmitter
3
{
4
  // particles per second

5
  public var emissionRate:Number;
6
  
7
  // position of emitter

8
  public var position:Point;
9
  
10
  // particle life & variation in seconds

11
  public var particleLife:Number;
12
  public var particleLifeVar:Number;
13
  
14
  // particle scale & variation

15
  public var particleScale:Number;
16
  public var particleScaleVar:Number;
17
  
18
  // particle grow & shrink time in lifetime percentage (0.0 to 1.0)

19
  public var particleGrowRatio:Number;
20
  public var particleShrinkRatio:Number;
21
  
22
  // particle speed & variation

23
  public var particleSpeed:Number;
24
  public var particleSpeedVar:Number;
25
  
26
  // particle angular velocity variation in degrees per second

27
  public var particleOmegaVar:Number;
28
  
29
  // the container new particles are added to

30
  private var container:DisplayObjectContainer;
31
  
32
  // the class object for instantiating new particles

33
  private var displayClass:Class;
34
  
35
  // vector that contains particle objects

36
  private var particles:Vector.<Particle>;
37
  
38
  // constructor

39
  public function PointEmitter
40
  (
41
    container:DisplayObjectContainer, 
42
    displayClass:Class
43
  )
44
  {
45
    this.container = container;
46
    this.displayClass = displayClass;
47
    this.position = new Point();
48
    this.particles = new Vector.<Particle>();
49
  }
50
  
51
  // creates a new particle

52
  private function createParticles(numParticles:uint, dt:Number):void
53
  {
54
    for (var i:uint = 0; i < numParticles; ++i)
55
    {
56
        var p:Particle = new Particle(new displayClass());
57
        container.addChild(p.display);
58
        particles.push(p);
59
        
60
        // initialize rotation & scale

61
        p.rotation = random(0.0, 180.0);
62
        p.initScale = p.scale = random(particleScale, particleScaleVar);
63
        
64
        // initialize life & grow & shrink time

65
        p.initLife = random(particleLife, particleLifeVar);
66
        p.growTime = particleGrowRatio * p.initLife;
67
        p.shrinkTime = particleShrinkRatio * p.initLife;
68
        
69
        // initialize linear & angular velocity

70
        var velocityDirectionAngle:Number = random(0.0, Math.PI);
71
        var speed:Number = random(particleSpeed, particleSpeedVar);
72
        p.vx = speed * Math.cos(velocityDirectionAngle);
73
        p.vy = speed * Math.sin(velocityDirectionAngle);
74
        p.omega = random(0.0, particleOmegaVar);
75
        
76
        // initialize position & current life

77
        p.x = position.x;
78
        p.y = position.y;
79
        p.life = p.initLife;
80
    }
81
  }
82
  
83
  // removes dead particles

84
  private function removeDeadParticles():void
85
  {
86
    // It's easy to loop backwards with splicing going on.

87
    // Splicing is not efficient, 

88
    // but I use it here for simplicity's sake.

89
    var i:int = particles.length;
90
    while (--i >= 0)
91
    {
92
      var p:Particle = particles[i];
93
      
94
      // check if particle's dead

95
      if (p.life < 0.0)
96
      {
97
        // remove from container

98
        container.removeChild(p.display);
99
        
100
        // splice it out

101
        particles.splice(i, 1);
102
      }
103
    }
104
  }
105
  
106
  // main update loop

107
  public function update(dt:Number):void
108
  {
109
    // calculate number of new particles per frame

110
    var newParticlesPerFrame:Number = emissionRate * dt;
111
    
112
    // extract integer part

113
    var numNewParticles:uint = uint(newParticlesPerFrame);
114
    
115
    // possibly add one based on fraction part

116
    if (Math.random() < newParticlesPerFrame - numNewParticles)
117
      ++numNewParticles;
118
    
119
    // first, create new particles

120
    createParticles(numNewParticles, dt);
121
    
122
    // next, update particles

123
    for each (var p:Particle in particles)
124
      p.update(dt);
125
    
126
    // finally, remove all dead particles

127
    removeDeadParticles();
128
  }
129
}

If we use this particle emitter and make it move in a circular motion, this is what we'll get:

Common-Implementation-SlowCommon-Implementation-SlowCommon-Implementation-Slow

Let's Make It Faster

Looks fine, right? Let's see what happens if we increase the emitter's movement speed:

Common-Implementation-FastCommon-Implementation-FastCommon-Implementation-Fast

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:

1
2
p.x = position.x;
3
p.y = position.y;
4
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:

1
2
public var useSubFrameInterpolation:Boolean;
3
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):

1
2
public function update(dt:Number):void
3
{
4
  // first-time initialization

5
  if (!prevPosition)
6
    prevPosition = position.clone();
7
  
8
  var newParticlesPerFrame:Number = emissionRate * dt;
9
  var numNewParticles:uint = uint(newParticlesPerFrame);
10
  if (Math.random() < newParticlesPerFrame - numNewParticles)
11
    ++numNewParticles;
12
  
13
  createParticles(numNewParticles, dt);
14
  
15
  for each (var p:Particle in particles)
16
    p.update(dt);
17
  
18
  removeDeadParticles();
19
  
20
  // record previous position

21
  prevPosition = position.clone();
22
}

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:

1
2
p.x = position.x;
3
p.y = position.y;
4
p.life = p.initLife;

...to this (remember that i is the loop variable):

1
2
if (useSubFrameInterpolation)
3
{
4
  // sub-frame interpolation

5
  var t:Number = Number(i) / Number(numParticles);
6
  var timeElapsed:Number = (1.0 - t) * dt;
7
  p.x = lerp(prevPosition.x, position.x, t);
8
  p.y = lerp(prevPosition.y, position.y, t);
9
  p.x += p.vx * timeElapsed;
10
  p.y += p.vy * timeElapsed;
11
  p.life = p.initLife - timeElapsed;
12
}
13
else
14
{
15
  // regular initialization

16
  p.x = position.x;
17
  p.y = position.y;
18
  p.life = p.initLife;
19
}

Now, this is what it looks like when the particle emitter is moving at high speed with sub-frame interpolation:

Sub-Frame-Interpolation-FastSub-Frame-Interpolation-FastSub-Frame-Interpolation-Fast

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:

Sub-Frame-Interpolation-Super-FastSub-Frame-Interpolation-Super-FastSub-Frame-Interpolation-Super-Fast

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.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Game Development tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.