Quantcast
Channel: Envato Tuts+ Game Development
Viewing all articles
Browse latest Browse all 728

Creating Smooth Particle Emission With Sub-Frame Interpolation

$
0
0

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:

// 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:

Common-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-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:

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:

Sub-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-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.


Viewing all articles
Browse latest Browse all 728

Trending Articles