Lesson 2/7 10 min

Vectors: position, direction, distance

Add, measure, normalise, and the dot product that answers "is it looking at me?".

« Où est-ce que je tape ? »

En cours de physique, la question qui revenait le plus était toujours la même, sous mille formes : « Monsieur, dans quelle direction va la balle, et à quelle vitesse ? ». Deux informations, un seul objet mathématique : une flèche. Sa direction dit où, sa longueur dit combien. Le mot pour ça, c'est un vecteur.

Dans la carte de Noël du Labo 3D, quand on secoue le sapin, une boule se décroche et doit partir vers l'extérieur de l'arbre, pas droit vers le haut, pas vers l'intérieur. Il fallait donc répondre à « dans quelle direction est cette boule par rapport au tronc ? ». C'est exactement la question de mes anciens élèves, avec une boule de verre à la place de la balle.

Ce cours est le deuxième d'une série où chaque notion de maths sert à quelque chose de visible. Aujourd'hui : soustraire deux positions, mesurer une distance, extraire une direction, et savoir si deux directions se regardent.

Un vecteur, c'est un déplacement

Dans three.js, Vector3 sert à tout : une position, une vitesse, une direction, même une couleur dans certains cas. La classe ne sait pas ce qu'elle représente, c'est à toi de le savoir. Mais le bon réflexe, c'est de voir un vecteur comme un déplacement : « fais trois pas à droite, quatre vers le haut ».

Une position est un déplacement depuis l'origine. Et pour aller d'un point a à un point b, le déplacement est b - a, composante par composante. Avec a = (1, 0, 2) et b = (4, 4, 2), on trouve (3, 4, 0) : trois à droite, quatre en haut, zéro en profondeur.

L'addition fait le chemin inverse : une position plus un déplacement donne une nouvelle position. Partir de a et appliquer (3, 4, 0) redonne b. Voilà toute l'animation d'un objet : à chaque image, on ajoute un petit déplacement à sa position.

const a = new THREE.Vector3(1, 0, 2);
const b = new THREE.Vector3(4, 4, 2);

const ab = new THREE.Vector3().subVectors(b, a);      // b - a = (3, 4, 0)
const next = a.clone().addScaledVector(ab, 0.5);      // a + 0.5 × ab = (2.5, 2, 2)
const same = new THREE.Vector3().lerpVectors(a, b, 0.5); // le milieu, même résultat

addScaledVector(v, s) ajoute v multiplié par s : c'est « avance de s fois ce déplacement ». lerpVectors(a, b, t) donne le point situé à la fraction t du trajet entre a et b : 0 renvoie a, 1 renvoie b, 0,5 le milieu.

Piège classique : a.sub(b), a.add(b) et a.normalize() modifient a sur place. Écrire const dir = target.sub(head.position) déplace aussi target, et ta cible saute sans raison. Pour garder l'original, passe par .clone() ou par subVectors(b, a) sur un vecteur neuf.

La norme : Pythagore en 3D

La longueur d'un vecteur s'appelle sa norme. En 2D, c'est Pythagore : pour (3, 4), on trouve √(9 + 16) = 5. En 3D, on ajoute simplement la troisième composante sous la racine.

La norme : pour v = (x, y, z), |v| = √(x² + y² + z²). Le vecteur (2, 3, 6) mesure √(4 + 9 + 36) = √49 = 7. Un joli résultat entier, pratique pour vérifier à la main.

En code, v.length() donne la norme, et p.distanceTo(q) est un raccourci pour la norme de q - p. La distance entre deux points, c'est la longueur du déplacement qui les relie. Pas de magie.

Dans la scène « Feu & Glace », le chat de glace est couvert de petits piquants. Pour qu'ils ne se chevauchent pas, chaque candidat est refusé s'il est trop près d'un piquant déjà posé :

const GAP = 0.125;
// ...
for (const q of grid.get(`${x + dx},${y + dy},${z + dz}`) ?? [])
  if (q.distanceTo(p) < GAP) return true;   // trop près, on refuse

Une seule comparaison de distance, répétée des milliers de fois, suffit à répartir les piquants de façon régulière.

Astuce de performance : la racine carrée coûte un peu. Pour comparer deux distances, ou une distance à un seuil, distanceToSquared() évite la racine : compare simplement au carré du seuil.

Normaliser : garder la direction, oublier la longueur

Souvent, la longueur d'un déplacement ne t'intéresse pas, seulement sa direction. Pour la garder, on divise le vecteur par sa norme : le résultat pointe pareil, mais mesure exactement 1. On l'appelle un vecteur unitaire, et la méthode est normalize().

Voici la vraie ligne de la scène du chat étoile, quand une boule se décroche :

const out = new THREE.Vector3(o.group.position.x, 0, o.group.position.z).normalize();

La boule est accrochée en (x, y, z) autour du tronc. En mettant y à zéro, on ne garde que la direction horizontale « du tronc vers la boule ». Et normalize() évite que les boules lointaines partent plus vite que les proches. Pour une boule en (0.5, ?, 0.5), le vecteur vaut (0.5, 0, 0.5), de norme 0,71. Normalisé, il devient (0.71, 0, 0.71), de norme 1. Ensuite seulement, on décide de la vitesse en multipliant.

Règle d'or : sépare toujours la direction (vecteur unitaire) de l'intensité (un nombre). direction × vitesse se lit, se règle et se débogue bien mieux qu'un vecteur dont la longueur cache les deux.

Cas limite : normaliser le vecteur nul (0, 0, 0) ne plante pas, il renvoie (0, 0, 0). Si ton objet est exactement sur sa cible, la direction n'existe pas : prévois ce cas.

À toi de faire regarder le chat

Voici une tête de chat simplifiée. Une petite sphère jaune est projetée sur un plan devant elle, au bout de ton pointeur. La direction du regard est normalize(cible - tête). La flèche jaune montre ce regard, la flèche bleue montre la direction tête → caméra, et la ligne d'état affiche la distance et le produit scalaire des deux. Sans pointeur, la cible tourne toute seule.

Essaie ces trois expériences : trouve la zone où la flèche devient verte et où le chat te regarde. Éloigne ensuite le pointeur du chat : la distance grandit, mais le produit scalaire ne bouge qu'avec l'angle. Enfin, descends le seuil à 0,5 : le chat te « regarde » dès que l'angle passe sous 60°.

🧊 Démo 3D · direction, distance et produit scalaire
Ce que tu aurais dû remarquer

Le produit scalaire monte vers 1 quand les deux flèches se superposent, et la zone « Il te regarde ! » est un cône autour de la direction tête → caméra, pas un point. À 0,9, ce cône mesure environ 26° de demi-angle ; à 0,5, 60°. Quand tu éloignes la cible, la distance grandit mais le produit scalaire ne dépend que de l'angle, pas de la longueur, parce que les deux vecteurs sont normalisés.

Le produit scalaire : « est-ce qu'il me regarde ? »

Le produit scalaire de deux vecteurs a et b est un seul nombre. En composantes : a.x × b.x + a.y × b.y + a.z × b.z. Sa signification géométrique est plus parlante :

La formule : a · b = |a| × |b| × cos θ, où θ est l'angle entre les deux vecteurs. Si les deux sont unitaires, a · b = cos θ : 1 pour la même direction, 0 pour deux directions perpendiculaires, -1 pour des directions opposées.

Deux lectures utiles. D'abord, dot = 0 veut dire « perpendiculaires » (ou l'un des vecteurs est nul). Ensuite, le signe dit devant ou derrière : positif, les deux vecteurs vont globalement dans le même sens ; négatif, ils s'opposent. C'est ce que fait la ligne de la démo :

gaze.subVectors(target, head.position).normalize();
toCamera.subVectors(camera.position, head.position).normalize();
const looking = gaze.dot(toCamera) > 0.9;   // cos 26° ≈ 0.9

0,9 correspond à un angle d'environ 25,8° (arccos 0,9). Le chat « te regarde » si son regard tombe dans ce cône de 26° autour de toi.

Le même outil sert dans le ciel de « Feu & Glace ». La direction de la lune est calculée une fois, puis chaque pixel du ciel est comparé à elle :

uMoonDir: { value: moonPos.clone().sub(new THREE.Vector3(0, 1.6, 6)).normalize() }
// dans le shader
float m = max(dot(d, uMoonDir), 0.0);
col += vec3(0.05,0.09,0.14) * pow(m, 45.0) + vec3(0.3,0.4,0.5) * pow(m, 90.0) * 0.35;

Pour un pixel de ciel dans la direction (0, 0.2, -1) normalisée, le produit scalaire avec la lune vaut 0,9993, et pow(m, 45) donne 0,97 : plein halo. Pour (0, 1, -1), le produit vaut 0,81 et pow(m, 45) tombe à 0,0001 : plus rien. Le halo n'est rien d'autre qu'un produit scalaire élevé à une puissance.

Règle d'or : normalise les deux vecteurs avant de comparer un produit scalaire à un seuil. Sans cela, il mélange l'angle et les longueurs : une cible à 3 unités donne 3 × cos θ, et le seuil 0,9 est atteint dès que cos θ dépasse 0,3, soit environ 72° d'écart.

Astuce : pour obtenir l'angle, prends Math.acos(dot) sur deux vecteurs unitaires, puis convertis ces radians avec MathUtils.radToDeg(). Mais pour juste comparer à un seuil, garde le produit scalaire : c'est moins cher, et le seuil se règle directement.

"Where am I hitting?"

In physics class, the most common question was always the same, in a thousand disguises: "Sir, which direction is the ball going, and how fast?". Two pieces of information, a single mathematical object: an arrow. Its direction says where, its length says how much. The word for it is a vector.

In the Christmas card in the 3D Lab, when you shake the tree, a bauble comes loose and has to fly away from the tree, not straight up, not inwards. So I had to answer "in which direction is this bauble relative to the trunk?". It is exactly my former students' question, with a glass bauble instead of the ball.

This is the second lesson in a series where every piece of maths does something visible. Today: subtracting two positions, measuring a distance, extracting a direction, and telling whether two directions face each other.

A vector is a displacement

In three.js, Vector3 is used for everything: a position, a velocity, a direction, even a colour in some cases. The class does not know what it represents, you do. But the right reflex is to see a vector as a displacement: "take three steps right, four up".

A position is a displacement from the origin. And to go from a point a to a point b, the displacement is b - a, component by component. With a = (1, 0, 2) and b = (4, 4, 2), you get (3, 4, 0): three right, four up, zero in depth.

Addition goes the other way: a position plus a displacement gives a new position. Starting from a and applying (3, 4, 0) gives b back. That is the whole animation of an object: each frame, you add a small displacement to its position.

const a = new THREE.Vector3(1, 0, 2);
const b = new THREE.Vector3(4, 4, 2);

const ab = new THREE.Vector3().subVectors(b, a);      // b - a = (3, 4, 0)
const next = a.clone().addScaledVector(ab, 0.5);      // a + 0.5 × ab = (2.5, 2, 2)
const same = new THREE.Vector3().lerpVectors(a, b, 0.5); // the midpoint, same result

addScaledVector(v, s) adds v multiplied by s: "advance s times this displacement". lerpVectors(a, b, t) gives the point at fraction t of the way from a to b: 0 returns a, 1 returns b, 0.5 the midpoint.

Classic trap: a.sub(b), a.add(b) and a.normalize() modify a in place. Writing const dir = target.sub(head.position) also moves target, and your target jumps for no reason. To keep the original, go through .clone() or use subVectors(b, a) on a fresh vector.

The length: Pythagoras in 3D

The length of a vector is called its norm. In 2D it is Pythagoras: for (3, 4), you get √(9 + 16) = 5. In 3D you simply add the third component under the root.

The norm: for v = (x, y, z), |v| = √(x² + y² + z²). The vector (2, 3, 6) measures √(4 + 9 + 36) = √49 = 7. A nice whole number, handy for checking by hand.

In code, v.length() gives the norm, and p.distanceTo(q) is a shortcut for the norm of q - p. The distance between two points is the length of the displacement that links them. No magic.

In the "Fire & Ice" scene, the ice cat is covered in small spikes. To keep them from overlapping, each candidate is rejected if it is too close to a spike already placed:

const GAP = 0.125;
// ...
for (const q of grid.get(`${x + dx},${y + dy},${z + dz}`) ?? [])
  if (q.distanceTo(p) < GAP) return true;   // too close, reject

A single distance comparison, repeated thousands of times, is enough to spread the spikes evenly.

Performance tip: the square root costs a little. To compare two distances, or a distance to a threshold, distanceToSquared() avoids the root: just compare against the square of the threshold.

Normalising: keep the direction, forget the length

Often, you do not care about the length of a displacement, only its direction. To keep it, divide the vector by its norm: the result points the same way but measures exactly 1. It is called a unit vector, and the method is normalize().

Here is the real line from the star cat scene, when a bauble comes loose:

const out = new THREE.Vector3(o.group.position.x, 0, o.group.position.z).normalize();

The bauble hangs at (x, y, z) around the trunk. Setting y to zero keeps only the horizontal direction "from the trunk to the bauble". And normalize() stops distant baubles from flying off faster than close ones. For a bauble at (0.5, ?, 0.5), the vector is (0.5, 0, 0.5), of norm 0.71. Normalised, it becomes (0.71, 0, 0.71), of norm 1. Only then do you decide on the speed, by multiplying.

Golden rule: always separate direction (a unit vector) from intensity (a number). direction × speed reads, tunes and debugs far better than a vector whose length hides both.

Edge case: normalising the zero vector (0, 0, 0) does not crash, it returns (0, 0, 0). If your object sits exactly on its target, the direction does not exist: handle that case.

Your turn to make the cat look

Here is a simplified cat head. A small yellow sphere is projected onto a plane in front of it, under your pointer. The gaze direction is normalize(target - head). The yellow arrow shows that gaze, the blue arrow shows the head → camera direction, and the status line displays the distance and the dot product of the two. With no pointer, the target circles on its own.

Try these three experiments: find the zone where the arrow turns green and the cat looks at you. Then move the pointer away from the cat: the distance grows, but the dot product only follows the angle. Finally, lower the threshold to 0.5: the cat "looks" at you as soon as the angle drops below 60°.

🧊 3D demo · direction, distance and dot product
What you should have noticed

The dot product climbs towards 1 when the two arrows overlap, and the "It is looking at you!" zone is a cone around the head → camera direction, not a point. At 0.9, that cone has a half-angle of about 26°; at 0.5, 60°. When you move the target away, the distance grows but the dot product depends only on the angle, not on the length, because both vectors are normalised.

The dot product: "is it looking at me?"

The dot product of two vectors a and b is a single number. In components: a.x × b.x + a.y × b.y + a.z × b.z. Its geometric meaning is more telling:

The formula: a · b = |a| × |b| × cos θ, where θ is the angle between the two vectors. If both are unit vectors, a · b = cos θ: 1 for the same direction, 0 for perpendicular directions, -1 for opposite directions.

Two useful readings. First, dot = 0 means "perpendicular" (or one of the vectors is zero). Second, the sign tells you in front or behind: positive, the two vectors go roughly the same way; negative, they oppose each other. That is what the demo line does:

gaze.subVectors(target, head.position).normalize();
toCamera.subVectors(camera.position, head.position).normalize();
const looking = gaze.dot(toCamera) > 0.9;   // cos 26° ≈ 0.9

0.9 corresponds to an angle of about 25.8° (arccos 0.9). The cat "looks at you" if its gaze falls within that 26° cone around you.

The same tool is used in the "Fire & Ice" sky. The moon's direction is computed once, then every sky pixel is compared to it:

uMoonDir: { value: moonPos.clone().sub(new THREE.Vector3(0, 1.6, 6)).normalize() }
// in the shader
float m = max(dot(d, uMoonDir), 0.0);
col += vec3(0.05,0.09,0.14) * pow(m, 45.0) + vec3(0.3,0.4,0.5) * pow(m, 90.0) * 0.35;

For a sky pixel in the normalised direction (0, 0.2, -1), the dot product with the moon is 0.9993, and pow(m, 45) gives 0.97: full halo. For (0, 1, -1), the product is 0.81 and pow(m, 45) falls to 0.0001: nothing left. The halo is nothing but a dot product raised to a power.

Golden rule: normalise both vectors before comparing a dot product to a threshold. Otherwise it mixes the angle and the lengths: a target 3 units away gives 3 × cos θ, and the 0.9 threshold is reached as soon as cos θ exceeds 0.3, about 72° off.

Tip: to get the angle, take Math.acos(dot) on two unit vectors, then convert those radians with MathUtils.radToDeg(). But to just compare against a threshold, keep the dot product: it is cheaper, and the threshold can be tuned directly.

🎯 Pratique

S'entraîner (clique pour ouvrir) :

💬 Ré-explique sans regarder
Ré-explique sans regarder

Explique à un collègue comment savoir si un personnage regarde vers la caméra, et pourquoi il faut normaliser les deux directions avant de comparer à un seuil.

Une bonne explication dit : on calcule la direction du regard (cible - tête) et la direction vers la caméra (caméra - tête), on normalise les deux, puis on prend le produit scalaire, qui vaut cos θ. Plus il est proche de 1, plus les directions coïncident, et on compare à un seuil comme 0,9. Sans normalisation, le produit vaut |a| × |b| × cos θ : il dépend aussi des longueurs, donc le seuil ne veut plus rien dire.
🧠 Rappel libre
Rappel libre

Sans remonter : quelle est la distance entre (1, 0, 2) et (4, 4, 2) ?

Le déplacement est (4 - 1, 4 - 0, 2 - 2) = (3, 4, 0), et sa norme vaut √(9 + 16 + 0) = 5. C'est ce que calcule a.distanceTo(b).
⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

Tu veux détecter quand le chat regarde la caméra. L'IA propose : « const gaze = target.sub(head.position); if (gaze.dot(toCamera) > 0.9) { ... }, avec toCamera déjà normalisé. » Tu acceptes, ou tu rejettes ?

À rejeter, pour deux raisons. D'abord sub() modifie target sur place : la cible se déplace à chaque appel. Ensuite gaze n'est pas normalisé : avec une cible à 3 unités, le produit vaut 3 × cos θ et dépasse 0,9 dès 72° d'écart. Il faut new THREE.Vector3().subVectors(target, head.position).normalize().
Quelle est la norme du vecteur (2, 3, 6) ?
Que signifie a.dot(b) === 0 pour deux vecteurs non nuls ?
Pourquoi normaliser les deux vecteurs avant de comparer dot() à un seuil comme 0,9 ?
La tête est en (1, 2, 0) et la cible en (4, 6, 0). Quel est le vecteur déplacement de la tête vers la cible, et quelle distance les sépare ?
Next step

You can now subtract positions and measure directions. But rotating, moving and scaling an object all at once, in the right order, takes one more tool. Lesson 3: matrices (translate, rotate, scale) and why order matters.

Lesson 3: Translate, rotate, scale: matrices →

Spotted an error in this lesson, something unclear, a question? Email me: every message helps improve this course.

Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement