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
- The further from camera, the slower
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.
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); }
Your smoke effect is fucking awesome! I used it for my Breaking Bad CodePen.
Thank you!
Thanks! Glad you like it
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.
This is really great work, man. Still relevant a few years later.