Neuf, pas 9,81
Au tableau, je donnais toujours g = 9,81 m/s². Dans la carte de Noël du Labo 3D, quand le chat fait tomber une boule du sapin, la ligne de gravité dit autrement : o.vel.y -= 9 * dt;. Neuf tout rond. Mon ancien moi de prof aurait corrigé la copie.
Sauf que la scène n'est pas notre salon. Elle fait quelques unités de haut, et c'est le rendu à l'écran qui décide de la bonne valeur. En animation, la physique juste est celle qui se voit juste, pas celle du tableau.
Mais une fois la gravité posée, il reste trois questions qui m'ont occupé plus longtemps que prévu : comment faire avancer une boule d'une image à la suivante, que se passe-t-il quand elle touche le sol, et comment l'empêcher de glisser éternellement ?
La méthode d'Euler en trois lignes
En cours, on écrit v(t) = v₀ + g·t et on résout. Dans une animation, on ne connaît pas le futur : on a seulement l'état de la boule maintenant, et un petit pas de temps dt (environ 16 ms à 60 images par seconde). La méthode d'Euler consiste à supposer que l'accélération est constante pendant ce petit pas :
velocity.y -= g * dt; // l'accélération change la vitesse
position.y += velocity.y * dt; // la vitesse change la position
C'est tout. La scène du chat étoile fait exactement cela, avec les vecteurs de la leçon 2 : o.vel.y -= 9 * dt; puis p.addScaledVector(o.vel, dt);, c'est-à-dire p += vel * dt sur les trois axes d'un coup.
L'ordre compte : on met la vitesse à jour avant la position. Dans l'autre sens (position d'abord), on déplace la boule avec la vitesse de l'image précédente, et pour un rebond sans perte l'énergie gagne un peu à chaque contact. C'est ce que montre la case « ordre naïf » de la démo plus bas.
Par exemple, une boule lâchée sans vitesse de 3 m de haut avec g = 9 arrive au sol à √(2 × 9 × 3) ≈ 7,35 m/s. Avec 9,81, on aurait 7,67 m/s : deux poids deux mesures, mais personne ne les verra à l'écran.
Quand dt est grand, Euler dérive
Euler fait une approximation à chaque pas, et plus le pas est grand, plus l'erreur est grosse. Pour une chute libre simple, l'erreur reste modeste. Mais dès qu'il y a un obstacle, deux défauts apparaissent.
Le premier est la dérive d'énergie. Avec l'ordre naïf et un rebond parfait, la boule remonte un peu plus haut à chaque contact. À 60 images par seconde, une boule lâchée de 3,35 m remonte à environ 3,6 m dès le premier rebond, puis 4,3 m au quatrième. À 10 images par seconde (dt = 0,1 s), elle dépasse 5 m dès le premier rebond, 9 m au troisième, et monte sans fin. L'énergie augmente alors qu'aucune force ne l'apporte : c'est la simulation qui ment.
Le second est l'effet tunnel. À 7,35 m/s, une boule avance de 12 cm par pas à 60 images par seconde, mais de 73 cm avec dt = 0,1 s, soit plus que le diamètre d'une boule de 0,5 m. Une étagère mince peut se retrouver entièrement « enjambée » entre deux positions : la boule n'était pas dessous au pas précédent, elle l'est déjà au suivant, sans avoir jamais touché la planche.
Piège du premier plan : quand l'onglet est caché, requestAnimationFrame s'arrête. Au retour, le premier dt peut valoir plusieurs secondes. Si tu l'injectes tel quel dans la physique, toutes les boules traversent le sol d'un coup et s'envolent.
La parade la plus simple, c'est de borner dt. La scène du chat le fait ligne 866 : const dt = Math.min(clock.getDelta(), 0.05). Au pire, la physique ralentit un instant, ce qui ne se voit pas. La parade plus propre, c'est le pas fixe avec accumulateur : on garde un pas physique constant (par exemple 1/120 s) et, à chaque image, on exécute autant de pas que le temps écoulé en contient.
const STEP = 1 / 120;
let accumulator = 0;
function tick(frameDt) {
accumulator += Math.min(frameDt, 0.05); // borne anti-tunnel
while (accumulator >= STEP) {
stepPhysics(STEP); // toujours le même pas
accumulator -= STEP;
}
render(); // affichage découplé de la physique
}
Règle d'or : la physique tourne à pas fixe, l'affichage au rythme de l'écran. Ta simulation devient reproductible : le même résultat sur un écran à 60 Hz, à 144 Hz ou sur un portable qui rame.
Le rebond : la vitesse change de signe
Quand la boule touche le sol, deux choses se passent. On la remet à la surface (p.y = ground), puis on retourne sa vitesse verticale. Au moment de l'impact vy est négative (vers le bas) ; juste après, elle doit être positive (vers le haut). Le signe change, et la valeur diminue parce que le choc perd de l'énergie. Voici les vraies lignes de la scène :
o.vel.y -= 9 * dt;
const p = o.group.position;
p.addScaledVector(o.vel, dt);
let ground = o.r;
// …
if (p.y < ground) {
p.y = ground;
if (Math.abs(o.vel.y) > 0.7) { o.vel.y *= -0.42; o.vel.x *= 0.8; o.vel.z *= 0.8; }
else {
o.vel.y = 0;
const grip = Math.exp(-1.4 * dt);
o.vel.x *= grip; o.vel.z *= grip;
// …
}
}
Le facteur -0.42 est le coefficient de restitution, noté e. Le signe moins retourne la vitesse, la valeur 0,42 la réduit. La vitesse est multipliée par e, donc l'énergie cinétique par e², et la hauteur de rebond aussi : une boule lâchée de 3 m remonte à 3 × 0,42² ≈ 0,53 m.
Les deux cas extrêmes se lisent d'un coup d'œil. Avec e = 0, la boule ne rebondit pas du tout : elle reste collée au sol, comme une boule de pâte à modeler. Avec e = 1, elle repart à la même vitesse et remonte exactement à sa hauteur de départ, pour toujours (sauf si la simulation dérive, voir plus haut).
Reste la question de l'arrêt. Avec e = 0,42, les vitesses d'impact successives depuis 3 m valent environ 7,35, 3,09, 1,30 puis 0,54 m/s. Sans garde-fou, la boule ferait des micro-rebonds de plus en plus petits, à l'infini, en vibrant sur le sol. D'où le seuil : if (Math.abs(o.vel.y) > 0.7). En dessous de 0,7 m/s, on arrête de rebondir, on met vel.y à 0 et on passe au frottement. Ici, la boule fait donc trois rebonds, puis elle se pose.
Astuce : le o.vel.x *= 0.8 du rebond est un frottement au contact : le sol freine aussi la glissade horizontale à chaque choc. C'est un choix de rendu, pas une loi de la physique. Essaie 0,95 et 0,5 et regarde si tes boules « sonnent » plus vrai.
Le frottement : multiplier à chaque pas
Une fois posée, la boule roule, et elle doit ralentir. Le modèle le plus simple multiplie la vitesse par un nombre légèrement inférieur à 1 : v *= grip. Avec grip = 0.97, la boule perd 3 % de sa vitesse à chaque pas. C'est un frottement visqueux : plus elle va vite, plus elle perd, et la vitesse décroît de façon exponentielle.
Il y a un piège. Si tu multiplies par 0,97 à chaque image, la boule ralentit deux fois plus vite sur un écran à 120 Hz que sur un écran à 60 Hz. La scène du chat évite cela avec la forme exacte :
const grip = Math.exp(-1.4 * dt);
o.vel.x *= grip; o.vel.z *= grip;
exp(-k × dt) est le facteur qui s'applique sur la durée dt quand la vitesse suit v(t) = v₀ · e^(-k·t). Avec k = 1,4, on garde e^(-1,4) ≈ 0,247 de la vitesse par seconde, que le dt soit 16 ms ou 33 ms. À 60 images par seconde, ce facteur par pas vaut exp(-1,4/60) ≈ 0,977. Un 0.977 écrit en dur à 30 images par seconde donnerait 0,497 par seconde au lieu de 0,247 : un frottement deux fois trop faible.
Attention : v *= 0.97 sans tenir compte de dt dépend du framerate. Pour un grip « par seconde », écris v *= Math.pow(grip, dt) ou v *= Math.exp(-k * dt). Dans la démo, le curseur est donné « par 1/60 s » et converti avec Math.pow(grip, dt * 60).
À toi de lâcher les boules
Voici cinq boules accrochées à un sapin simplifié, avec une étagère de bois en plein milieu. Au lancement elles tombent déjà. Joue avec les curseurs, et essaie surtout ces trois expériences. D'abord, mets la restitution à 1 : que fait la première boule ? Ensuite, passe la gravité à 2 : tu viens d'aller sur une petite lune. Enfin, monte le pas de simulation à 100 ms et appuie sur « Relâcher les boules » : regarde la deuxième boule, et ce qui arrive à l'étagère.
Ce que tu aurais dû remarquer
Avec e = 1 et un pas fin, la première boule remonte à sa hauteur de départ, rebond après rebond. Avec e = 0, elle se pose sans rebondir. Un frottement proche de 0,80 la freine presque tout de suite, à 1,00 elle roule jusqu'au mur. Avec un pas de 100 ms, la simulation se dégrade : les rebonds se désynchronisent, et la deuxième boule traverse l'étagère, parce que sa position saute d'au-dessus à en dessous de la planche en un seul pas. Coche l'ordre naïf avec e = 1 et un grand pas : la boule monte de plus en plus haut, c'est la dérive d'énergie.
Rouler sans glisser
Reste un détail qui fait toute la différence à l'écran : une boule qui avance sans tourner, ça se voit tout de suite. Quand une sphère de rayon r roule sans glisser à la vitesse v, son point de contact avec le sol est immobile, ce qui impose ω = v / r (vitesse angulaire en radians par seconde). Pour l'animation, on intègre : angle += v × dt / r.
La scène du chat fait exactement ça, avec une subtilité de repère : rouler vers l'avant (z) fait tourner autour de x, et rouler vers la droite (x) fait tourner autour de z, mais dans l'autre sens, d'où le signe moins.
o.group.rotation.x += o.vel.z * dt / o.r;
o.group.rotation.z -= o.vel.x * dt / o.r;
Avec le rayon de 0,25 m de la démo et une vitesse de 2 m/s, on obtient ω = 2 / 0,25 = 8 rad/s, soit environ 1,27 tour par seconde. Remarque le petit culot doré sur chaque boule : il sert uniquement à rendre cette rotation visible.
Ce que le code fait vraiment : il suppose que la boule roule toujours sans glisser, même juste après un rebond où elle glisse en réalité. C'est un raccourci, mais un raccourci qui trompe très bien l'œil.
Nine, not 9.81
On the board, I always wrote g = 9.81 m/s². In the Christmas card from the 3D Lab, when the cat knocks a bauble off the tree, the gravity line says otherwise: o.vel.y -= 9 * dt;. A round nine. My old teacher self would have marked it wrong.
But the scene is not our living room. It is a few units tall, and it is the rendering on screen that decides the right value. In animation, the right physics is the physics that looks right, not the one from the board.
Once gravity is set, three questions remain, and they took me longer than expected: how to move a ball from one frame to the next, what happens when it hits the floor, and how to stop it sliding forever.
Euler's method in three lines
In class, you write v(t) = v₀ + g·t and solve. In an animation, you do not know the future: you only have the ball's state now, and a small time step dt (about 16 ms at 60 frames per second). Euler's method assumes the acceleration is constant during that small step:
velocity.y -= g * dt; // acceleration changes the speed
position.y += velocity.y * dt; // speed changes the position
That is all. The star cat scene does exactly this, with the vectors from lesson 2: o.vel.y -= 9 * dt; then p.addScaledVector(o.vel, dt);, which is p += vel * dt on all three axes at once.
Order matters: update the velocity before the position. The other way round (position first), you move the ball with the previous frame's velocity, and with a lossless bounce the energy creeps up a little at every contact. That is what the "naive order" checkbox in the demo below shows.
For example, a ball dropped with no speed from 3 m with g = 9 hits the floor at √(2 × 9 × 3) ≈ 7.35 m/s. With 9.81 it would be 7.67 m/s: two weights, two measures, but nobody will see the difference on screen.
When dt is large, Euler drifts
Euler makes an approximation at each step, and the bigger the step, the bigger the error. For a plain free fall the error stays modest. But as soon as there is an obstacle, two flaws appear.
The first is energy drift. With the naive order and a perfect bounce, the ball climbs a little higher at every contact. At 60 frames per second, a ball dropped from 3.35 m climbs back to about 3.6 m on the first bounce, then 4.3 m on the fourth. At 10 frames per second (dt = 0.1 s), it exceeds 5 m on the first bounce, 9 m on the third, and keeps climbing. Energy grows although no force supplies it: the simulation is lying.
The second is tunnelling. At 7.35 m/s, a ball moves 12 cm per step at 60 frames per second, but 73 cm with dt = 0.1 s, more than the diameter of a 0.5 m ball. A thin shelf can be completely "stepped over" between two positions: the ball was not below it on the previous step, it already is on the next one, without ever touching the plank.
Background tab trap: when the tab is hidden, requestAnimationFrame stops. On return, the first dt can be several seconds. Feed it raw into the physics and every ball goes through the floor at once and flies off.
The simplest remedy is to clamp dt. The cat scene does it at line 866: const dt = Math.min(clock.getDelta(), 0.05). At worst the physics slows down for a moment, which nobody notices. The cleaner remedy is the fixed step with an accumulator: keep a constant physics step (say 1/120 s) and, each frame, run as many steps as the elapsed time holds.
const STEP = 1 / 120;
let accumulator = 0;
function tick(frameDt) {
accumulator += Math.min(frameDt, 0.05); // anti-tunnelling clamp
while (accumulator >= STEP) {
stepPhysics(STEP); // always the same step
accumulator -= STEP;
}
render(); // display decoupled from physics
}
Golden rule: physics runs at a fixed step, display at the screen's pace. Your simulation becomes reproducible: same result on a 60 Hz screen, a 144 Hz one or a laptop that struggles.
The bounce: the speed changes sign
When the ball hits the floor, two things happen. You put it back on the surface (p.y = ground), then you flip its vertical velocity. At impact vy is negative (downwards); right after, it must be positive (upwards). The sign changes, and the value shrinks because the collision loses energy. Here are the real lines from the scene:
o.vel.y -= 9 * dt;
const p = o.group.position;
p.addScaledVector(o.vel, dt);
let ground = o.r;
// …
if (p.y < ground) {
p.y = ground;
if (Math.abs(o.vel.y) > 0.7) { o.vel.y *= -0.42; o.vel.x *= 0.8; o.vel.z *= 0.8; }
else {
o.vel.y = 0;
const grip = Math.exp(-1.4 * dt);
o.vel.x *= grip; o.vel.z *= grip;
// …
}
}
The -0.42 factor is the coefficient of restitution, written e. The minus sign flips the velocity, the 0.42 shrinks it. Speed is multiplied by e, so kinetic energy by e², and so is the bounce height: a ball dropped from 3 m comes back up to 3 × 0.42² ≈ 0.53 m.
The two extremes read at a glance. With e = 0, the ball does not bounce at all: it stays stuck to the floor, like a lump of modelling clay. With e = 1, it leaves at the same speed and climbs back to exactly its starting height, forever (unless the simulation drifts, see above).
Then there is the question of stopping. With e = 0.42, successive impact speeds from 3 m are about 7.35, 3.09, 1.30 then 0.54 m/s. With no safeguard, the ball would make ever smaller micro-bounces, endlessly, jittering on the floor. Hence the threshold: if (Math.abs(o.vel.y) > 0.7). Below 0.7 m/s, we stop bouncing, set vel.y to 0 and move on to friction. Here the ball makes three bounces, then settles.
Tip: the o.vel.x *= 0.8 in the bounce is contact friction: the floor also slows the horizontal slide at every impact. It is a rendering choice, not a law of physics. Try 0.95 and 0.5 and see whether your baubles "sound" more real.
Friction: multiply at every step
Once on the floor, the ball rolls, and it must slow down. The simplest model multiplies the speed by a number slightly below 1: v *= grip. With grip = 0.97, the ball loses 3% of its speed per step. It is viscous friction: the faster it goes, the more it loses, and speed decays exponentially.
There is a trap. If you multiply by 0.97 on every frame, the ball slows down twice as fast on a 120 Hz screen as on a 60 Hz one. The cat scene avoids this with the exact form:
const grip = Math.exp(-1.4 * dt);
o.vel.x *= grip; o.vel.z *= grip;
exp(-k × dt) is the factor that applies over a duration dt when speed follows v(t) = v₀ · e^(-k·t). With k = 1.4, you keep e^(-1.4) ≈ 0.247 of the speed per second, whether dt is 16 ms or 33 ms. At 60 frames per second this per-step factor is exp(-1.4/60) ≈ 0.977. A hard-coded 0.977 at 30 frames per second would give 0.497 per second instead of 0.247: friction twice too weak.
Watch out: v *= 0.97 without taking dt into account depends on the framerate. For a "per second" grip, write v *= Math.pow(grip, dt) or v *= Math.exp(-k * dt). In the demo, the slider is given "per 1/60 s" and converted with Math.pow(grip, dt * 60).
Your turn to drop the baubles
Here are five baubles hanging on a simplified tree, with a wooden shelf in the middle. They are already falling at launch. Play with the sliders, and above all try these three experiments. First, set restitution to 1: what does the first ball do? Then drop gravity to 2: you have just moved to a small moon. Finally, raise the simulation step to 100 ms and press "Drop the baubles": watch the second ball, and what happens to the shelf.
What you should have noticed
With e = 1 and a fine step, the first ball climbs back to its starting height, bounce after bounce. With e = 0, it settles without bouncing. Friction near 0.80 stops it almost at once, at 1.00 it rolls all the way to the wall. With a 100 ms step, the simulation degrades: bounces drift out of sync, and the second ball goes through the shelf, because its position jumps from above to below the plank in a single step. Tick the naive order with e = 1 and a large step: the ball climbs higher and higher, that is the energy drift.
Rolling without slipping
One last detail makes all the difference on screen: a ball moving without spinning is spotted at once. When a sphere of radius r rolls without slipping at speed v, its contact point with the floor is motionless, which forces ω = v / r (angular speed in radians per second). For the animation, you integrate: angle += v × dt / r.
The cat scene does exactly that, with a subtlety about axes: rolling forward (z) spins around x, and rolling right (x) spins around z, but the other way round, hence the minus sign.
o.group.rotation.x += o.vel.z * dt / o.r;
o.group.rotation.z -= o.vel.x * dt / o.r;
With the demo's 0.25 m radius and a speed of 2 m/s, you get ω = 2 / 0.25 = 8 rad/s, about 1.27 turns per second. Notice the small golden cap on each ball: it is only there to make the spin visible.
What the code really does: it assumes the ball always rolls without slipping, even right after a bounce where it actually slides. It is a shortcut, but one that fools the eye very well.
🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Explique à un collègue comment une boule rebondit dans une simulation (vel.y *= -e) et pourquoi on a besoin d'un seuil d'arrêt.
e entre 0 et 1, qui fait perdre de l'énergie. Chaque rebond est donc plus petit (hauteur multipliée par e²). Sans seuil, les rebonds deviendraient infiniment petits et la boule vibrerait sur le sol. Sous une vitesse minimale, on met vel.y à 0 et on laisse le frottement terminer le travail.🧠 Rappel libre
Sans remonter : une boule tombe de 2 m avec g = 9 et e = 0.5. À quelle vitesse touche-t-elle le sol, et à quelle hauteur remonte-t-elle ?
v = √(2 × g × h) = √(2 × 9 × 2) = 6 m/s. Après le rebond, la vitesse est +3 m/s (signe opposé, multipliée par e). La hauteur atteinte vaut v² / (2g) = 9 / 18 = 0,5 m, soit e² × 2 m.⚖️ Juge le code de l'IA
Ta boucle d'animation contient const dt = Math.min(clock.getDelta(), 0.05). L'IA te dit : « Cette borne est inutile, enlève-la, le vrai dt est plus précis. » Tu acceptes, ou tu rejettes ?
dt est exact, mais la physique d'Euler ne supporte pas les grands pas. Quand l'onglet revient au premier plan, getDelta() peut renvoyer plusieurs secondes : les boules traversent le sol (effet tunnel) et la simulation explose. La borne coûte un léger ralenti dans le pire cas et protège de ça. Pour aller plus loin, on garde la borne et on passe en pas fixe avec accumulateur.vel.y = -6 et e = 0.5. Que vaut vel.y juste après le rebond ?e = 0 et e = 1 pour le rebond ?r = 0.25 roule sans glisser à v = 2 m/s. Quelle est sa vitesse angulaire ω = v / r ?Tes boules tombent et rebondissent, mais dans le noir elles sont invisibles. Pour qu'une sphère ait l'air d'une boule de verre, il faut comprendre comment la lumière frappe une surface. Leçon 6 : lumière et matériaux, avec Lambert, l'émissif et le bloom.
Leçon 6 : Lumière et matériaux : Lambert, ombres, bloom →