WebGL pas à pas

Séance 3 · Cours

Animation et boucle de jeu

Une animation n'est qu'une suite d'images fixes affichées assez vite. Tout l'art consiste à décider quand calculer la suivante, et de combien faire avancer le monde entre deux images.

Durée 30 minTP associé TP 3 · Mini-jeu

À la fin de cette séance, vous saurez

  • écrire une boucle de rendu avec requestAnimationFrame ;
  • exprimer les vitesses en unités par seconde grâce au delta time ;
  • mettre en place un pas de temps fixe ;
  • séparer la mise à jour de l'état (update) et le dessin (render), et lire le clavier proprement.

1. La boucle de rendu

Pour animer, il faut redessiner régulièrement. Le navigateur fournit pour cela requestAnimationFrame(f) : « appelle f juste avant le prochain rafraîchissement de l'écran ». En général, c'est 60 fois par seconde (plus sur un écran 120 Hz). La fonction se redemande elle-même à la fin, ce qui forme une boucle.

function image(tempsMs) {          // le navigateur fournit l'heure, en millisecondes
  dessiner();                      // votre fonction de dessin (clear, useProgram, drawArrays…)
  requestAnimationFrame(image);    // on demande l'image suivante
}
requestAnimationFrame(image);      // on lance la boucle
Pourquoi pas une boucle while ou setInterval ?
  • Une boucle while (true) bloque le navigateur : la page ne s'affiche jamais et ne répond plus.
  • setInterval(f, 16) n'est pas synchronisé avec l'écran (images saccadées ou en double) et continue de tourner dans un onglet caché.
  • requestAnimationFrame est synchronisé avec l'écran et se met en pause quand l'onglet n'est pas visible.

2. Le temps écoulé (delta time)

Première idée pour déplacer un objet : x += 0.01 à chaque image. Sur un écran 60 Hz, il avance de 0,6 unité par seconde. Sur un écran 144 Hz, de 1,44. Sur un ordinateur surchargé qui n'affiche que 20 images par seconde, de 0,2. Le jeu n'a pas la même vitesse chez tout le monde.

La solution : exprimer les vitesses par seconde et les multiplier par le temps écoulé depuis l'image précédente, noté dt.

let x = -1;                 // position de l'objet
const vitesse = 0.6;        // en unités par seconde
let tPrecedent = performance.now();

function image(t) {
  const dt = (t - tPrecedent) / 1000;   // en secondes, environ 0.016 à 60 images/s
  tPrecedent = t;

  x += vitesse * dt;                    // vitesse en unités par seconde
  dessiner();
  requestAnimationFrame(image);
}
DémoPar image ou par seconde ?

■ x += 0.01 par image
■ x += 0.6 * dt

Baissez le nombre d'images par seconde : le carré orange ralentit, le bleu garde sa vitesse (il saute simplement plus loin à chaque image).

3. Le pas de temps fixe

Le dt corrige la vitesse moyenne, mais il reste variable : le jeu se comporte légèrement différemment selon la machine. Si une image prend 0,5 s (onglet en arrière-plan, ralentissement), l'objet avance d'un coup de 0,3 unité et peut traverser un mur sans que la collision soit détectée.

Beaucoup de jeux distinguent donc deux choses : la mise à jour du monde (ici, x += vitesse * dt), faite à fréquence fixe, par exemple 60 fois par seconde avec toujours le même pas, et le dessin, fait aussi souvent que l'écran le permet. On accumule le temps réel écoulé dans un réservoir et on le consomme par tranches fixes :

const DT = 1 / 60;          // durée d'une mise à jour : toujours la même
let reserve = 0;            // temps réel pas encore simulé
let tPrecedent = performance.now();

function image(t) {
  reserve += Math.min((t - tPrecedent) / 1000, 0.25);   // plafond : évite la « spirale de la mort »
  tPrecedent = t;

  while (reserve >= DT) {   // autant de mises à jour que nécessaire pour rattraper le temps réel
    x += vitesse * DT;      // la mise à jour, toujours avec le même pas
    reserve -= DT;
  }
  dessiner();               // le dessin, une fois par image
  requestAnimationFrame(image);
}

Si l'écran affiche 60 images par seconde, il y a en moyenne une mise à jour par image. S'il n'en affiche que 30, il y en a deux par image. S'il en affiche 144, certaines images n'ont aucune mise à jour. Dans tous les cas, le monde avance exactement de 60 pas par seconde.

DémoFréquence de mise à jour et fréquence d'affichage

L'affichage tourne toujours à la fréquence de l'écran. Avec peu de mises à jour par seconde, la balle avance par à-coups : le rendu redessine plusieurs fois le même état.

Pour aller plus loin

Pour garder un mouvement fluide même avec peu de mises à jour, on peut dessiner l'objet entre son ancienne et sa nouvelle position, au prorata de reserve / DT. C'est l'interpolation d'état, décrite dans l'article Fix Your Timestep! de Glenn Fiedler.

Mesurer les FPS

On compte les images (et les mises à jour) et on affiche le total une fois par seconde :

const affichage = document.getElementById("fps");   // un élément de la page, par exemple <div id="fps">
let nbImages = 0, debutSeconde = performance.now();

// dans image(t), après dessiner() :
nbImages++;
if (t - debutSeconde >= 1000) {
  affichage.textContent = `${nbImages} FPS`;
  nbImages = 0;
  debutSeconde = t;
}

4. Séparer la mise à jour et le dessin

Dans les boucles précédentes, la mise à jour tient en une ligne. Un vrai jeu manipule tout un état : positions, vitesses, score, vies… On regroupe donc les deux opérations dans deux fonctions bien distinctes :

Cette séparation rend le code plus lisible et permet de mettre le jeu en pause (on n'appelle plus update). La boucle à pas de temps fixe de la section 3 devient :

while (reserve >= DT) {
  update(DT);               // toutes les règles du jeu
  reserve -= DT;
}
render();                   // tout le dessin

Voici l'état d'un petit jeu et ses deux fonctions :

const etat = {
  joueur: { x: 0, y: -0.8, taille: 0.06, vitesse: 1.2 },
  balle:  { x: 0, y: 0, taille: 0.04, vx: 0.7, vy: 0.5 },
};

function update(dt) {
  etat.balle.x += etat.balle.vx * dt;
  etat.balle.y += etat.balle.vy * dt;
  if (Math.abs(etat.balle.x) > 0.95) etat.balle.vx = -etat.balle.vx;   // rebond
}

function render() {
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.useProgram(program);
  gl.bindVertexArray(vao);
  const j = etat.joueur, b = etat.balle;
  dessinerCarre(j.x, j.y, j.taille, [0.3, 0.7, 1.0]);
  dessinerCarre(b.x, b.y, b.taille, [1.0, 0.5, 0.2]);
}

Dessiner plusieurs objets avec la même géométrie

Faut-il modifier le buffer de sommets à chaque fois qu'un carré bouge ? Non : le buffer contient un carré centré en (0, 0), envoyé une seule fois. Le déplacement est confié au vertex shader, via un uniform. Pour dessiner deux objets, on appelle deux fois drawArrays avec des uniforms différents.

#version 300 es
in vec2 a_position;
uniform vec2 u_deplacement;      // position de l'objet
uniform float u_taille;          // demi-côté du carré

void main() {
  gl_Position = vec4(a_position * u_taille + u_deplacement, 0.0, 1.0);
}
#version 300 es
precision highp float;
uniform vec3 u_couleur;
out vec4 couleur;

void main() {
  couleur = vec4(u_couleur, 1.0);
}
// initialisation : le VAO contient un carré de -1 à 1 (6 sommets) ; emplacements des uniforms
const locDeplacement = gl.getUniformLocation(program, "u_deplacement");
const locTaille      = gl.getUniformLocation(program, "u_taille");
const locCouleur     = gl.getUniformLocation(program, "u_couleur");

// dessin : appelée après useProgram et bindVertexArray
function dessinerCarre(x, y, taille, couleur) {
  gl.uniform2f(locDeplacement, x, y);
  gl.uniform1f(locTaille, taille);
  gl.uniform3fv(locCouleur, couleur);
  gl.drawArrays(gl.TRIANGLES, 0, 6);   // même VAO, mêmes 6 sommets
}
À retenir

Envoyer un buffer au GPU coûte cher ; changer un uniform ne coûte presque rien. On envoie la géométrie une fois, puis on la place, la colore et la transforme avec des uniforms. La séance 4 généralise cette idée avec les matrices.

5. Lire le clavier

Mauvaise idée : déplacer le joueur directement dans l'évènement keydown. Cet évènement se répète à la vitesse de répétition du clavier (après un délai), pas à celle du jeu, et ne permet pas d'appuyer sur deux touches à la fois.

Bonne pratique : les évènements mettent seulement à jour l'ensemble des touches enfoncées, et update le consulte.

const touches = new Set();
window.addEventListener("keydown", (e) => touches.add(e.key));
window.addEventListener("keyup",   (e) => touches.delete(e.key));

function update(dt) {
  const j = etat.joueur;
  let dx = 0;
  if (touches.has("ArrowLeft"))  dx -= 1;
  if (touches.has("ArrowRight")) dx += 1;
  j.x += dx * j.vitesse * dt;
  j.x = Math.max(-1 + j.taille, Math.min(1 - j.taille, j.x));   // rester dans le cadre
  // ... puis le déplacement de la balle, comme plus haut
}

Utilisez e.key pour les flèches ("ArrowLeft"…). Pour des touches de déplacement indépendantes de la disposition du clavier (ZQSD en AZERTY, WASD en QWERTY), utilisez e.code : il vaut "KeyW" pour la touche en haut à gauche, quelle que soit la lettre imprimée dessus.

6. Collisions entre rectangles

Pour un premier jeu, on représente chaque objet par un rectangle aligné sur les axes (en anglais AABB). Deux rectangles de centres (x₁, y₁), (x₂, y₂) et de demi-côtés t₁, t₂ se touchent si leurs projections se recouvrent sur les deux axes :

function collision(a, b) {
  return Math.abs(a.x - b.x) < a.taille + b.taille
      && Math.abs(a.y - b.y) < a.taille + b.taille;
}

Résumé