Animations avec JavaScript & RequestAnimationFrame
bande dessinée d'un zootrope décomposant le mouvement d'un saute mouton

Animations avec JavaScript & RequestAnimationFrame

Depuis quelques mois, j’oriente mon temps de R&D vers la spécification SVG et les différentes possibilités d’interactions avec CSS et Javascript.
Dans un proche avenir, les spécifications Web Animation API, SVG2 et indirectement CSS custom properties vont grandement faciliter la création d’animations SVG.
Mais si vous êtes tenu de supporter la majorité des navigateurs, dont IE111, requestAnimationFrame reste la seule solution :

  • SVG SMIL n’a jamais été implémenté ni dans Internet Explorer ni dans Edge et va devenir obsolète pour les navigateurs à base du moteur Blink.
  • CSS permet des interactions, mais elles restent assez limitées avec SVG 1.1, comme l’impossibilité de modifier les coordonnées des tracés, les transformations sur les dégradés, motifs ou encore appliquer une transformation sur un symbole.
  • Charger un framework d’animation pour animer une icône, c’est un peu comme sortir un bazooka pour tuer une mouche.

Cet article présente une synthèse de pratiques et méthodes d’animations avec requestAnimationFrame issues de divers articles et de tests. Les exemples sont valables pour animer des éléments HTML, SVG ou même un canvas.

Pour rendre ma progression plus ludique, les différentes étapes détaillent chaque aspect nécessaire à la réalisation de cette animation SVG (que j’ai partagé pour mes vœux 2018), en augmentant progressivement la complexité et les détails.

See the Pen raf12-Last by Thomas Jund (@Sacripant) on CodePen.

Bonne lecture.

RequestAnimationFrame basic

const frame = now => {
	console.log(now)
};
requestAnimationFrame(frame);

En exécutant la fonction frame au sein d’une requestAnimationFrame vous indiquez au navigateur que vous souhaitez exécuter la fonction avant le prochain rafraichissement du navigateur.
requestAnimationFrame retourne en callback un paramètre (ici nommé now), qui est un DOMHighResTimeStamp : un TimeStamp super précis (microseconde) indiquant le moment ou le navigateur effectuera son rafraichissement.

frame n’est exécutée qu’une seule fois. Pour qu’il y ait animation, il faut boucler la fonction.

Boucler et annuler

Pour créer une boucle, il faut récursivement relancer votre fonction au sein d’une requestAnimationFrame. Pour quitter la boucle, il suffit d’utiliser la fonction cancelAnimationFrame, ou sortir de la boucle par une condition.

let rafID = null;
let counter = 0;

const frame = now => {
	counter ++;
	console.log(now);

	if (counter === 10) {
		cancelAnimationFrame(rafID);
        counter = 0;
        rafID = null;
		return;
	}
	rafID = requestAnimationFrame(frame); 
};
rafID = requestAnimationFrame(frame);

Dans cet exemple, un compteur est incrémenté à chaque frame. Lorsque le compteur arrive à 10, la boucle est annulée.
En inspectant la console, on remarque que la fonction frame est exécutée environ toutes les 16ms, ce qui correspond environ à 60 frames par seconde. Mais cette vitesse d’exécution n’est pas garantie et va dépendre de la complexité de votre fonction, de la puissance du terminal, du nombre d’onglets ouverts, etc.

Progression

Pour créer une transition, il faut partir d’une situation initiale, arriver à une situation finale et maitriser la progression. La fonction frame calculera chaque étape de la progression.
Nous avons donc besoin qu’une variable, variant de 0 à 1, indique à chaque frame la position de la progression.
0 = situation initiale.
1 = situation finale.
const progress = indexFrameCourante ÷ NbTotaleFrames

Prenons l’exemple d’une animation qui doit s’exécuter en 10 frames :

let rafID = null;
// Index frame courante
let f = 0;
// Nombre de Frames de l'animation
const NF = 10;

const frame = now => {
	f ++;
	// define progress
	const progress = f/NF;

	console.log(progress);

	if (f >= NF) {
		cancelAnimationFrame(rafID);
        f = 0;
        rafID = null;
		return;
	}
	rafID = requestAnimationFrame(frame); 
};
rafID = requestAnimationFrame(frame);

Dans cet exemple la variable progress retournera donc …

0.1
0.2
0.3
…
1

…correspondant aux 10 étapes de l’animation. Il suffit de modifier la valeur de NF pour avoir une animation plus ou moins longue.

Animation d’une illustration SVG

Commençons par animer un regard.

Le dessin de l’œil est défini au sein d’un symbol cloné 2x via la balise use. En modifiant les attributs des éléments composant le symbole, les différents clones seront animés.

Pour déplacer l’iris vers la droite en faisant varier l’attribut transfom:translate(), d’une valeur initiale de 0 à 23, il suffit d’exploiter la valeur retournée par la variable progress, en la multipliant par la valeur finale : progress * finalPosition.

const animePupil = (function(){
    // … 
    const irisPupil = document.getElementById('irisPupil');
    const frame = () => {
        // …
        irisPupil.setAttribute('transform', `translate( ${progress*23} )`);
        // …
    };
    const play = () => {
        rafID = requestAnimationFrame(frame); 
    }; 
    return {
        play: play
    };
}());
startBtn.addEventListener('click', () => {
    animePupil.play();
}, false);

See the Pen raf1-simpleTranslate by Thomas Jund (@Sacripant) on CodePen.

Distance

Dans le cas où la valeur initiale n’est pas 0, mais une autre valeur, la distance à parcourir doit être calculée par position.end - position.start. Et la valeur de positionnement pour chaque frame sera position.start + progress * distance.

Pour une position de départ de -23 et d’arrivée de 23 :

// … 
// Calcul de la distance
const range = (min, max) => max - min;
// transformation start -> end
const transform = (progress, {start, end}) => start + progress * range(start, end);

const animePupil = (function(){
    // …
    const animDatas = {
    	irisPupil : { 
            translateX : {start: -23, end: 23 }
    	}
    };
    const frame = () => {
        // …                
        irisPupil.setAttribute('transform', `translate(
            ${transform(progress, animDatas.pupil.translateX)}
        )`);
        // …
    };
}());
//…

See the Pen raf2-simpleTranslate by Thomas Jund (@Sacripant) on CodePen.

Inverser

Pour inverser une animation il suffit de faire décroitre l’index de la variable f qui aura pour effet d’inverser également les valeurs de la progression.
Nous allons ainsi ajouter :

  • une variable dir dont on fera alterner la valeur entre 1 et -1 ;
  • remplacer au sein de la fonction frame la simple incrémentation de f++ par f += dir ;
  • et modifier la condition de sortie de la boucle if( f >= NF ) par if( !(f%NF) ) pour retourner une condition true lorsque f==0 || f==NF.
// …
const animePupil = (function(){
    // …
    let dir = 1;
    // …
    const frame = () => {
        f += dir;
        // …
        if ( !(f%NF) ) {
            // On ne stop plus l'animation
            // Mais on l'inverse
            dir *= -1;
        }
        // …
    }
    // On stoppe l'animation via un btn, sans réinitialiser f
    stopBtn.addEventListener('click', e => {
    	cancelAnimationFrame(rID);
    	rID = null;
    }, false);
}());

See the Pen raf3-vaEtVient by Thomas Jund (@Sacripant) on CodePen.

En conservant la valeur de f, l’animation reprend à l’endroit où on l’a stoppée. De plus, si on clique plusieurs fois sur startBtn, l’animation va s’accélérer, en additionnant les requestAnimationFrame. Un nombre équivalent de clics sur stopBtn sera alors nécessaire pour ralentir et stopper l’animation.
Pour éviter cet effet de bord, il suffit de vérifier si rafID est null au sein de la fonction play.

const play = () => {
    if (rafID) return;
    rafID = requestAnimationFrame(frame); 
};

On peut également ajouter une pause entre le va et le vient à l’aide d’un setTimeOut :

const animePupil = (function(
// …
let replay;
// …
const frame = () => {
    // …
    if (!(f%NF)) {
    	dir *= -1;
        stop();
    	replay = setTimeout( () => {
            play();
    	}, 2500);
    	return;
    }
    rafID = requestAnimationFrame(frame); 
};
// …
    const stop = () => {
        clearTimeout(replay);
        // …
    }; 
}());

See the Pen raf4-vaEtVient by Thomas Jund (@Sacripant) on CodePen.

De la ligne à la courbe

Tout cela est bien trop plat.

L’animation est rendue de manière linéaire : avec une vitesse constante. Pour moduler la vitesse de progression via une timing function2 de type ease-in il faut modifier la valeur de progress via une fonction mathématique souvent empreinte de trigonométrie. Si pour vous, comme pour moi, la pratique des mathématiques n’a jamais été votre fort, la majorité des fonctions a déjà été écrite par la communauté (voir ressources en fin d’article).

Timing functions

Passer la valeur de progress au sein d’une timing functions de type ease-in va avoir pour effet de ralentir la transition au début pour l’accélérer ensuite, celles de type ease-out vont faire l’inverse.a

// …
// timing functions
const tfn = { 
    easeOutCubic : t => Math.pow(t-1, 3) + 1,
    easeInCubic : t => Math.pow(t, 3),
    easeInQuad : t => t*t,
    easeOutQuad : t => t*(2-t),
};

const animePupil = (function(){
    // …
    const frame = () => {
        // …
        const progress = f/NF;
        const ease = tfn.easeOutCubic(progress);
        //…
        irisPupil.setAttribute('transform', `translate(
            ${transform(ease, animDatas.irisPupil.translateX)}
        )`);
        // …

Appliquer une tfn.easeInCubic va donc fortement accélérer la transition au début, pour la ralentir sur la fin. Mais dans le cas de notre animation, la transition effectue un va-et-vient. Quand la progression est inversée, notre transition effectue une tfc.easeOutcubic.
Il faut donc lui appliquer une ftn.easeOutCubic dans les cas où dir === -1.

const ease = (dir-1) ? tfn.easeInCubic(progress) : tfn.easeOutCubic(progress);

See the Pen raf5-parabolic1 by Thomas Jund (@Sacripant) on CodePen.

Timing function parabolique ?

Et pourquoi pas.

Une fonction de type parabolique concave permet à la transformation d’atteindre le point d’arrivée au milieu de la progression pour ensuite retourner à la valeur de départ en fin de progressionb. C’est comme faire un va-et-vient à votre mouvement au sein d’une seule progression avec un ralentissement au milieu.

const tfn  = {
    // …
    parabolic : t => -4*Math.pow(t,2) + 4*t
};
const animePupil = (function(){
    //…
    const frame = () => {
        // …
        const parabolic = tfn.parabolic(ease);
    }

Remarque : ease étant maintenant la vitesse par défaut pour la translation des yeux, c’est elle qui devient la référente à passer à tfn.parabolic

Utilisons-la pour simuler une rotation du globe oculaire en faisant varier le rayon des abscisses (rx) de l’iris et des pupilles individuellement pour qu’elles paraissent circulaires au milieu et elliptiques aux bords. Il est nécessaire pour cela de déclarer ces éléments SVG comme des <ellipse/> plutôt que des <circle/> et ainsi pouvoir modifier l’attribut rx.
Pour améliorer l’effet, nous allons légèrement augmenter la translation de la pupille par rapport à l’iris.
Le SVG a été mis à jour pour que le dessin corresponde aux valeurs d’animation de départ modifiées ici.

 const animePupil = (function(){
    // …
    const pupil = document.getElementById('pupil');
    const iris = document.getElementById('iris');
    const animDatas = {
        iris : {
            translateX : { start: -23, end: 23 },
            rx : { start: 18, end: 22 }
        },
        pupil : {
            translateX : { start: -26, end: 26 },
            rx : { start: 6, end: 8 }
        } 
    };
    const frame = () => {
        // …
        // Transform Iris
        iris.setAttribute(
            'transform', 
            `translate(
                ${transform(ease, animDatas.iris.translateX)}
            )`
        );
        iris.setAttribute(
            'rx', 
            transform(parabolic, animDatas.iris.rx)
        );

        // Transform pupil
        pupil.setAttribute(
            'transform', 
            `translate( 
                ${transform(ease, animDatas.pupil.translateX)}
            )`
        );
        pupil.setAttribute(
            'rx', 
            transform(parabolic, animDatas.pupil.rx)
        );
        // …

See the Pen raf6-parabolic2 by Thomas Jund (@Sacripant) on CodePen.

Il est également possible d’utiliser cette même fonction pour élever le regard en modifiant les valeurs de transformY

 const animDatas = { 
    iris : {
        // …
        translateY : { start: 0, end: -7 },
    },
    pupil : {
        // …
        translateY : { start: 0, end: -8 },
    }   
};
const frame = () => {
    // …
    iris.setAttribute(
        'transform', 
        `translate(
            ${transform(ease, animDatas.iris.translateX)}
            ${transform(parabolic, animDatas.iris.translateY)}
        )`
    );
    // …
    // Transform pupil
    pupil.setAttribute(
        'transform', 
        `translate( 
            ${transform(ease, animDatas.pupil.translateX)}
            ${transform(parabolic, animDatas.pupil.translateY)}
        )`
    );
    //…

See the Pen raf7-tfn by Thomas Jund (@Sacripant) on CodePen.

SVG Path Morphing

Les yeux, ça cligne.

Nous allons animer le tracé du contour de l’œil pour les faire cligner.
Commençons par définir une nouvelle IIFE animeEyelids avec les mêmes variables et méthodes que la fonction animePupil utilisée précédemment, à seule nuance qu’il ne sera pas utile d’inverser l’animation.

const animePupil = (function(){
    // …
}());
const animeEyelids = (function(){
    let rafID = null;
    let f = 0;
    let replay;
    const NF = 20;
    const eyeLids = document.getElementById('eyeShape');
    const animeDatas = {
        // Datas tracés oeil ouvert / fermé
    }
    const frame = () => {
        f += 1;
        const progress = f/NF;
        const parabolic = tfn.parabolic(progress);

        // Votre animation

        if (!(f%NF)) {
            stop();
            replay = setTimeout( () => {
                play(); 
            }, 4000);
            return;
        }
        rafID = requestAnimationFrame(frame); 
    };
    const play = () => {
        if (rafID) return;
        rafID = requestAnimationFrame(frame); 
    };
    const stop = () => {
        f = 0;
        clearTimeout(replay);
        cancelAnimationFrame(rafID);
        rafID = null;
    }; 
    return {
        play: play,
        stop: stop
    };
}());

Pour animer un tracé (path) SVG, il faut transformer chaque coordonnée le définissant des valeurs de départ vers les valeurs d’arrivée.
Cela se fera d’autant plus simplement si le type de tracé et la quantité de points sont équivalents dans le tracé de départ et celui d’arrivée.

Voici le tracé qui défini le contour de l’œil paupières ouvertes :

<path d="
    M 27,75
    S 40 52 75 52 120 75 120 75 105 93 75 93 27 75 27 75 
    z
" />

et celui qui défini le contour de l’oeil paupières fermées

<paths d="
    M 27,75
    S 40 93 75 93 120 75 120 75 105 93 75 93 27 75 27 75 
    z
" />

Les 2 dessins sont définis uniquement à l’aide d’un tracé S possédant le même nombre de coordonnées.
En stockant les coordonnées du tracé dans un tableau, il est alors possible de mapper les valeurs de départ vers les valeurs d’arrivées au sein de la fonction frame :

const animeEyelids = (function(){
    // …
    const animDatas = {
        open :  [40, 52, 75, 52, 120, 75, 120, 75, 105, 93, 75, 93, 27, 75, 27, 75],
        close : [40, 93, 75, 93, 120, 75, 120, 75, 105, 93, 75, 93, 27, 75, 27, 75]
    };
    const frame = () => {
        // …
        path = animDatas.open.map((start, index) => {
            const datas = {
                start : start,
                end: animDatas.close[index]
            };
            const point = transform(parabolic, datas);
            return point;
        });
        eyeLids.setAttribute('d', `M 27 75 S ${path.join(' ')} z`);
        // …

See the Pen raf8-morphing by Thomas Jund (@Sacripant) on CodePen.

Remarques : les utilisateurs de Firefox pourront ici visualiser un navrant bug de rendu alors que cela fonctionne sans problème sous un antique ie113.

Une part de hasard

Un coup de dés jamais n’abolira la hasard.

Avoir une animation qui tourne simplement en boucle n’est pas souvent intéressant. Ajouter quelques grains de sables et de hasard est parfois nécessaire.

const random = (min, max) => Math.random() * (max - min) + min;

Cette fonction retourne un nombre aléatoire compris entre les valeurs min et max passées en arguments.
Elle permet par exemple de faire varier le délai du setTimeout qui permet d’ajouter un temps d’attente avant de répéter une séquence :

const animePupil = (function(){
    // …
    const frame = () => {
        // …
        if (!(f%NF)) {
            // …
            replay = setTimeout( () => {
                play(); 
            }, random(1500, 3500));
            return;
        }
        // …
const animeEyelids = (function(){
    // …
    const frame = () => {
        // …
        if (!(f%NF)) {
            stop();
            replay = setTimeout( () => {
                play(); 
            }, random(1500, 10000));
            return;
        }
        // …

Les yeux peuvent également, parfois, s’arrêter à mi-parcours en définissant un nombre de frames maximum à animer de manière aléatoire :

const animePupil = (function(){
    // …
    let max;
    const maxRandom = () => {
        max = Math.round( Math.random() * NF );
    };
    // …
    const frame = () => {
        // …
        // Suppression du easeInQuad qui n'a va poser des problèmes de rendus en cas de progression partielle
        const ease = progress;
        // …
        if ( f === max || !(f%NF) ) {
            //…
        }
        // …
    };
};
const play = () => {
    // …
    maxRandom();
};

See the Pen raf9-hasard by Thomas Jund (@Sacripant) on CodePen.

Générer et animer une multitude

Via Inkscape, un visage a été ajouté à ce regard. Il nous faut alors définir ce que ces yeux regardent.

Dans cette dernière partie, nous allons cloner et animer le tracé d’un flocon pour générer une légère neige tombante au sein d’une nouvelle IIFE animeSnow.

Au sein de celle-ci il faut définir une fonction randomInterval qui exécutera un callback de manière répétitive au bout d’un certain nombre de frames.

Répéter

const animeSnow = (function(){
    let rafID = null;
    const randomInterval = (callback, min, max) => {
        const NF = Math.round(random(min, max));
        let f = 0;

        const frame = () => {
            f += 1;
            if (f >= NF) {
                NF = random(min, max);
                f = 0;
                callback();
            }
            rafID = requestAnimationFrame(frame);
        };
        rafID = requestAnimationFrame(frame);
    };

    const animeFlake = () => {
        // Clonage + animation du flocon
    };

    const play = () => {
        if (rafID) return;
        randomInterval(animeFlake, 20, 35);
    };
    // …
}());

La fonction randomInterval va exécuter la fonction animeFlake toutes les 20 à 35 frames. Au sein de animeFlake, on peut maintenant cloner le tracé du flocon et l’animer.

Cloner

Le dessin du flocon est défini au sein d’un symbol SVG. Pour le cloner il faut créer un nouvel élément use pointant vers le symbole à l’aide de l’attribut xlink:href.

const newFlake = () => {
    const flake = document.createElementNS('http://www.w3.org/2000/svg', 'use');
    flake.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#snowflake');
    return flake;
};
const animeFlake = () => {
    const flake = newFlake();
    svg.appendchild(flake);
};

SVG ayant son propre namespace, il est nécessaire d’utiliser createElementNS pour créer un élément use, et setAttributNS pour lui ajouter un attribut xlink:href.

Puis en profiter pour personnaliser un peu ce nouveau flocon avec un peu d’aléatoire pour sa position et son épaisseur.

const newFlake = () => {
    // …
    flake.setAttribute('x', Math.round(random(0, svg.w)) );
    flake.style.strokeWidth = `${random(0.5, 2)}px`;
    return flake;
};

See the Pen raf10-clone by Thomas Jund (@Sacripant) on CodePen.

Animer chaque clone

Rebelote. Nous allons au sein de la fonction animeFlake définir les paramètres de l’animation, la fonction frame, et la lancer de manière récursive avec un requestAnimationFrame. Lorsque le flocon arrivera au bout de sa progression, il sera retiré du DOM.


const animeFlake = () => {
    let flake = newFlake();
    let f = 0;
    const NF = 500;

    const animeDatas = {
        translateY : { start: 0, end: 540 },
        rotate : { start: 0, end: 100 }
    };

    const frame = () => {
        f += 1;
        const progress = f/NF;
        const ease = tfn.easeOutCubic(progress);

        flake.setAttribute(
            'transform',
            `translate(
                0
                ${transform(ease, animeDatas.translateY)}
                )
            rotate(
                ${transform(ease, animeDatas.rotate)}
                )`);

        if ( !(f%NF) ) {    
            svg.removeChild(flake);
            return;
        }
        requestAnimationFrame(frame); 
    };
    svg.appendChild(flake);
    requestAnimationFrame(frame);
};

See the Pen raf11-AnimeClone by Thomas Jund (@Sacripant) on CodePen.

Dernières retouches

Les principales briques sont posées et aspects abordés.
Pour arriver au résultat présenté en début d’article, l’animation a subit quelques dernières retouches et modifications :

  • l’ajout d’une légère rotation du visage pour accompagner le mouvement des yeux ;
  • l’ajout d’un facteur d’échelle aléatoire accompagner d’un filtre SVG Blur sur les flocons pour créer une profondeur de champ ;
  • un ajustements de la vitesse (NF et setTimeout) ;
  • le debug pour Firefox ;
  • l’ajout d’un petit reflet dans les yeux ;
  • la conversion du code ES6 en ES5 via BabelJS.

See the Pen raf12-Last by Thomas Jund (@Sacripant) on CodePen.

Conclusion

Performance

Quelques aspects important concernant la performance, having no animation is better than animations that stutter :

  • Contrairement aux animations CSS qui s’exécutent dans un tread séparé, JavaScript reste monothread. Il vaut mieux éviter qu’une animation JS tourne pendant d’autres processus (HTTP requests).
  • L’animation d’éléments SVG se fait en manipulant le DOM. Ce qui en soit est une limite aux animations trop complexes. Bref, c’est bien pour animer un graphique ou un icône, pas pour les jeux vidéos.
  • L’animation d’éléments possédant une couche alpha (opacité réduite ou flou gaussien) comme le cas des flocons dans la dernière version peut être un facteur important de ralentissement sur certains terminaux même si l’animation restera assez fluide.

Don’t use framework

Bien que d’écrire une animation avec RequestAnimationFrame soit assez verbeux (ce qui m’a longuement rebuté), il n’est pas si compliqué, avec quelques briques de code assez simples, d’ajouter un peu de mouvement à nos sites web sans avoir besoin d’utiliser un framework volumineux.

Sources et Ressources

Aller plus loin

Timing function

Math et animations