Une tête qui tourne, deux oreilles qui suivent
Quand j'enseignais, un exercice revenait chaque année : « tu déplaces une figure, puis tu la fais tourner. Est-ce la même chose si tu inverses ? » Presque toute la classe répondait oui, et presque toute la classe se trompait. Il suffisait de dessiner un triangle sur une feuille, de le pousser de deux carreaux puis de pivoter la feuille autour du coin, et de recommencer dans l'autre sens.
Le chat du Labo me l'a reposé en vrai. Sa tête suit ton pointeur : elle tourne à gauche, à droite, elle penche. Or cette tête porte deux oreilles, deux yeux, des moustaches. Je n'ai pas écrit une ligne pour déplacer chaque oreille. J'ai juste dit à la tête de tourner, et tout ce qui lui est attaché a suivi.
Le mécanisme derrière ce petit miracle s'appelle une matrice de transformation. C'est le sujet de cette leçon : ce que contient une matrice, pourquoi elle a quatre lignes et quatre colonnes, pourquoi l'ordre des opérations change le résultat, et comment une hiérarchie d'objets s'en sert.
Position, rotation, échelle : trois réglages, une matrice
Tout objet three.js (un Object3D, donc un Mesh, un Group, une lumière) a trois réglages : position (où il est), rotation (comment il est tourné) et scale (de combien il est étiré). Ce sont les seuls que tu manipules.
À chaque image, three.js les range dans une seule matrice 4×4, object.matrix, composée dans cet ordre : T × R × S. Elle agit sur un point de droite à gauche : le point est d'abord étiré par S, puis tourné par R, puis déplacé par T.
const cat = new THREE.Group();
cat.position.set(0, 0, 0.02); // T : où
cat.rotation.y = Math.PI / 2; // R : tourné d'un quart de tour
cat.scale.setScalar(1.3); // S : agrandi de 30 %
cat.updateMatrix(); // matrix = T × R × S
console.log(cat.matrix.elements); // 16 nombres, rangés colonne par colonne
Deux lignes de cette scène viennent du chat étoile : le groupe du chat est agrandi avec cat.scale.setScalar(1.3), et sa tête est un sous-groupe posé à head.position.set(0, 0.47, 0.04). Ces deux réglages finissent chacun dans une matrice.
Où est la position ? Dans matrix.elements, les 16 nombres sont rangés colonne par colonne. La 4e colonne, les éléments 12, 13 et 14, contient la translation : c'est la position de l'objet. Les trois premières colonnes mélangent rotation et échelle.
Dans le code, tu n'écris presque jamais de matrice à la main : tu règles position, rotation et scale, et three.js recalcule la matrice quand il en a besoin. Elle devient utile quand tu veux comprendre ce qui se passe, ou quand tu triches pour de bon, comme on va le faire plus bas.
Piège des radians : rotation est en radians, pas en degrés. Un quart de tour vaut Math.PI / 2, un demi-tour Math.PI. Écrire rotation.y = 90 fait tourner l'objet de 90 radians, soit un peu plus de 14 tours, et le résultat a l'air aléatoire. Pour convertir : THREE.MathUtils.degToRad(90).
Pourquoi quatre lignes, et pas trois ?
Une rotation de l'espace se range dans une matrice 3×3 : trois nombres en entrée, trois en sortie, chacun combinaison des trois autres. L'échelle aussi. Mais pas la translation. Déplacer un point, c'est lui ajouter un vecteur : (x, y, z) + (tx, ty, tz). Or une multiplication par une matrice 3×3 envoie toujours l'origine sur l'origine. Aucune matrice 3×3 ne peut donc pousser l'origine ailleurs : la translation n'est pas une transformation linéaire.
L'astuce, qu'on appelle les coordonnées homogènes, tient en une phrase : on ajoute une quatrième coordonnée w = 1 au point, et on passe en 4×4. La translation se loge alors dans la dernière colonne, et le même produit de matrices gère les trois opérations d'un coup :
// (x, y, z, 1) devient (x + tx, y + ty, z + tz, 1)
// | 1 0 0 tx | | x | | x + tx |
// | 0 1 0 ty | × | y | = | y + ty |
// | 0 0 1 tz | | z | | z + tz |
// | 0 0 0 1 | | 1 | | 1 |
La même idée explique pourquoi les directions n'ont pas de position : un vecteur direction se note avec w = 0, donc la dernière colonne ne peut pas le déplacer. Une flèche qu'on translate reste la même flèche.
L'ordre compte : tourner puis avancer, ce n'est pas avancer puis tourner
Revenons à l'exercice de classe, avec des nombres. Prends la translation T de 2 vers la droite, (2, 0, 0), et la rotation R d'un quart de tour autour de y, 90°. Applique-les à un point qui part de l'origine.
Tourner puis avancer (la matrice T × R) : l'origine tourne sur elle-même, donc ne bouge pas, puis glisse de 2 vers la droite. Elle arrive en (2, 0, 0). Et un point placé à un pas devant elle, en (0, 0, 1), tourne vers la droite et finit en (3, 0, 0).
Avancer puis tourner (la matrice R × T) : l'origine glisse en (2, 0, 0), puis c'est tout le décor qui pivote autour de l'origine du monde. Le point part sur l'arrière et arrive en (0, 0, -2). Le même point (0, 0, 1) finit en (1, 0, -2).
Mêmes ingrédients, mêmes valeurs, deux endroits différents : (2, 0, 0) contre (0, 0, -2). On dit que le produit de matrices n'est pas commutatif. Dans three.js, la bonne manière de lire T × R est : « l'objet est d'abord tourné sur lui-même, puis posé à sa place ». C'est ce que font position et rotation d'un objet, et c'est presque toujours ce que tu veux.
Pour le voir sans qu'aucun objet ne s'en mêle, on peut construire les deux matrices soi-même :
const rotation = new THREE.Matrix4().makeRotationY(Math.PI / 2);
const move = new THREE.Matrix4().makeTranslation(2, 0, 0);
const turnThenMove = new THREE.Matrix4().multiplyMatrices(move, rotation); // T × R
const moveThenTurn = new THREE.Matrix4().multiplyMatrices(rotation, move); // R × T
ghost.matrixAutoUpdate = false; // on pilote la matrice nous-mêmes
ghost.matrix.copy(moveThenTurn);
Piège de lecture : multiplyMatrices(a, b) calcule a × b, et ce produit agit sur un point en appliquant b en premier. Le dernier écrit est le premier appliqué. On se trompe vite dans ce sens-là. Et sans matrixAutoUpdate = false, three.js recalcule matrix depuis position et rotation et écrase ton produit.
À toi de tourner le chat
Voici un chat articulé : le corps est un Group, la tête et la queue en sont les enfants, et la queue elle-même est une chaîne de trois segments emboîtés. Elle ondule toute seule. La boule jaune marque sa pointe, et le texte en dessous donne la 4e colonne de sa matrice monde, donc sa position réelle dans la scène.
Le fantôme bleu est obtenu avec les matrices du dessus, sans passer par position ni rotation. Il reçoit les mêmes valeurs que le chat (l'angle et la position x). Fais tourner le corps à 90° avec x = 2, puis bascule entre « Translation puis rotation » et « Rotation puis translation » : une seule des deux place le fantôme sous le chat. Ensuite bouge la courbure de la queue et regarde les trois segments suivre.
Ce que tu aurais dû remarquer
Avec « Rotation puis translation », le fantôme est exactement sous le chat, orienté comme lui : c'est la composition que three.js applique à un objet. Avec « Translation puis rotation », il décrit un cercle autour de l'origine du monde quand tu tournes le corps : après avoir été poussé de x, il pivote avec tout le repère. À 90° et x = 2, tu lis (0, 0, -2) au lieu de (2, 0, 0). Et quand la queue ondule, la pointe se déplace sans que tu aies rien calculé : chaque segment ne connaît que son angle par rapport au précédent.
Parents et enfants : matrixWorld
Un objet a deux matrices. matrix est la matrice locale : sa position, sa rotation et son échelle par rapport à son parent. matrixWorld est la matrice monde : sa place réelle dans la scène. Le lien entre les deux est un simple produit :
// matrixWorld = parent.matrixWorld × matrix locale
child.matrixWorld.multiplyMatrices(parent.matrixWorld, child.matrix);
Comme chaque parent a lui-même un parent, le produit se déroule de la racine vers la feuille. C'est ce que fait three.js à chaque image, dans scene.updateMatrixWorld(), que le rendu appelle pour toi.
Dans le chat étoile, la structure est exactement celle-là :
const cat = new THREE.Group();
cat.scale.setScalar(1.3);
tree.add(cat); // le chat est un enfant de l'arbre
const head = new THREE.Group();
head.position.set(0, 0.47, 0.04); // décalage local, par rapport au corps
cat.add(head);
// plus loin, dans la boucle d'animation
head.rotation.y += (pointer.x * 0.45 - head.rotation.y) * Math.min(1, dt * 3);
La dernière ligne ne touche qu'un seul nombre : l'angle de la tête. Mais comme les oreilles, les yeux et les moustaches sont enfants de head, leur matrixWorld contient le produit par celle de la tête, et ils tournent avec elle. Et comme le chat est lui-même un enfant de l'arbre, quand l'arbre se balance (tree.rotation.z), le chat se balance avec lui sans que l'animation du chat n'en sache rien.
C'est le rôle de THREE.Group : un objet vide qui n'a rien à dessiner, juste une matrice et des enfants. Il sert de charnière.
Règle d'or : pose chaque article sur son articulation, pas dans le monde. Si l'oreille doit suivre la tête, elle est enfant de la tête, et ses coordonnées sont celles de l'oreille dans la tête. Elle ne contient jamais de calcul « position de la tête plus un peu ».
Dans cette scène, la queue n'est pas une chaîne de groupes : c'est un tube dont la courbe est recalculée à chaque image, ce qui donne une forme plus souple. La chaîne de segments de la démo montre le même principe, à l'état brut : le segment 3 est enfant du 2, qui est enfant du 1, qui est enfant du corps.
Échelle non uniforme sur un parent : si tu fais group.scale.set(2, 1, 1), tu étires le groupe dans la direction x. Un enfant déjà tourné de 45° ne se contente pas de grandir : ses angles se déforment, une sphère devient un ovale penché, un cube un losange. Cette déformation (un cisaillement) ne se rattrape pas en remettant l'enfant droit. Garde une échelle égale sur x, y et z pour tout objet qui a des enfants, et applique l'étirement au maillage seul, à l'intérieur.
Astuce de débogage : pour voir le repère d'un groupe, ajoute-lui un new THREE.AxesHelper(0.5) en enfant. Les flèches suivent le groupe, et tu vois tout de suite si c'est l'ordre des transformations ou l'échelle qui fausse ton résultat.
A head that turns, two ears that follow
When I was teaching, one exercise came back every year: "you move a shape, then rotate it. Is it the same if you swap the two?" Almost the whole class said yes, and almost the whole class was wrong. All it took was drawing a triangle on a sheet of paper, pushing it two squares and then turning the sheet around the corner, and repeating the other way round.
The Lab's cat asked me the same question for real. Its head follows your pointer: it turns left, right, it tilts. Yet that head carries two ears, two eyes, whiskers. I did not write a single line to move each ear. I just told the head to turn, and everything attached to it followed.
The mechanism behind that small miracle is called a transformation matrix. It is the subject of this lesson: what a matrix holds, why it has four rows and four columns, why the order of operations changes the result, and how a hierarchy of objects uses it.
Position, rotation, scale: three settings, one matrix
Every three.js object (an Object3D, so a Mesh, a Group, a light) has three settings: position (where it is), rotation (how it is turned) and scale (how much it is stretched). Those are the only ones you handle.
On every frame, three.js packs them into a single 4×4 matrix, object.matrix, composed in this order: T × R × S. It acts on a point from right to left: the point is first stretched by S, then turned by R, then moved by T.
const cat = new THREE.Group();
cat.position.set(0, 0, 0.02); // T: where
cat.rotation.y = Math.PI / 2; // R: turned a quarter turn
cat.scale.setScalar(1.3); // S: enlarged by 30%
cat.updateMatrix(); // matrix = T × R × S
console.log(cat.matrix.elements); // 16 numbers, stored column by column
Two lines of this scene come from the star cat: the cat's group is enlarged with cat.scale.setScalar(1.3), and its head is a sub-group set at head.position.set(0, 0.47, 0.04). Each of those settings ends up in a matrix.
Where is the position? In matrix.elements, the 16 numbers are stored column by column. The 4th column, elements 12, 13 and 14, holds the translation: that is the object's position. The first three columns mix rotation and scale.
In code, you almost never write a matrix by hand: you set position, rotation and scale, and three.js recomputes the matrix when it needs it. It becomes useful when you want to understand what is going on, or when you deliberately cheat, as we will do below.
The radians trap: rotation is in radians, not degrees. A quarter turn is Math.PI / 2, a half turn is Math.PI. Writing rotation.y = 90 turns the object by 90 radians, a bit over 14 full turns, and the result looks random. To convert: THREE.MathUtils.degToRad(90).
Why four rows, not three?
A rotation in space fits in a 3×3 matrix: three numbers in, three out, each one a combination of the three inputs. So does scaling. But not translation. Moving a point means adding a vector: (x, y, z) + (tx, ty, tz). Yet multiplying by a 3×3 matrix always sends the origin to the origin. So no 3×3 matrix can push the origin elsewhere: translation is not a linear transformation.
The trick, called homogeneous coordinates, fits in one sentence: add a fourth coordinate w = 1 to the point, and move to 4×4. The translation then sits in the last column, and the same matrix product handles all three operations at once:
// (x, y, z, 1) becomes (x + tx, y + ty, z + tz, 1)
// | 1 0 0 tx | | x | | x + tx |
// | 0 1 0 ty | × | y | = | y + ty |
// | 0 0 1 tz | | z | | z + tz |
// | 0 0 0 1 | | 1 | | 1 |
The same idea explains why directions have no position: a direction vector is written with w = 0, so the last column cannot move it. An arrow you translate stays the same arrow.
Order matters: turn then move is not move then turn
Back to the classroom exercise, with numbers. Take the translation T of 2 to the right, (2, 0, 0), and the rotation R of a quarter turn around y, 90°. Apply them to a point starting at the origin.
Turn then move (the matrix T × R): the origin spins on itself, so it does not move, then slides 2 to the right. It lands at (2, 0, 0). And a point one step in front of it, at (0, 0, 1), turns to the right and ends up at (3, 0, 0).
Move then turn (the matrix R × T): the origin slides to (2, 0, 0), then the whole scenery pivots around the world origin. The point swings round to the back and lands at (0, 0, -2). The same point (0, 0, 1) ends at (1, 0, -2).
Same ingredients, same values, two different places: (2, 0, 0) versus (0, 0, -2). We say that the matrix product is not commutative. In three.js, the right way to read T × R is: "the object is first turned on itself, then put in place". That is what an object's position and rotation do, and it is almost always what you want.
To see it with no object getting in the way, you can build both matrices yourself:
const rotation = new THREE.Matrix4().makeRotationY(Math.PI / 2);
const move = new THREE.Matrix4().makeTranslation(2, 0, 0);
const turnThenMove = new THREE.Matrix4().multiplyMatrices(move, rotation); // T × R
const moveThenTurn = new THREE.Matrix4().multiplyMatrices(rotation, move); // R × T
ghost.matrixAutoUpdate = false; // we drive the matrix ourselves
ghost.matrix.copy(moveThenTurn);
Reading trap: multiplyMatrices(a, b) computes a × b, and that product acts on a point by applying b first. The last one written is the first one applied. It is easy to get that backwards. And without matrixAutoUpdate = false, three.js recomputes matrix from position and rotation and overwrites your product.
Your turn to turn the cat
Here is an articulated cat: the body is a Group, the head and tail are its children, and the tail itself is a chain of three nested segments. It waves on its own. The yellow ball marks its tip, and the text below gives the 4th column of its world matrix, so its real position in the scene.
The blue ghost is built with the matrices above, without using position or rotation. It gets the same values as the cat (the angle and the x position). Turn the body to 90° with x = 2, then switch between "Translate, then rotate" and "Rotate, then translate": only one of the two puts the ghost under the cat. Then move the tail bend and watch the three segments follow.
What you should have noticed
With "Rotate, then translate", the ghost sits exactly under the cat, facing the same way: that is the composition three.js applies to an object. With "Translate, then rotate", it traces a circle around the world origin as you turn the body: after being pushed by x, it pivots along with the whole frame. At 90° and x = 2, you read (0, 0, -2) instead of (2, 0, 0). And when the tail waves, the tip moves without you computing anything: each segment only knows its angle relative to the previous one.
Parents and children: matrixWorld
An object has two matrices. matrix is the local matrix: its position, rotation and scale relative to its parent. matrixWorld is the world matrix: its real place in the scene. The link between them is a plain product:
// matrixWorld = parent.matrixWorld × local matrix
child.matrixWorld.multiplyMatrices(parent.matrixWorld, child.matrix);
Since every parent has a parent of its own, the product unrolls from the root down to the leaf. That is what three.js does on every frame, in scene.updateMatrixWorld(), which rendering calls for you.
In the star cat, the structure is exactly that:
const cat = new THREE.Group();
cat.scale.setScalar(1.3);
tree.add(cat); // the cat is a child of the tree
const head = new THREE.Group();
head.position.set(0, 0.47, 0.04); // local offset, relative to the body
cat.add(head);
// later, in the animation loop
head.rotation.y += (pointer.x * 0.45 - head.rotation.y) * Math.min(1, dt * 3);
The last line touches a single number: the head's angle. But since the ears, eyes and whiskers are children of head, their matrixWorld contains the product with the head's, and they turn with it. And since the cat is itself a child of the tree, when the tree sways (tree.rotation.z), the cat sways with it without the cat's animation knowing anything about it.
That is the role of THREE.Group: an empty object with nothing to draw, just a matrix and children. It acts as a hinge.
Golden rule: hang each piece on its joint, not in the world. If the ear must follow the head, it is a child of the head, and its coordinates are those of the ear inside the head. They never contain a "head position plus a bit" computation.
In that scene, the tail is not a chain of groups: it is a tube whose curve is recomputed every frame, which gives a smoother shape. The demo's chain of segments shows the same principle in its raw form: segment 3 is a child of 2, which is a child of 1, which is a child of the body.
Non-uniform scale on a parent: if you write group.scale.set(2, 1, 1), you stretch the group along x. A child already turned by 45° does not just get bigger: its angles distort, a sphere becomes a tilted oval, a cube a rhombus. That distortion (a shear) cannot be undone by straightening the child. Keep scale equal on x, y and z for any object that has children, and apply the stretch to the mesh alone, inside.
Debugging tip: to see a group's frame, add a new THREE.AxesHelper(0.5) as its child. The arrows follow the group, and you immediately see whether it is the transformation order or the scale that throws your result off.
🎯 Pratique
S'entraîner (clique pour ouvrir) :
💬 Ré-explique sans regarder
Explique à un collègue pourquoi la queue du chat suit le corps sans que tu calcules sa position, et ce que contient matrixWorld.
parent.matrixWorld × matrix, déroulé de la racine jusqu'à l'objet. Quand le corps bouge, sa matrice change, donc celle de la queue aussi, sans que la queue ait été touchée.🧠 Rappel libre
Sans remonter : une translation de (2, 0, 0) et une rotation de 90° autour de y. Où arrive l'origine avec T × R, et où avec R × T ?
T × R, l'origine tourne sur elle-même puis glisse : elle arrive en (2, 0, 0). Avec R × T, elle glisse en (2, 0, 0) puis pivote autour de l'origine du monde : elle arrive en (0, 0, -2). Le produit de matrices n'est pas commutatif.⚖️ Juge le code de l'IA
Ton chat est trop court, tu veux l'allonger. L'IA propose : « Mets cat.scale.set(1, 1, 2) sur le groupe du chat. » La tête est tournée de 30° dans ce groupe. Tu acceptes, ou tu rejettes ?
body.scale.z = 2, ou construis un corps plus long. Le groupe garde une échelle uniforme.(2, 0, 0) et une rotation de 90° autour de y. Le résultat est-il le même quel que soit l'ordre ?head avec head.add(ear), puis tu tournes head.rotation.y. Que deviennent les oreilles ?A waving tail, a breathing cat, a twinkling star: behind each of these movements is the same curve rising and falling without end. Lesson 4: sine and cosine, orbits, breathing and twinkling.
Lesson 4: Sine and cosine: orbits and oscillations →