Creating realistic particle effect with HTML5 canvas



This is my first experiment with the HTML5 canvas – the so-called replacement for Flash. I tried to create a realistic smoke particle effect to go in the background of my war zone scene. As you can see above it’s worked out pretty well (unless you are using an old version of IE – which doesn’t support the canvas control).

Tips

  • Copy from real life
  • To figure out how smoke behaves I first studied a video clip frame by frame. I found that the smoke is made up of individual puffs of smoke, which expand rapidly emerging from the fire and then slowly drift upwards expanding much slower and fading out. If you want realism I recommend observing the effect in real life, rather than creating it purely from your head, or copying some one else’s particle effect.

  • The further from camera, the slower
  • It’s important to take into account the distance the particle effect is from the camera. The further away, the slower the particles will move. For instance, the fireball of a huge spaceship exploding will almost appear to expand in slow motion. If you make the fireball expand too fast it will look like a tiny explosion to the viewer.

Technical details

I embed a normal image in my html page as the background and then overlaid the canvas control on top of it, as follows:


...






On loading the image the init() function is called which positions the canvas control to lie exactly on top of the background image.

To animate the canvas we use the same render loop as we do in games. This is what it looks like:

var lastRender = new Date().getTime();
var context;
var smokeRight = new ParticleEmitter();
function render()
 {
     // time in milliseconds
     var timeElapsed = new Date().getTime() - lastRender;
     lastRender = new Date().getTime();
     smokeRight.update(timeElapsed);
     smokeRight.render(context);
     requestAnimFrame(render);
 }

requestAnimFrame is an improved version of setTimeout. It calls our render function once every 16.66 ms ( i.e. 60 FPS). Every object typically has an update and render method. update is called with a timeElapsed parameter.  timeElapsed is the time in milliseconds since the last call. If you want everything to animate smoothly it is important that you make all movement calculations relative to this parameter.

There are two “classes” (really function objects, because javascript does not have classes, as such), ParticleEmitter and Particle.  ParticleEmitter creates an array of Particles and animates them:

function ParticleEmitter()
{
	this.m_x;
	this.m_y;
	this.m_dieRate;
	this.m_image;
	this.m_speed = 0.02;
	this.m_alpha = 1.0;

	this.m_listParticle = [];

	// ParticleEmitter().init function
	// xScale = number between 0  and 1. 0 = on left side 1 = on top
	// yScale = number between 0  and 1. 0 = on top 1 = on bottom
	// particles = number of particles
	// image = smoke graphic for each particle
	this.init = function(xScale, yScale, particles, image)
	{
		this.m_x = CANVAS_WIDTH*xScale;
		this.m_y = CANVAS_HEIGHT*yScale;
		this.m_image = image;
		this.m_dieRate = 0.95;
		// start with smoke already in place
		for (var n = 0; n < particles; n++)
		{
			this.m_listParticle.push(new Particle());
			this.m_listParticle[n].init(this, n*50000*this.m_speed);
		}
	}

	this.update = function(timeElapsed)
	{
		for (var n = 0; n < this.m_listParticle.length; n++)
		{
			this.m_listParticle[n].update(timeElapsed);
		}
	}

	this.render = function(context)
	{
		for (var n = 0; n < this.m_listParticle.length; n++)
		{
			this.m_listParticle[n].render(context);
		}
	}
};

Each Particle initialises itself around the location of the emitter. The update method cause the particle to expand, fade in, then fade out and move upwards depending on the age. Each particle has a random direction, velocity and lifespan. When m_age > m_timeDie the particle is considered to be dead, at which case it is "reborn" at the location of the emitter again. This provides a continuous trail of smoke. (Well actually, I make it so that eventually the smoke stops according to the m_dieRate parameter).

The init function mentioned previously, loads the smoke puff graphic and the foreground character graphic and initialises the ParticleEmitters:

function init()
{
	var canvas = document.getElementById('tutorial');
	if (canvas.getContext)
	{
		context = canvas.getContext('2d');
	}
	else
	{
		return;
	}
	var imgBack = document.getElementById('background');
	CANVAS_WIDTH = imgBack.width;
	CANVAS_HEIGHT = imgBack.height;
	canvas.width = imgBack.width;
	canvas.height = imgBack.height;
	var xImage = imgBack.offsetLeft;
	var yImage = imgBack.offsetTop;
	var elem = imgBack.offsetParent;
	while (elem)
	{
		xImage += elem.offsetLeft;
		yImage += elem.offsetTop;
		elem = elem.offsetParent;
	}
	canvas.style.position = 'absolute';
	canvas.style.left = xImage + "px";
	canvas.style.top = (yImage) + "px";
	var imgSmoke = new Image();  
	imgSmoke.src = 'puffBlack.png';
	imgSmoke.onload = function()
	{ 
		smokeRight.init(.9, .531, 20, imgSmoke);
		smokeLeft.m_alpha = 0.3;
		smokeLeft.init(.322, .453, 30, imgSmoke);
		requestAnimFrame(render);
	};  
}

Only when the smoke image is finished loading is the first requestAnimFrame called, which kicks off the animation.

This is the graphic I used for the smoke particle:

You may notice there is also a wind effect controlled by the global windVelocity variable. This changes slightly every frame, simulating puffs of wind.

Optimisation

Finally, there is a drawing optimisation. The clearRect method clears all, or part of the canvas. We are being smart and we only clear the smallest rectangle that is needed. We keep track of this with the global variables: dirtyLeft, dirtyTop, dirtyRight, dirtyBottom. This optimisation may not actually be necessary in our case - depends on what platforms you are targetting.  The canvas control is quite fast on PCs (even older browsers like Firefox 3.6), however mobile browsers will struggle. This example works on the iPhone (albeit with slightly jittery animation)

Integration into WordPress

To get the particle effect to work in WordPress insert the following into the post:





Then you need to upload the script via ftp and specify the full url for all file references (i.e src="http://mydomain.com/wordpress/peffect.js"

References

I found the tutorial at Mozilla.org to be very useful source on the canvas control. Also, this article has some great optimisation tips.

Full listing

Finally, here is a full listing of the code used:

function ParticleEmitter()
{
	this.m_x;
	this.m_y;
	this.m_dieRate;
	this.m_image;
	this.m_speed = 0.02;
	this.m_alpha = 1.0;

	this.m_listParticle = [];

	// ParticleEmitter().init function
	// xScale = number between 0  and 1. 0 = on left side 1 = on top
	// yScale = number between 0  and 1. 0 = on top 1 = on bottom
	// particles = number of particles
	// image = smoke graphic for each particle
	this.init = function(xScale, yScale, particles, image)
	{
		// the effect is positioned relative to the width and height of the canvas
		this.m_x = CANVAS_WIDTH*xScale;
		this.m_y = CANVAS_HEIGHT*yScale;
		this.m_image = image;
		this.m_dieRate = 0.95;
		// start with smoke already in place
		for (var n = 0; n < particles; n++)
		{
			this.m_listParticle.push(new Particle());
			this.m_listParticle[n].init(this, n*50000*this.m_speed);
		}
	}

	this.update = function(timeElapsed)
	{
		for (var n = 0; n < this.m_listParticle.length; n++)
		{
			this.m_listParticle[n].update(timeElapsed);
		}
	}

	this.render = function(context)
	{
		for (var n = 0; n < this.m_listParticle.length; n++)
		{
			this.m_listParticle[n].render(context);
		}
	}
};

function Particle()
{
	this.m_x;
	this.m_y;
	this.m_age;
	this.m_xVector;
	this.m_yVector;
	this.m_scale;
	this.m_alpha;
	this.m_canRegen;
	this.m_timeDie;
	this.m_emitter;
  
	this.init = function(emitter, age)
	{
		this.m_age = age;
		this.m_emitter = emitter;
		this.m_canRegen = true;
		this.startRand();
	}

	this.isAlive = function () 
	{
		return this.m_age < this.m_timeDie;
	}

	this.startRand = function()
	{
		// smoke rises and spreads
		this.m_xVector = Math.random()*0.5 - 0.25;
		this.m_yVector = -1.5 - Math.random();
		this.m_timeDie = 20000 + Math.floor(Math.random()*12000);

		var invDist = 1.0/Math.sqrt(this.m_xVector*this.m_xVector 
			+ this.m_yVector*this.m_yVector);
		// normalise speed
		this.m_xVector = this.m_xVector*invDist*this.m_emitter.m_speed;
		this.m_yVector = this.m_yVector*invDist*this.m_emitter.m_speed;
		// starting position within a 20 pixel area 
		this.m_x = (this.m_emitter.m_x + Math.floor(Math.random()*20)-10);
		this.m_y = (this.m_emitter.m_y + Math.floor(Math.random()*20)-10);
		// the initial age may be > 0. This is so there is already a smoke trail in 
		// place at the start
		this.m_x += (this.m_xVector+windVelocity)*this.m_age;
		this.m_y += this.m_yVector*this.m_age;
		this.m_scale = 0.01;	
		this.m_alpha = 0.0;
	}

	this.update = function(timeElapsed)
	{
		this.m_age += timeElapsed;
		if (!this.isAlive()) 
		{
			// smoke eventually dies
			if (Math.random() > this.m_emitter.m_dieRate)
			{
				this.m_canRegen = false;
			}
			if (!this.m_canRegen)
			{
				return;
			}
			// regenerate
			this.m_age = 0;
			this.startRand();
			return;
		}
		// At start the particle fades in and expands rapidly (like in real life)
		var fadeIn = this.m_timeDie * 0.05;
		var startScale;
		var maxStartScale = 0.3;
		if (this.m_age < fadeIn)
		{
			this.m_alpha = this.m_age/fadeIn;
			startScale = this.m_alpha*maxStartScale; 
			// y increases quicker because particle is expanding quicker
			this.m_y += this.m_yVector*2.0*timeElapsed;
		}
		else
		{
			this.m_alpha = 1.0 - (this.m_age-fadeIn)/(this.m_timeDie-fadeIn);
			startScale = maxStartScale;
			this.m_y += this.m_yVector*timeElapsed;
		}
		// the x direction is influenced by wind velocity
		this.m_x += (this.m_xVector+windVelocity)*timeElapsed;
		this.m_alpha *= this.m_emitter.m_alpha;
		this.m_scale = 0.001 + startScale + this.m_age/4000.0;
	}

	this.render = function(ctx)
	{
		if (!this.isAlive()) return;
		ctx.globalAlpha = this.m_alpha;
		var height = this.m_emitter.m_image.height*this.m_scale;
		var width = this.m_emitter.m_image.width*this.m_scale;
		// round it to a integer to prevent subpixel positioning
		var x = Math.round(this.m_x-width/2);
		var y = Math.round(this.m_y+height/2);
		ctx.drawImage(this.m_emitter.m_image, x, y, width, height);  
		if (x < dirtyLeft)
		{
			dirtyLeft = x;
		}
		if (x+width > dirtyRight)
		{
			dirtyRight = x+width;
		}
		if (y < dirtyTop)
		{
			dirtyTop = y;
		}
		if (y+height > dirtyBottom)
		{
			dirtyBottom = y+height;
		}
	}
};

var lastRender = new Date().getTime();
var context;
var smokeRight = new ParticleEmitter();
var smokeLeft = new ParticleEmitter();
var CANVAS_WIDTH = 960;
var CANVAS_HEIGHT = 640;
// only redraw minimimum rectangle
var dirtyLeft = 0;
var dirtyTop = 0;
var dirtyRight = CANVAS_WIDTH;
var dirtyBottom = CANVAS_HEIGHT;
var windVelocity = 0.01;
var count = 0;


function init()
{
	var canvas = document.getElementById('tutorial');
	if (canvas.getContext)
	{
		context = canvas.getContext('2d');
	}
	else
	{
		return;
	}
	var imgBack = document.getElementById('background');
	// make canvas same size as background image
	CANVAS_WIDTH = imgBack.width;
	CANVAS_HEIGHT = imgBack.height;
	canvas.width = imgBack.width;
	canvas.height = imgBack.height;
	// get absolute position of background image
	var xImage = imgBack.offsetLeft;
	var yImage = imgBack.offsetTop;
	var elem = imgBack.offsetParent;
	while (elem)
	{
		xImage += elem.offsetLeft;
		yImage += elem.offsetTop;
		elem = elem.offsetParent;
	}
	// position canvas on top of background
	canvas.style.position = 'absolute';
	canvas.style.left = xImage + "px";
	canvas.style.top = yImage + "px";
	var imgSmoke = new Image();  
	imgSmoke.src = 'puffBlack.png';
	imgSmoke.onload = function()
	{ 
		smokeRight.init(.9, .531, 20, imgSmoke);
		smokeLeft.m_alpha = 0.3;
		smokeLeft.init(.322, .453, 30, imgSmoke);
		requestAnimFrame(render);
	};  
}

// shim layer with setTimeout fallback
    window.requestAnimFrame = (function(){
      return  window.requestAnimationFrame       || 
              window.webkitRequestAnimationFrame || 
              window.mozRequestAnimationFrame    || 
              window.oRequestAnimationFrame      || 
              window.msRequestAnimationFrame     || 
              function( callback ){
                window.setTimeout(callback, 17);
              };
    })();

  
function render() 
{  
	// time in milliseconds
	var timeElapsed = new Date().getTime() - lastRender;
	lastRender = new Date().getTime();
	context.clearRect(dirtyLeft, dirtyTop, dirtyRight-dirtyLeft, dirtyBottom-dirtyTop);
	dirtyLeft = 1000;
	dirtyTop = 1000;
	dirtyRight = 0;
	dirtyBottom = 0;
	smokeRight.update(timeElapsed);
	smokeRight.render(context);
	smokeLeft.update(timeElapsed);
	smokeLeft.render(context);
	windVelocity += (Math.random()-0.5)*0.002;
	if (windVelocity > 0.015)
	{
		windVelocity = 0.015;
	}
	if (windVelocity < 0.0)
	{
		windVelocity = 0.0;
	}
	requestAnimFrame(render);  
}  
This entry was posted in web development and tagged , , , . Bookmark the permalink.

4 Responses to Creating realistic particle effect with HTML5 canvas

  1. Your smoke effect is fucking awesome! I used it for my Breaking Bad CodePen.

    Thank you!

  2. Ed Welch says:

    Thanks! Glad you like it

  3. Jam says:

    This is really amazing. I hope you don’t mind but I have snaffled the code and integrated it into a (non-commercial) project of mine. I have played around with the variables and have got a beautiful thick plume of smoke. I do have a question though (as a javascript newbie – sorry). Is there a simple way to adjust this to start with zero particles on screen. I’d like to use it as a click event, so I want it to start off with one puff and grow, rather than the maximum number of particles being in existence at the beginning. Having fiddled around a bit though, I’m starting to think that this is pretty integral to the code – is it, or is there something I can alter? Many thanks.

  4. Steve says:

    This is really great work, man. Still relevant a few years later.

Leave a Reply

Your email address will not be published. Required fields are marked *

Anti-Spam Quiz: