Un chat qui ne respire pas
Quand j'ai posé le premier chat roux au sommet du sapin, il était parfait et complètement mort. La bonne forme, la bonne couleur, mais immobile comme une peluche. Il lui manquait un truc minuscule que l'œil réclame sans savoir le nommer : le ventre qui se soulève, un peu, toutes les trois secondes.
Ancien prof de physique, j'avais la réponse depuis toujours dans mes cours sur les ressorts et les pendules : un mouvement qui va et qui vient, c'est un sinus. La même courbe, vue au tableau pour un ressort, décrit un chat qui respire, une boule de sapin qui se balance ou la flamme d'une cheminée.
Voici la ligne qui fait respirer le chat dans la scène Le Chat Étoile :
cat.scale.y = 1.3 * (1 + Math.sin(t * 2.2) * 0.012);
Trois nombres suffisent à la lire : 2.2, 0.012, et le 1 autour duquel tout oscille. On va les prendre un par un.
y = A · sin(ω·t + φ) : le mode d'emploi
Math.sin prend un nombre et renvoie une valeur qui monte jusqu'à 1, redescend jusqu'à -1, et recommence indéfiniment. Si tu lui passes le temps t en secondes, tu obtiens une valeur qui va et vient au cours du temps. Ensuite, tout se règle avec trois boutons :
y = A * Math.sin(w * t + phi);
A est l'amplitude : la hauteur du va-et-vient. Sans elle, le sinus oscille entre -1 et 1, ce qui est énorme pour un chat. Dans la ligne ci-dessus, l'amplitude est 0.012 : le ventre varie de 1,2 %.
w (la lettre grecque ω, oméga) est la pulsation, en radians par seconde. Le sinus se répète tous les 2π radians (environ 6,28), donc la période, le temps d'un aller-retour complet, vaut T = 2π / ω. Pour le chat, ω = 2.2 donne une période de 2π / 2,2 ≈ 2,86 secondes : un souffle calme.
phi (φ, phi) est la phase : un décalage dans le temps. Elle ne change ni la hauteur ni la vitesse, elle change seulement le moment où l'on est dans le cycle à l'instant 0.
Piège classique : Math.sin travaille en radians, pas en degrés. Math.sin(90) ne vaut pas 1 (ça fait environ 0,89). Pour convertir, multiplie par Math.PI / 180. Et si ton animation est « bizarrement rapide », vérifie d'abord ω : doubler ω divise la période par deux. La période de Math.sin(2 * t) est π, soit environ 3,14 secondes.
Une moyenne, plus une oscillation
Revois la ligne du chat : 1 + Math.sin(t * 2.2) * 0.012. Le 1, c'est la taille normale. Le sinus ne la remplace pas, il s'y ajoute. On obtient une valeur qui reste entre 0,988 et 1,012. Multipliée par 1.3 (la hauteur de base du chat), la hauteur oscille entre 1,284 et 1,316. Moins de 2,5 % de variation, et pourtant l'œil voit que c'est vivant.
Règle d'or : écris toujours « valeur de repos + amplitude × sin(…) ». Le repos dit où l'objet est, le sinus dit de combien il bouge. Tu règles chacun sans toucher l'autre.
La phase : désynchroniser des objets identiques
Dans la même scène, les boules suspendues au sapin se balancent. Si elles utilisaient exactement la même formule, elles iraient et viendraient toutes ensemble, comme des soldats au garde-à-vous. Chacune a donc son propre décalage tiré au hasard :
o.group.rotation.z = Math.sin(t * 1.4 + o.phase) * (0.04 + shake * 0.4);
La pulsation 1.4 donne une période de 4,49 secondes pour toutes les boules. Seule o.phase varie, de 0 à 6 radians (un peu moins d'un tour complet). Quand l'arbre est au repos, l'amplitude vaut 0.04 radian, environ 2,3 degrés. Quand on le secoue, shake monte à 1 et l'amplitude passe à 0,44 radian, environ 25 degrés. Un seul facteur, deux ambiances.
Pourquoi un radian ? L'unité naturelle de rotation dans three.js est le radian : 2π pour un tour, π pour un demi-tour. C'est la même unité que celle de Math.sin, et c'est pour ça que tu peux brancher un sinus directement sur une rotation.
À toi de régler les trois boutons
Voici un chat simplifié qui respire, cinq boules suspendues à une barre, et une luciole qui tourne autour. Tout est piloté par le même sinus. Essaie ces trois choses : mets l'écart de phase à 0 pour voir les boules marcher au pas, puis monte-le jusqu'à ce qu'une vague parcoure la rangée. Ensuite, réduis la période à une seconde. Enfin, regarde le petit graphe : chaque courbe est une boule, et la ligne blanche au centre est « maintenant ».
Ce que tu aurais dû remarquer
Avec un écart de phase nul, les cinq courbes du graphe sont superposées et les boules se balancent à l'unisson. Plus l'écart grandit, plus les courbes se décalent les unes par rapport aux autres : la rangée ondule comme une vague. L'amplitude change la hauteur des courbes (et la taille du souffle du chat) mais jamais leur rythme. La période, elle, change le rythme sans toucher à la hauteur. Le chat respire au même tempo que les boules parce qu'il lit le même angle.
cos et sin ensemble : un cercle
Le sinus et le cosinus ont la même courbe, simplement décalée d'un quart de période : cos(a) vaut 1 quand sin(a) vaut 0, et inversement. Prends-les ensemble comme deux coordonnées, et le point (cos a, sin a) décrit un cercle de rayon 1. Multiplie par un rayon r et tu as une orbite :
firefly.position.x = center.x + r * Math.cos(angle);
firefly.position.z = center.z + r * Math.sin(angle);
L'orbite se déroule dans le plan horizontal (x et z), la hauteur y reste libre. C'est la luciole de la démo : un angle qui grandit avec le temps, et deux lignes pour le convertir en position. À l'angle π / 2, par exemple, cos vaut 0 et sin vaut 1, donc la luciole est à (0, r) par rapport au centre.
Et si on utilise un sinus par axe, avec des pulsations différentes ? On n'a plus un cercle mais une trajectoire qui se promène. C'est ce que font les papillons de la scène Feu & Glace :
b.c.x + Math.sin(t * b.w.x + b.ph.x) * b.r.x,
b.c.y + Math.sin(t * b.w.y + b.ph.y) * b.r.y,
b.c.z + Math.sin(t * b.w.z + b.ph.z) * b.r.z,
Chaque axe a son centre c, son rayon r, sa pulsation w et sa phase ph. Les rayons, pulsations et phases sont tirés au hasard à la création, donc chaque papillon dessine son chemin propre, sans jamais répéter exactement le même.
Scintillement et feu : couper et additionner
Les guirlandes du sapin ne respirent pas : elles s'allument et s'éteignent. Un sinus a une partie négative, qu'on ne peut pas afficher en lumière. La solution tient en quelques caractères :
const glow = 1.6 + 1.4 * Math.max(0, Math.sin(t * 1.3 + i * 0.9));
Math.max(0, …) coupe toutes les valeurs négatives : pendant la moitié du cycle, le sinus est à zéro, l'ampoule reste à sa luminosité de base 1.6, puis elle monte jusqu'à 3.0 avant de redescendre. Le i * 0.9 est la phase vue plus haut : chaque ampoule décale la précédente d'environ 52 degrés, donc la guirlande scintille en vague plutôt qu'en bloc.
Pour le feu de cheminée, un seul sinus serait trop régulier, on y verrait un métronome. L'astuce est d'additionner deux sinus de vitesses sans rapport :
hearth.intensity = 24 + 4 * Math.sin(t * 7) + 3 * Math.sin(t * 13.3);
Les périodes sont environ 0,90 et 0,47 seconde. Comme 7 et 13.3 ne sont pas des multiples simples l'un de l'autre, les deux ondes ne retombent presque jamais en phase : l'intensité reste entre 17 et 31, mais ne repasse jamais deux fois par la même suite de valeurs. L'œil y lit de l'irrégularité, alors que tout est parfaitement calculé.
Astuce : pour que l'effet paraisse naturel, choisis des pulsations qui ne sont pas des multiples entiers l'une de l'autre (7 et 13.3, pas 7 et 14). Avec 7 et 14, la somme se répète à l'identique toutes les 0,9 seconde.
Lerp et easing : aller d'un point à un autre
Dernier outil, qui n'utilise pas de sinus mais s'emploie dans les mêmes situations : passer d'une valeur à une autre en douceur. L'interpolation linéaire, ou lerp, se résume à une formule :
const value = a + (b - a) * k; // k va de 0 (on est en a) à 1 (on est en b)
Avec a = 2, b = 10 et k = 0.25, on obtient 2 + 8 × 0,25 = 4 : un quart du chemin. Fais varier k de 0 à 1 en une seconde et un objet glisse d'un point à l'autre à vitesse constante, ce qui paraît mécanique.
L'easing corrige ça en déformant k avant de l'utiliser. Le plus courant est le smoothstep 3k² - 2k³ : il démarre doucement, accélère au milieu, freine à l'arrivée. Pour k = 0.25, il renvoie 0,156 au lieu de 0,25 : on est encore au début de l'élan, donc en retard sur la ligne droite. Pour k = 0.5, il renvoie exactement 0,5. Un mouvement qui démarre et s'arrête en douceur, c'est ce qu'on appelle un ease-in-out.
A cat that does not breathe
When I first put the ginger cat at the top of the tree, it was perfect and completely dead. Right shape, right colour, but as still as a plush toy. It was missing something tiny that the eye demands without knowing its name: a belly that rises, slightly, every three seconds.
As a former physics teacher, I had always had the answer in my lessons on springs and pendulums: a movement that goes back and forth is a sine. The same curve I drew on the board for a spring also describes a breathing cat, a swinging Christmas bauble or a fireplace flame.
Here is the line that makes the cat breathe in the Star Cat scene:
cat.scale.y = 1.3 * (1 + Math.sin(t * 2.2) * 0.012);
Three numbers are enough to read it: 2.2, 0.012, and the 1 around which everything oscillates. Let's take them one at a time.
y = A · sin(ω·t + φ): the instructions
Math.sin takes a number and returns a value that rises to 1, falls to -1, and starts over forever. Feed it the time t in seconds and you get a value that moves back and forth over time. Then everything is tuned with three knobs:
y = A * Math.sin(w * t + phi);
A is the amplitude: how big the back-and-forth is. Without it, the sine swings between -1 and 1, which is huge for a cat. In the line above, the amplitude is 0.012: the belly varies by 1.2%.
w (the Greek letter ω, omega) is the angular frequency, in radians per second. The sine repeats every 2π radians (about 6.28), so the period, the time of one full round trip, is T = 2π / ω. For the cat, ω = 2.2 gives a period of 2π / 2.2 ≈ 2.86 seconds: a calm breath.
phi (φ) is the phase: a shift in time. It changes neither the height nor the speed, only where in the cycle you are at time 0.
Classic trap: Math.sin works in radians, not degrees. Math.sin(90) is not 1 (it is about 0.89). To convert, multiply by Math.PI / 180. And if your animation is "strangely fast", check ω first: doubling ω halves the period. The period of Math.sin(2 * t) is π, about 3.14 seconds.
An average, plus an oscillation
Look at the cat's line again: 1 + Math.sin(t * 2.2) * 0.012. The 1 is the normal size. The sine does not replace it, it is added to it. The result stays between 0.988 and 1.012. Multiplied by 1.3 (the cat's base height), the height oscillates between 1.284 and 1.316. Less than 2.5% variation, and yet the eye sees that it is alive.
Golden rule: always write "rest value + amplitude × sin(…)". The rest value says where the object is, the sine says how much it moves. You can tune each one without touching the other.
Phase: desynchronising identical objects
In the same scene, the baubles hanging on the tree sway. If they all used exactly the same formula, they would go back and forth together, like soldiers at attention. So each one gets its own random offset:
o.group.rotation.z = Math.sin(t * 1.4 + o.phase) * (0.04 + shake * 0.4);
The 1.4 frequency gives every bauble a period of 4.49 seconds. Only o.phase varies, from 0 to 6 radians (a bit less than one full turn). When the tree is still, the amplitude is 0.04 radian, about 2.3 degrees. When you shake it, shake rises to 1 and the amplitude becomes 0.44 radian, about 25 degrees. One factor, two moods.
Why a radian? The natural unit for rotation in three.js is the radian: 2π for a full turn, π for a half turn. It is the same unit Math.sin uses, which is why you can plug a sine straight into a rotation.
Your turn to tune the three knobs
Here is a simplified cat breathing, five baubles hanging from a bar, and a firefly circling around. Everything is driven by the same sine. Try three things: set the phase gap to 0 to watch the baubles march in step, then raise it until a wave runs along the row. Then cut the period to one second. Finally, look at the small graph: each curve is a bauble, and the white line in the middle is "now".
What you should have noticed
With a zero phase gap, the five curves on the graph overlap and the baubles swing in unison. The larger the gap, the more the curves shift against each other: the row ripples like a wave. Amplitude changes the height of the curves (and the size of the cat's breath) but never their rhythm. Period changes the rhythm without touching the height. The cat breathes at the same tempo as the baubles because it reads the same angle.
cos and sin together: a circle
Sine and cosine are the same curve, simply shifted by a quarter period: cos(a) is 1 when sin(a) is 0, and the other way round. Take them together as two coordinates and the point (cos a, sin a) draws a circle of radius 1. Multiply by a radius r and you have an orbit:
firefly.position.x = center.x + r * Math.cos(angle);
firefly.position.z = center.z + r * Math.sin(angle);
The orbit runs in the horizontal plane (x and z), and the height y stays free. That is the demo's firefly: an angle that grows with time, and two lines to turn it into a position. At angle π / 2, for example, cos is 0 and sin is 1, so the firefly sits at (0, r) relative to the centre.
And what if each axis gets its own sine, with different frequencies? You no longer get a circle but a path that wanders. That is what the butterflies in the Fire & Ice scene do:
b.c.x + Math.sin(t * b.w.x + b.ph.x) * b.r.x,
b.c.y + Math.sin(t * b.w.y + b.ph.y) * b.r.y,
b.c.z + Math.sin(t * b.w.z + b.ph.z) * b.r.z,
Each axis has its centre c, radius r, frequency w and phase ph. Radii, frequencies and phases are drawn at random on creation, so each butterfly traces its own path, never repeating exactly.
Twinkling and fire: clip and add
The tree's string lights do not breathe: they switch on and off. A sine has a negative half, which you cannot show as light. The fix takes a few characters:
const glow = 1.6 + 1.4 * Math.max(0, Math.sin(t * 1.3 + i * 0.9));
Math.max(0, …) clips all negative values: for half of the cycle the sine is at zero and the bulb stays at its base brightness 1.6, then climbs to 3.0 before falling back. The i * 0.9 is the phase from earlier: each bulb is offset from the previous one by about 52 degrees, so the string twinkles in a wave rather than as a block.
For the fireplace, a single sine would be too regular, you would see a metronome. The trick is to add two sines with unrelated speeds:
hearth.intensity = 24 + 4 * Math.sin(t * 7) + 3 * Math.sin(t * 13.3);
The periods are about 0.90 and 0.47 seconds. Because 7 and 13.3 are not simple multiples of each other, the two waves almost never line up again: the intensity stays between 17 and 31, but never goes through the same sequence of values twice. The eye reads irregularity, while everything is perfectly computed.
Tip: to make the effect look natural, pick frequencies that are not whole multiples of each other (7 and 13.3, not 7 and 14). With 7 and 14, the sum repeats identically every 0.9 seconds.
Lerp and easing: getting from one point to another
One last tool, which uses no sine but turns up in the same situations: moving from one value to another smoothly. Linear interpolation, or lerp, boils down to one formula:
const value = a + (b - a) * k; // k goes from 0 (we are at a) to 1 (we are at b)
With a = 2, b = 10 and k = 0.25, you get 2 + 8 × 0.25 = 4: a quarter of the way. Make k go from 0 to 1 over one second and an object slides from one point to the other at constant speed, which looks mechanical.
Easing fixes that by bending k before using it. The most common one is smoothstep, 3k² - 2k³: it starts gently, speeds up in the middle and slows down on arrival. For k = 0.25 it returns 0.156 instead of 0.25: we are still at the start of the run-up, behind the straight line. For k = 0.5 it returns exactly 0.5. A movement that starts and stops softly is called an ease-in-out.
🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Explique à un collègue pourquoi les boules du sapin utilisent Math.sin(t * 1.4 + o.phase) avec une phase différente pour chacune, plutôt que le même sinus pour toutes.
🧠 Rappel libre
Sans remonter : la boule d'un sapin se balance avec Math.sin(t * 1.4). Quelle est sa période, et combien d'allers-retours fait-elle en une minute ?
T = 2π / ω = 2π / 1,4 ≈ 4,49 s. En 60 secondes, la boule fait donc 60 / 4,49 ≈ 13,4 allers-retours.⚖️ Juge le code de l'IA
Tu veux que ton chat respire doucement. L'IA propose : « Mets cat.scale.y = 1 + Math.sin(t), c'est la formule de base du sinus. » Tu acceptes, ou tu rejettes ?
scale.y oscille entre 0 et 2, donc le chat s'écrase à plat puis double de hauteur. Et la période de Math.sin(t) est 2π, soit 6,3 secondes, bien lente pour une respiration. Il faut une amplitude minuscule et une pulsation réglée : 1 + Math.sin(t * 2.2) * 0.012, comme dans la vraie scène.Math.sin(2 * t) ?Math.sin(t * 1.4 + o.phase), que change o.phase ?0.04 à 0.44 dans amplitude * Math.sin(…). Qu'est-ce qui change ?x = r * Math.cos(t) et z = r * Math.sin(t) quand t grandit ?Les boules se balancent gentiment, mais que se passe-t-il quand on secoue l'arbre et qu'elles tombent ? Elles accélèrent, rebondissent, perdent de l'énergie et finissent par s'arrêter. Leçon 5 : la gravité, le rebond et le frottement.
Leçon 5 : Gravité, rebond, frottement : les boules tombent →