A partir des bases établies précédemment, nous allons ajouter petit à petit de nouvelles fonctionnalités, à commencer par l'ajout de propriétés sur les particules puis sur l'émetteur lui même.

Nouveautés sur les particules

Les nouvelles propriétés

Un temps de vie

Une des propriétés de base d'une particule est son temps de vie. Cette propriété est important pour les raisons suivantes :

  • A chaque fois qu'une particule dépasse son temps de vie, la particule peut être "recyclée" et ainsi servir à nouveau.
  • Elle permet aussi de pouvoir faire évoluer la particule au cours de sa vie
    • Sa naissance : Dans l'application, on considère que la particule naît dans les 5 premiers pour cents de sa vie. Pour simuler cette naissance, on va agir sur la taille de la particule.
      • A la naissance, sa taille sera nulle
      • Au bout de 5% de sa vie, elle aura atteint sa taille finale
      • Formule d'interpolation : currentSize= \frac { particuleSize } { birthPercentage \times particuleLifeTime }
    • Sa mort : Dans l'application, on considère que la particule meurt doucement dans les 30 derniers pour cents de sa vie. Pour simuler la mort de la particule, on va agir sur l'alpha de particule ( sur sa transparence ).
      • Au début de sa mort, la particule conservera son alpha normal
      • A sa mort, la particule sera devenue complètement transparente
      • Formule d'interpolation : currentAlpha= \frac { particuleLifeTime  - particuleCurrentTime } { deathPercentage \times particuleLifeTime }

Une texture

Les petits carrés de couleur étaient sympathiques mais il est temps de passer à autre chose. A l'instar de ce qu'il se fait en OpenGl ou DirectX, on peut associer une image à notre carré. On retrouve donc la notion de texture. Pour ce faire, on va se servir du petit bout de code suivant :

// Déclaration de l'image : 
var particleImage = new Image();
particleImage.src =  'http://gregory.corgie.free.fr/currentDotclear/public/particle_fire.png';

// Affichage de l'image 
myCanvasContext.drawImage( particleImage , 0, 0, 10, 10  );

Le chemin vers l'image devrait pointer sur l'image suivante; une magnifique texture faite de mes petites mains de programmeur :
particle_fire

Note : Une fonctionnalité importante semble manquer en HTML5, que l'on retrouve dans Flash sous l'appellation de "Tint". Cette opération permet de moduler une image par une couleur. Plus précisément, tous les pixels de la texture vont être pré multipliés par une valeur de couleur que l'on peut choisir.


ParticleGen_modulation

Note 2: Veillez à bien attendre le chargement complet de l'image avant le premier appel à drawImage(). Les navigateurs semblent planter si l'image n'est pas encore chargée.


Changement du mode de blending : l'additif


Si vous avez déjà regardé le résultat final, vous avez peut être eut du mal à reconnaitre la texture de particule affichée au dessus. C'est normal car le mode de blending utilisé n'est pas le mode de blending "classique" ( alpha-blending ).

Qu'est-ce qu'un mode de blending ?

Un mode de blending est la façon dont l'image qui est en cours d'affichage va être composée avec le fond. Le plus connu des modes de blending est l'alpha-blending, ce mode permet de représenter la transparence d'un objet ( \alpha). Si l'on souhaite dessiner un rond transparent par dessus un carré bleu, la couleur finale sera calculée selon la formule : Couleur_{Finale} = \alpha_{Circle} \times Couleur_{Circle} + ( 1 - \alpha_{Circle} ) \times Couleur_{CarreBleu}

Qu'est-ce que l'additif ?

Il existe de nombreux mode de blending ( darken, multiply etc ... ) qui sont caractérisés par une formule mathématique et ainsi permettre de "composer" l'image et le fond différement. Ici, l'exemple consistant à représenter une boule de feu, le mode de blending choisi est l'additif ( 'lighter' côté html5 ). En reprenant l'exemple du rond rouge sur un rond bleu, le résultat sera un rond violet ( rouge + bleu ) sur un carré bleu. Dans notre exemple d'application, plus on va ajouter des carrés les uns au dessus des autres, plus la couleur résultante se rapprochera du blanc ( "couleur maximale").


Gestion de l'émetteur


Déplacement aléatoire de l'émetteur

On considère que la vitesse de l'émetteur est bornée par une valeur maximum et qu'il dispose aussi d'une direction de déplacement. Si l'émetteur est aligné avec le point à atteindre, l'émetteur accélère, sinon il freine. Une fois que l'émetteur est assez proche du point du destination, on tire de manière aléatoire un nouveau point de destination à l'intérieur du canevas. Je vous laisserai voir le code source pour comprendre les détails d'implémentation.

Contrôle de l'émetteur à la souris

Il est possible d'ajouter un écouteur sur les déplacements de la souris à partir du canevas. On trouve sur le net de nombreux bouts de code pour calculer la position relative de la souris par rapport au canevas, mais beaucoup sont obsolètes... Après plusieurs essais, je vous propose donc de suivre la procédure suivante :

var canvas = document.getElementById('myCanvas');

// Attach the mousemove event handler.
canvas.addEventListener('mousemove', mouseHandle, false); 

// Function declaration
function mouseHandle(ev) 
{ 
   var x, y;

    // Get the mouse absolute position
    if(ev.pageX || ev.pageY)
    { 
    	x = ev.pageX;
    	y = ev.pageY
    }
    else
    { 
        x = ev.clientX + document.body.scrollLeft - document.body.clientLeft;
  	y = ev.clientY + document.body.scrollTop  - document.body.clientTop;
    }
 
   // We want canvas relative coordinates
   x -= g_canvas.offsetLeft;
   y -= g_canvas.offsetTop;

    // Do what you want here...
}

Le résultat





Patricles Number : 0
Initial Speed Value : 0
Initial Speed Angle : 0
Initial Life Time : 0
Initial Particle Size : 0


Le script



<canvas id="particleCanvas" width="480" height="480"></canvas>
<br>
<div>
    <form name="panelForm" action="">
        <input id="btPlay" type="button" value="Pause" onclick="toggleEngine()">
        <input id="btSetting" type="button" value="Change Settings" onclick="settingsChange()">
        <input id="btDebug" type="button" value="Debug Off" onclick="toggleDebugDraw()">
        <br>
        Patricles Number :
        <input id="sld_particleCount" type="range" min="0" max="1000" step="1" onchange="setParticlesCount(this.value)" />
        <span id="particleCount">0</span>
        <br>
        Initial Speed Value :
        <input id="sld_initSpeed" type="range" min="-30" max="30" step="0.1" onchange="setInitSpeedValue(this.value)" />
        <span id="initSpeed">0</span>
        <br>
        Initial Speed Angle :
        <input id="sld_initAngle" type="range" min="0" max="180" step="1" onchange="setInitSpeedAngle(this.value)" />
        <span id="initAngle">0</span>
        <br>
        Initial Life Time :
        <input id="sld_initLifeTime" type="range" min="0" max="5" step="0.1" onchange="setInitLifeTime(this.value)" />
        <span id="initLifeTime">0</span>
        <br>
        Initial Particle Size :
        <input id="sld_initSize" type="range" min="1" max="30" step="0.1" onchange="setInitSize(this.value)" />
        <span id="initSize">0</span>
        <br>
    </form>
</div>

<script type="text/javascript">

// ----------------------------------------------------------------------------------------------------------------
// Global vars
// TODO : Reverse loops can be 30% faster according to Jeff Greenberg
// http://home.earthlink.net/~kendrasg/info/js_opt/jsOptMain.html
// ----------------------------------------------------------------------------------------------------------------
var g_delta_time_ms 		= 10;								// ( 100 frames per second )
var g_delta_time 			= g_delta_time_ms / 1000;
var g_particlesCount 		= 200;
var g_particlesSize 		= 25;
var g_emitter;
var g_particlesArray 		= new Array( g_particlesCount );
var g_real_gravity 			= 9.81;								// m/s-2
var g_scale_px_m 			= 100;								// 1m = 10 px
var g_gravity 				= g_real_gravity * g_scale_px_m;	// px/s-2
var g_initSpeedValue 		= -2;
var g_initSpeedAngle 		= 30;
var g_initLifeTime 		    = 1.0;
var g_frictionLoss 			= 0.9;

// Canvas size 
var	g_canvas;
var	g_canvas_context;
var SCENE_WIDTH 			= 480; 
var SCENE_HEIGHT 			= 480; 

// Others vars
var g_play 					= true;
var g_debugDraw				= false;
var g_mouseActive           = false;
var g_settingsId           	= 0;
var g_settings				= new Array();

//Create and preload a new image.
var g_imageReady = false;
var g_particleImage = new Image();
var g_particlePath = 'http://gregory.corgie.free.fr/currentDotclear/public/particle_fire.png';
g_particleImage.src = g_particlePath;

// Must wait image load completion before the first draw
g_particleImage.onload = function()		{	g_imageReady = true;	}

var g_birthPercentage		= 0.05;
var g_deathPercentage		= 0.3;


// ----------------------------------------------------------------------------------------------------------------

window.onload = applicationLoad();

// ----------------------------------------------------------------------------------------------------------------
// Initialization
// ----------------------------------------------------------------------------------------------------------------
function applicationLoad()
{ 
	// Get the canvas element.
	g_canvas = document.getElementById('particleCanvas');
	if (!g_canvas || !g_canvas.getContext) 
	{
	  alert( "The canvas has not been found, looking for the id particleCanvas " );
	  return;
	}
	
	// Get the canvas 2d context.
	g_canvas_context = g_canvas.getContext('2d');
	if (!g_canvas_context)
	{
	  alert( " 2d context has not been found " );
	  return;
	}

 	// Create the generator
 	g_emitter = new emitterCtr(); 
 	  
	// Frame manage interval setting.
	// The function will be triggered every 10 ms
	idInterv = setInterval( frameManage, g_delta_time_ms );
	  
	 for (var i=0; i < g_particlesCount; i++) 
	 {
	 	g_particlesArray[i] = new particleCtr(); 
		generateParticle( g_particlesArray[i] ); 	
	 }
	
	// ----------------------------------------------------------------------------------------------------------------  
	 // Init UI components
	 document.getElementById("particleCount").innerHTML	= g_particlesCount ; 
	 document.getElementById("sld_particleCount").value	= g_particlesCount ;
	 document.getElementById("initSpeed").innerHTML		= g_initSpeedValue ;
	 document.getElementById("sld_initSpeed").value		= g_initSpeedValue ;
	 document.getElementById("initAngle").innerHTML		= g_initSpeedAngle ;
	 document.getElementById("sld_initAngle").value		= g_initSpeedAngle ;
	 document.getElementById("initLifeTime").innerHTML	= g_initLifeTime ;
	 document.getElementById("sld_initLifeTime").value	= g_initLifeTime ;
	 document.getElementById("initSize").innerHTML		= g_particlesSize;
	 document.getElementById("sld_initSize").value		= g_particlesSize;
	 // ----------------------------------------------------------------------------------------------------------------  

     // Attach the mousemove event handler.
     g_canvas.addEventListener('mousemove', mouseHandle, false); 

     // Settings definition
     g_settings[0] = new Array();
 	g_settings[0].particleCount = 400;
 	g_settings[0].initSpeedValue = -2;
 	g_settings[0].initSpeedAngle = 30;
 	g_settings[0].initLifeTime = 1;
 	g_settings[0].initSize = 25;

 	g_settings[1] = new Array();
 	g_settings[1].particleCount = 800;
 	g_settings[1].initSpeedValue = 0;
 	g_settings[1].initSpeedAngle = 60;
 	g_settings[1].initLifeTime = 0.7;
 	g_settings[1].initSize = 5.0;

 	g_settings[2] = new Array();
 	g_settings[2].particleCount = 1000;
 	g_settings[2].initSpeedValue = 7;
 	g_settings[2].initSpeedAngle = 180;
 	g_settings[2].initLifeTime = 0.25;
 	g_settings[2].initSize = 4; 	
}


//----------------------------------------------------------------------------------------------------------------
// ----------------------------------------------------------------------------------------------------------------
function frameManage()
{	
	if( !g_play )
		return;

	// Clear the scene
	g_canvas_context.fillStyle = '#000';
	//g_canvas_context.clearRect (0, 0,  SCENE_WIDTH, SCENE_HEIGHT );
	g_canvas_context.fillRect (0, 0,  SCENE_WIDTH, SCENE_HEIGHT );
	g_canvas_context.strokeRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT);

    // Update the emitter 
    updateEmitter();
    
	// Resize the array if needed
	if( g_particlesArray.length != g_particlesCount )
		g_particlesArray.length = g_particlesCount;		
			
	for (var i=0; i < g_particlesCount; i++) 
	{
		if( g_particlesArray[i] == undefined )
		{
			g_particlesArray[i] = new particleCtr();
			generateParticle( g_particlesArray[i] ); 
		}
		
		// If the particle is dead, respawn it
		if( !g_particlesArray[i].active )
		{
			g_particlesArray[i].pos[0] = Math.random() * SCENE_WIDTH;
			g_particlesArray[i].pos[1] = Math.random() * SCENE_HEIGHT;
			g_particlesArray[i].active = true;
			
			generateParticle( g_particlesArray[i] );
		}

		updateParticle( g_particlesArray[i] );

        if( g_particlesArray[i].active )
		    drawParticle( g_particlesArray[i] );
	}
	
	g_mouseActive = false;
}
//----------------------------------------------------------------------------------------------------------------


// ----------------------------------------------------------------------------------------------------------------
// Particle management functions	
// ----------------------------------------------------------------------------------------------------------------
function updateParticle( particle )
{
	if( checkParticleDeath( particle ) )
	{
		particle.active = false;
		return;
	}

	var acceleration = [ 0 , g_gravity ];

	// Compute new velocity
	particle.speed[0] = g_frictionLoss * particle.speed[0] + acceleration[0] * g_delta_time;
	particle.speed[1] = g_frictionLoss * particle.speed[1] + acceleration[1] * g_delta_time;

	// Compute new position
	particle.pos[0] = particle.pos[0] + particle.speed[0] * g_delta_time;		
	particle.pos[1] = particle.pos[1] + particle.speed[1] * g_delta_time;
	
	// Update particule life
	particle.time   = Math.min( particle.time + g_delta_time, particle.lifeTime );
}

function checkParticleDeath( particle )
{
	if( particle.time >= particle.lifeTime )	
	    return true;
	
	if( particle.pos[0] < 0 || particle.pos[0] > SCENE_WIDTH )
		return true;
		
	if( particle.pos[1] < 0 || particle.pos[1] > SCENE_HEIGHT )
		return true;
	    
	return false;
}

// ----------------------------------------------------------------------------------------------------------------


// ----------------------------------------------------------------------------------------------------------------
// Draw particle functions	
// ----------------------------------------------------------------------------------------------------------------
function drawParticle( particle )
{
	var particleSize = particle.size;
	var particleTime = particle.time;
	var particleLifeTime = particle.lifeTime;
	
	
    g_canvas_context.save();
    
    // Alpha computation
    if( particleTime >= ( ( 1.0 - g_deathPercentage ) * particleLifeTime) )
        g_canvas_context.globalAlpha = ( particleLifeTime - particleTime ) / ( g_deathPercentage * particleLifeTime );
    else if( particleTime < g_birthPercentage * particleLifeTime )
    	particleSize *= particleTime / ( g_birthPercentage * particleLifeTime  );
    else
    	g_canvas_context.globalAlpha = 1.0;

	g_canvas_context.translate( particle.pos[0], particle.pos[1] );
	
	if( !g_debugDraw )
	{
		if( g_imageReady )
		{
	    	g_canvas_context.globalCompositeOperation = 'lighter';  
	    	g_canvas_context.drawImage( g_particleImage, -0.5 * particleSize, -0.5 * particleSize, particleSize, particleSize );
		}
	}
	else
	{
	    g_canvas_context.fillStyle = particle.color;
        g_canvas_context.fillRect( -0.5 * particleSize, -0.5 * particleSize, particleSize, particleSize );
    }
	
	g_canvas_context.restore();
}

function generateParticle( particle )
{
	var initAngle = Math.random() * g_initSpeedAngle;
	var initSpeed = ( Math.random() * g_initSpeedValue + g_emitter.speed ) * g_scale_px_m;

	if ( Math.random() > 0.5 )
		initAngle = -initAngle;
		
	particle.speed[0] = initSpeed * (  Math.cos( Math.PI * ( initAngle )/ 180 + g_emitter.angle ) );
	particle.speed[1] = initSpeed * (  Math.sin( Math.PI * ( initAngle )/ 180 + g_emitter.angle ) );

	particle.pos[0] = g_emitter.pos[0];
	particle.pos[1] = g_emitter.pos[1];
	
	particle.lifeTime   = ( Math.random() * 0.5 + 0.5 ) * g_initLifeTime;
	particle.time       = 0;

	var color = Math.round( 0xffffff * Math.random() );
	particle.color = '#' + color.toString(16);

	particle.size = g_particlesSize;
}
// ----------------------------------------------------------------------------------------------------------------

// ----------------------------------------------------------------------------------------------------------------
// Particle management functions	
// ----------------------------------------------------------------------------------------------------------------
function updateEmitter()
{
    var goalVector = [  g_emitter.posGoal[0] - g_emitter.pos[0], g_emitter.posGoal[1] - g_emitter.pos[1] ];

   if( g_mouseActive )
   {
        // Normalize it
        var curDirLength = length( goalVector );
        var newXDir = goalVector[0] / curDirLength;
        var newYDir = goalVector[1] / curDirLength;
        setCurrentDir( newXDir, newYDir );
        
        g_emitter.pos[0] = g_emitter.posGoal[0];
        g_emitter.pos[1] = g_emitter.posGoal[1];
        
        g_emitter.speed = Math.min( g_emitter.maxSpeed, curDirLength );
   }    
   else
   {
        
        if( lengthSquare(goalVector) <  (10 * 10) )
        {
            g_emitter.posGoal[0] = Math.random() * SCENE_WIDTH;
            g_emitter.posGoal[1] = Math.random() * SCENE_HEIGHT;
        }
        else
        {
            var goalVectorLength = length( goalVector );
            goalVector[0] /= goalVectorLength;
            goalVector[1] /= goalVectorLength;

            // Current dir
            var newXDir = g_emitter.curDir[0] + 0.1 * ( goalVector[0] - g_emitter.curDir[0] );
            var newYDir = g_emitter.curDir[1] + 0.1 * ( goalVector[1] - g_emitter.curDir[1] );
            setCurrentDir( newXDir, newYDir );

            if( dotProduct( goalVector, g_emitter.curDir ) > 0 )
                g_emitter.speed = Math.min( g_emitter.maxSpeed, ( g_emitter.speed + 0.1 ) );
            else
                g_emitter.speed = Math.max( 0, 0.5 * g_emitter.speed );

            // Update emitter position 		    
            g_emitter.pos[0] = g_emitter.pos[0] + g_emitter.curDir[0] * g_emitter.speed;
            g_emitter.pos[1] = g_emitter.pos[1] + g_emitter.curDir[1] * g_emitter.speed;
        }
    }

    drawEmitter();
}

function drawEmitter()
{
	if( g_debugDraw )
	{
		g_canvas_context.save(); 
		g_canvas_context.fillStyle = '#f00';
		g_canvas_context.translate( g_emitter.pos[0], g_emitter.pos[1] );
		g_canvas_context.rotate( g_emitter.angle );
		g_canvas_context.fillRect( -2.5, -2.5, 5, 5 );
		g_canvas_context.fillRect( 5, -1, 2, 2 );
		g_canvas_context.restore(); 
		
		g_canvas_context.fillStyle = '#00f';
		g_canvas_context.fillRect( g_emitter.posGoal[0], g_emitter.posGoal[1], 5, 5 );
	}
}
// ----------------------------------------------------------------------------------------------------------------

// ----------------------------------------------------------------------------------------------------------------
// Particle constructor
// ----------------------------------------------------------------------------------------------------------------
function particleCtr()
{
	this.pos = [ SCENE_WIDTH * 0.5 , SCENE_HEIGHT * 0.5 ];			// X position, Y position
	this.speed = [ 0, 0 ];
	this.color = '#f00';
	this.size = 10;
	this.time = 0;
	this.lifeTime = 1.0;
	this.active = true;
}

function emitterCtr()
{
	this.pos = [ SCENE_WIDTH * 0.5 , SCENE_HEIGHT * 0.5 ];			// X position, Y position
	this.posGoal = [ 0, 0 ];
	this.speed = 0;
	this.maxSpeed = 3.0;
	this.curDir = [ 1, 0 ];
	this.angle = 0;
}

function setCurrentDir( xDir, yDir )
{   
    var newDir = [ xDir, yDir ];
    
    // Normalize it
    var curDirLength = length( newDir );
    g_emitter.curDir[0] = newDir[0] / curDirLength;
    g_emitter.curDir[1] = newDir[1] /  curDirLength;
    
    // Normalize it
    var xAxis = [ 1, 0 ];
    var dotProd = dotProduct( xAxis, g_emitter.curDir );
 	
    if( crossProduct( xAxis, g_emitter.curDir ) > 0 )
        g_emitter.angle = Math.acos( dotProd );
    else
        g_emitter.angle = -Math.acos( dotProd );
}
//----------------------------------------------------------------------------------------------------------------


// ----------------------------------------------------------------------------------------------------------------
// UI functions	
// ----------------------------------------------------------------------------------------------------------------
function toggleEngine()				{ g_play ? document.getElementById("btPlay").value="Play" : document.getElementById("btPlay").value="Pause"; g_play = !g_play; }
function toggleDebugDraw()			{ g_debugDraw ? document.getElementById("btDebug").value='Debug On' : document.getElementById("btDebug").value='Debug Off'; g_debugDraw = !g_debugDraw; }
function setParticlesCount( count)	{ g_particlesCount = count; document.getElementById("particleCount").innerHTML=count; }
function setInitSpeedValue( count)	{ g_initSpeedValue = count; document.getElementById("initSpeed").innerHTML=count; }	
function setInitSpeedAngle( count)	{ g_initSpeedAngle = count; document.getElementById("initAngle").innerHTML=count; }	
function setInitLifeTime( count)	{ g_initLifeTime = count; document.getElementById("initLifeTime").innerHTML=count; }	
function setInitSize( count )		{ g_particlesSize = count; document.getElementById("initSize").innerHTML=count; }
// ----------------------------------------------------------------------------------------------------------------

// ----------------------------------------------------------------------------------------------------------------
// Math function	
// ----------------------------------------------------------------------------------------------------------------
function dotProduct( vec1, vec2 )	{ return ( vec1[0] * vec2[0] + vec1[1] * vec2[1] ); }
function crossProduct( vec1, vec2 )	{ return ( vec1[0] * vec2[1] - vec1[1] * vec2[0] ); }
function lengthSquare( vec1 )		{ return ( Math.pow( vec1[0], 2 ) + Math.pow( vec1[1], 2 ) ); }
function length( vec1 )				{ return Math.sqrt( lengthSquare( vec1 ) ) + 0.0001; }
// ----------------------------------------------------------------------------------------------------------------


// ----------------------------------------------------------------------------------------------------------------
// Mouse functions
// ----------------------------------------------------------------------------------------------------------------
function mouseHandle(ev) 
{ 
   var x, y;

    // Get the mouse absolute position
     if(ev.pageX || ev.pageY)
    { 
    	x = ev.pageX;
    	y = ev.pageY
    }
    else
    { 
        // Opera
        x = ev.clientX + document.body.scrollLeft - document.body.clientLeft;
  	    y = ev.clientY + document.body.scrollTop  - document.body.clientTop;
    }
 
  	// We want canvas relative coordinates
	x -= g_canvas.offsetLeft;
	y -= g_canvas.offsetTop;
 
    if( x > 0 || x < SCENE_WIDTH || y > 0 || y < SCENE_HEIGHT )
    {       
        // The event handler works like a drawing pencil which tracks the mouse 
        // movements. We start drawing a path made up of lines.
        g_emitter.posGoal[0] = x;
        g_emitter.posGoal[1] = y;
        g_mouseActive = true; 
    }
}

function settingsChange()
{ 
	g_settingsId = ( g_settingsId + 1 ) % 3;
	
	setParticlesCount( g_settings[g_settingsId].particleCount );
	setInitSpeedValue( g_settings[g_settingsId].initSpeedValue );
	setInitSpeedAngle( g_settings[g_settingsId].initSpeedAngle );
	setInitLifeTime( g_settings[g_settingsId].initLifeTime );
	setInitSize( g_settings[g_settingsId].initSize );
}



</script>