Leçon 6/7 10 min

Lumière et matériaux : Lambert, ombres, bloom

Lambert, c'est un produit scalaire : n·l. L'émissif, le tone mapping et le bloom qui font briller un chat de feu ou de glace.

Un chat presque noir qui brille

En classe, je faisais toujours la même démonstration d'optique : une lampe de poche, une boule de polystyrène, et la salle plongée dans le noir. Je demandais « où est-ce que la boule est la plus éclairée ? », et il y avait toujours un élève pour répondre « là où on la regarde ». Faux : elle est la plus éclairée là où sa surface fait face à la lampe, et elle s'éteint doucement vers les bords, jusqu'à la zone que la lampe ne voit pas.

Des années plus tard, dans la scène « Feu & Glace » du Labo 3D, j'ai voulu un chat de feu. Je lui ai donné un pelage 0x5a1a05, un brun presque noir, et pourtant il brille comme une braise. Les yeux, eux, bavent autour d'eux comme dans un film. Rien de tout ça ne vient d'une lampe : tout vient de trois idées que ce cours met bout à bout. Une formule d'une ligne (Lambert), une matière qui s'éclaire toute seule (l'émissif), et deux étapes d'image (tone mapping, bloom).

Lambert : la lumière reçue est un produit scalaire

Reprends ta boule de polystyrène. En un point de sa surface, on dessine la normale n : la flèche qui sort perpendiculairement à la surface. Puis la flèche l qui va de ce point vers la lampe. Plus ces deux flèches sont proches, plus le point est éclairé. C'est exactement la question « est-ce que deux directions se regardent ? » de la leçon 2, et sa réponse est le produit scalaire.

Loi de Lambert : l'intensité diffuse vaut max(0, n·l), avec n et l normalisés (longueur 1). Comme n·l = cos(angle), on obtient : 0° donne 1 (plein éclairage), 30° donne 0,87, 45° donne 0,71, 60° donne 0,5, 90° donne 0.

const l = lamp.position.clone().sub(point).normalize();  // du point vers la lampe
const diffuse = Math.max(0, normal.dot(l));              // 0 à 1
// côté GPU, c'est la même ligne : max(dot(normal, toLight), 0.0)

Deux cas surprennent au début. À 90°, la lumière arrive en rasant la surface : cos 90° = 0, la face est noire, même si la lampe est tout près. Et une face tournée à l'opposé de la lampe donne un n·l négatif (à 120°, cos = -0,5). Une lumière « négative » n'existe pas : le max(0, ...) le ramène à zéro. Dos à la lampe, c'est noir, point.

Piège classique : si n ou l n'est pas normalisé, le produit scalaire n'est plus un cosinus. Un l de longueur 3 triple l'éclairage, et la scène s'éblouit sans raison. Appelle normalize() sur la direction vers la lampe, three.js le fait déjà pour les normales du maillage.

Déboucher les ombres : la lumière hémisphérique

Avec la seule loi de Lambert, tout ce qui tourne le dos à la lampe est d'un noir parfait. Dans la vraie vie, ça n'arrive jamais : le ciel, les murs, le sol renvoient de la lumière partout. Pour imiter ça sans calculer chaque rebond, on ajoute une lumière qui éclaire tout, sans direction précise.

La plus utile est la HemisphereLight : une couleur pour le haut (le ciel), une autre pour le bas (le sol), et les surfaces qui regardent vers le haut reçoivent la première, celles qui regardent vers le bas la seconde. Voilà ce que fait le chat étoile :

scene.add(new THREE.HemisphereLight(0x8ea4d6, 0x2a1a10, 0.55));  // ciel bleuté, sol brun

Règle d'or : une lumière directionnelle ou une lampe pour la forme, une hémisphérique faible pour que les ombres ne soient jamais d'un noir mort. Une ambiante blanche forte aplatit tout, parce qu'elle ne dépend plus de la normale.

La matière : color, roughness, metalness

Reste à dire de quoi est faite la surface. MeshStandardMaterial prend trois réglages principaux. color est la teinte que la surface renvoie en diffus : c'est elle qui multiplie le n·l de Lambert. roughness va de 0 (miroir, reflet net) à 1 (mat, reflet étalé). metalness va de 0 (matière isolante) à 1 (métal, qui teinte ses reflets).

Dans « Feu & Glace », le cœur de glace est un matériau très lisse et un peu métallique :

const iceHeart = new THREE.MeshStandardMaterial({
  color: 0xb8e2ff, roughness: 0.1, metalness: 0.2,
  emissive: 0x2f8fe0, emissiveIntensity: 0.35, flatShading: true
});

Tu vois deux autres lignes qu'on n'a pas encore expliquées : emissive et emissiveIntensity. Elles sont l'idée la plus importante de cette leçon.

L'émissif : la surface ajoute sa propre lumière

L'émissif s'ajoute au résultat, après tout le reste. La surface émet une couleur de son cru, que la lampe soit là ou non, que la face regarde la lampe ou lui tourne le dos. C'est comme ça que le chat de feu brille alors que sa color est presque noire :

const fireHeart = new THREE.MeshStandardMaterial({
  color: 0x5a1a05, roughness: 0.5,       // brun très sombre : Lambert n'a presque rien à renvoyer
  emissive: 0xff4a0a, emissiveIntensity: 1.2   // orange braise, ajouté par la surface elle-même
});

L'émissif n'est pas une lampe : une surface émissive s'éclaire elle-même, mais n'éclaire pas ses voisines. Pour que le cœur du chat colore vraiment le pelage autour, la scène ajoute en plus une vraie PointLight au même endroit (heartLight, dans le code du chat de feu).

Un détail qui compte pour la suite : emissiveIntensity peut dépasser 1. L'écran, lui, ne sait afficher que des canaux entre 0 et 1. Que devient une couleur trop forte ?

Tone mapping : faire tenir l'excès dans l'écran

Sans précaution, tout ce qui dépasse 1 est simplement coupé : un orange très fort devient un aplat jaune-blanc sans nuance, comme une photo brûlée. Le tone mapping remplace cette coupure par une courbe douce qui ramène les intensités élevées dans l'écran en gardant les détails. Pour l'intuition, la courbe la plus simple est x / (1 + x) : 0,5 devient 0,33, 1 devient 0,5, et 4 devient 0,8. Les valeurs fortes sont écrasées en douceur, jamais coupées.

three.js propose plusieurs courbes. Les deux scènes du Labo utilisent la même, inspirée du cinéma (ACES), avec un réglage d'exposition, comme le diaphragme d'un appareil photo :

renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;   // 1.05 pour le chat étoile

Une exposition sous 1 assombrit l'image entière, au-dessus de 1 elle l'éclaircit. Dans « Feu & Glace », 0,9 laisse de la place aux flammes pour briller sans tout brûler.

Bloom : faire baver ce qui brille

Dans un appareil photo, une lumière très vive déborde sur ses voisines : c'est le halo. Le bloom imite ça en post-traitement. Il garde les pixels les plus lumineux de l'image, les floute, puis les rajoute par-dessus. Le chat étoile s'en sert pour ses lueurs :

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));          // dessine la scène hors écran
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.55, 0.5, 0.85);
composer.addPass(bloom);                                  // strength, radius, threshold
composer.addPass(new OutputPass());                       // tone mapping + sortie écran
// dans la boucle : composer.render() à la place de renderer.render(scene, camera)

Les trois derniers nombres se lisent ainsi. strength (0,55) est l'intensité du halo ajouté. radius (0,5) est sa largeur : plus il est grand, plus la lueur s'étale. threshold (0,85) est la luminosité minimale pour qu'un pixel participe : en dessous, il ne bave pas du tout.

Ne l'oublie pas : avec un EffectComposer, la scène est dessinée hors écran, et c'est le dernier passage (OutputPass) qui applique le tone mapping du renderer. Sans lui, l'image garde des couleurs brutes et paraît délavée.

À toi de déplacer la lampe

Voici un chat simplifié, de feu ou de glace, sur un sol. La lampe est la petite sphère claire : glisse-la avec le pointeur sur la scène, ou utilise le curseur d'angle. Le point blanc, au sommet du dos, a pour normale (0, 1, 0) : la ligne sous la scène affiche son n·l et l'angle correspondant. Essaie trois choses : baisse la lampe jusqu'à ce que n·l tombe à 0, puis coupe l'émissif et passe en chat de feu, enfin joue avec le seuil du bloom.

🧊 Démo 3D · Lambert, émissif et bloom
Ce que tu aurais dû remarquer

Quand la lampe descend à hauteur du dos, n·l tend vers 0 et le dos s'assombrit, puis il devient noir quand la lampe passe sous lui (n·l négatif, donc ramené à 0). Le chat de feu, lui, ne s'éteint jamais complètement tant que l'émissif est actif : sa lueur ne dépend pas de la lampe. Sans émissif, il devient presque noir, parce que sa color renvoie très peu de lumière. Côté bloom, baisser le seuil fait baver de plus en plus de zones, jusqu'à noyer le chat dans le halo ; le monter ne laisse bavoir que les yeux.

Les ombres en quelques lignes

Une ombre, c'est un endroit où un objet se trouve entre la lampe et la surface. Pour le savoir, three.js dessine d'abord la scène vue depuis la lampe, en ne gardant que la profondeur : c'est la shadow map. Ensuite, pour chaque pixel de l'écran, il compare sa distance à la lampe avec ce qu'indique la carte. Si quelque chose est plus près, le pixel est à l'ombre.

renderer.shadowMap.enabled = true;
lamp.castShadow = true;                 // la lampe projette
lamp.shadow.mapSize.set(1024, 1024);    // résolution de la carte
cat.castShadow = true;                  // l'objet qui fait de l'ombre
ground.receiveShadow = true;            // la surface qui la reçoit

Astuce : les ombres coûtent cher (une lampe ponctuelle redessine la scène plusieurs fois). Limite-les à une lampe, et à quelques objets. Si des zébrures apparaissent sur les surfaces éclairées, c'est un défaut de la carte : essaie lamp.shadow.bias = -0.0004, valeur du chat étoile.

An almost black cat that glows

In class, I always ran the same optics demo: a torch, a polystyrene ball, and the room plunged into darkness. I would ask "where is the ball brightest?", and there was always a student who answered "where we look at it". Wrong: it is brightest where its surface faces the torch, and it fades gently towards the edges, down to the zone the torch cannot see.

Years later, in the "Fire & Ice" scene of the 3D Lab, I wanted a fire cat. I gave it a 0x5a1a05 coat, an almost black brown, and yet it glows like an ember. Its eyes bleed light around them like in a film. None of that comes from a lamp: it comes from three ideas this course chains together. A one-line formula (Lambert), a material that lights itself (emissive), and two image steps (tone mapping, bloom).

Lambert: the light received is a dot product

Back to the polystyrene ball. At a point on its surface, draw the normal n: the arrow sticking out perpendicular to the surface. Then draw the arrow l going from that point towards the lamp. The closer the two arrows, the brighter the point. It is exactly the "do two directions face each other?" question from lesson 2, and the answer is the dot product.

Lambert's law: the diffuse intensity is max(0, n·l), with n and l normalised (length 1). Since n·l = cos(angle): 0° gives 1 (full light), 30° gives 0.87, 45° gives 0.71, 60° gives 0.5, 90° gives 0.

const l = lamp.position.clone().sub(point).normalize();  // from the point to the lamp
const diffuse = Math.max(0, normal.dot(l));              // 0 to 1
// on the GPU it is the same line: max(dot(normal, toLight), 0.0)

Two cases surprise people at first. At 90°, the light grazes the surface: cos 90° = 0, the face is black even if the lamp is right next to it. And a face turned away from the lamp gives a negative n·l (at 120°, cos = -0.5). "Negative light" does not exist: the max(0, ...) clamps it to zero. Back to the lamp means black, full stop.

Classic trap: if n or l is not normalised, the dot product is no longer a cosine. An l of length 3 triples the lighting, and the scene blows out for no reason. Call normalize() on the direction to the lamp; three.js already does it for mesh normals.

Opening up the shadows: hemisphere light

With Lambert alone, everything facing away from the lamp is perfectly black. In real life that never happens: the sky, the walls and the floor bounce light everywhere. To fake that without computing each bounce, you add a light that lights everything, with no particular direction.

The most useful one is the HemisphereLight: one colour for the top (the sky), another for the bottom (the ground). Surfaces facing up get the first, surfaces facing down get the second. This is what the star cat does:

scene.add(new THREE.HemisphereLight(0x8ea4d6, 0x2a1a10, 0.55));  // bluish sky, brown ground

Golden rule: a directional light or a lamp for the shape, a weak hemisphere light so shadows are never dead black. A strong white ambient light flattens everything, because it no longer depends on the normal.

The material: color, roughness, metalness

You still have to say what the surface is made of. MeshStandardMaterial has three main settings. color is the tint the surface sends back as diffuse light: it multiplies Lambert's n·l. roughness goes from 0 (mirror, sharp reflection) to 1 (matte, spread-out reflection). metalness goes from 0 (insulator) to 1 (metal, which tints its reflections).

In "Fire & Ice", the ice heart is a very smooth, slightly metallic material:

const iceHeart = new THREE.MeshStandardMaterial({
  color: 0xb8e2ff, roughness: 0.1, metalness: 0.2,
  emissive: 0x2f8fe0, emissiveIntensity: 0.35, flatShading: true
});

You can see two other lines we have not explained yet: emissive and emissiveIntensity. They are the most important idea of this lesson.

Emissive: the surface adds its own light

Emissive is added to the result, after everything else. The surface emits a colour of its own, whether the lamp is there or not, whether the face looks at the lamp or turns its back on it. This is how the fire cat glows while its color is almost black:

const fireHeart = new THREE.MeshStandardMaterial({
  color: 0x5a1a05, roughness: 0.5,       // very dark brown: Lambert has almost nothing to return
  emissive: 0xff4a0a, emissiveIntensity: 1.2   // ember orange, added by the surface itself
});

Emissive is not a lamp: an emissive surface lights itself, but does not light its neighbours. For the cat's heart to really colour the fur around it, the scene also adds a real PointLight at the same spot (heartLight, in the fire cat's code).

One detail matters for what follows: emissiveIntensity can exceed 1. The screen, however, only displays channels between 0 and 1. What happens to a colour that is too strong?

Tone mapping: fitting the excess into the screen

Without care, anything above 1 is simply cut off: a very strong orange becomes a flat yellow-white with no nuance, like a burnt photo. Tone mapping replaces that cut with a soft curve that brings high intensities into the screen while keeping the detail. For intuition, the simplest curve is x / (1 + x): 0.5 becomes 0.33, 1 becomes 0.5, and 4 becomes 0.8. Strong values are squeezed gently, never cut.

three.js offers several curves. Both Lab scenes use the same one, inspired by cinema (ACES), with an exposure setting, like a camera's aperture:

renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;   // 1.05 for the star cat

An exposure below 1 darkens the whole image, above 1 it brightens it. In "Fire & Ice", 0.9 leaves room for the flames to shine without burning everything.

Bloom: making bright things bleed

In a camera, a very bright light spills onto its neighbours: that is the halo. Bloom imitates it as post-processing. It keeps the brightest pixels of the image, blurs them, then adds them back on top. The star cat uses it for its glows:

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));          // draws the scene off screen
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.55, 0.5, 0.85);
composer.addPass(bloom);                                  // strength, radius, threshold
composer.addPass(new OutputPass());                       // tone mapping + screen output
// in the loop: composer.render() instead of renderer.render(scene, camera)

The last three numbers read as follows. strength (0.55) is how strong the added halo is. radius (0.5) is its width: the larger, the more the glow spreads. threshold (0.85) is the minimum brightness for a pixel to take part: below it, the pixel does not bleed at all.

Do not forget it: with an EffectComposer, the scene is drawn off screen, and it is the last pass (OutputPass) that applies the renderer's tone mapping. Without it, the image keeps raw colours and looks washed out.

Your turn: move the lamp

Here is a simplified cat, fire or ice, on a floor. The lamp is the small pale sphere: drag it with the pointer on the scene, or use the angle slider. The white dot on top of the back has normal (0, 1, 0): the line under the scene shows its n·l and the matching angle. Try three things: lower the lamp until n·l drops to 0, then switch emissive off on the fire cat, and finally play with the bloom threshold.

🧊 3D demo · Lambert, emissive and bloom
What you should have noticed

When the lamp drops to the height of the back, n·l heads to 0 and the back darkens, then goes black once the lamp is below it (negative n·l, clamped to 0). The fire cat never goes fully dark while emissive is on: its glow does not depend on the lamp. With emissive off, it turns almost black, because its color returns very little light. As for bloom, lowering the threshold makes more and more areas bleed, until the cat drowns in the halo; raising it lets only the eyes bleed.

Shadows in a few lines

A shadow is a place where an object sits between the lamp and the surface. To find out, three.js first draws the scene as seen from the lamp, keeping only the depth: that is the shadow map. Then, for each screen pixel, it compares its distance to the lamp with what the map says. If something is closer, the pixel is in shadow.

renderer.shadowMap.enabled = true;
lamp.castShadow = true;                 // the lamp casts
lamp.shadow.mapSize.set(1024, 1024);    // resolution of the map
cat.castShadow = true;                  // the object that makes a shadow
ground.receiveShadow = true;            // the surface that receives it

Tip: shadows are expensive (a point light redraws the scene several times). Limit them to one lamp and a few objects. If stripes appear on lit surfaces, it is a shadow map artefact: try lamp.shadow.bias = -0.0004, the star cat's value.

🎯 Pratique

S'entraîner (clique pour ouvrir) :

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

Explique à un collègue pourquoi le chat de feu brille même dans le noir, alors que sa color est presque noire, et pourquoi une face de dos à la lampe reste noire.

Une bonne explication dit : la lumière diffuse reçue vaut max(0, n·l) multipliée par la color, donc une color sombre renvoie peu de lumière. L'émissif s'ajoute à part : la surface émet sa propre couleur, sans dépendre des lampes. Pour une face de dos, n·l est négatif, et le max le ramène à 0 : pas de lumière négative, donc noir. Il faut une lumière hémisphérique pour déboucher ces zones.
🧠 Rappel libre
Rappel libre

Sans remonter : une lampe fait un angle de 60° avec la normale d'une face. Quelle part de la lumière cette face reçoit-elle ? Et à 120° ?

À 60°, cos 60° = 0,5 : la face reçoit la moitié de la lumière maximale. À 120°, cos 120° = -0,5, négatif, donc max(0, -0,5) = 0 : la face est noire, elle tourne le dos à la lampe.
⚖️ Juge le code de l'IA
Accepter ou rejeter le code de l'IA

Le dos de ton chat est noir quand la lampe est de face. L'IA propose : « Ajoute emissive: 0xffffff, emissiveIntensity: 1 sur le matériau du chat, comme ça il est éclairé de partout. » Tu acceptes, ou tu rejettes ?

À rejeter. Un émissif blanc ajoute la même lumière partout, indépendamment de la normale : le chat devient une silhouette plate, sans volume, et le tone mapping le fait virer au blanc. Le dos est noir parce qu'n·l y est négatif, c'est normal. La bonne correction est une HemisphereLight faible (ou une seconde lampe de remplissage), qui éclaire selon la direction des faces et garde le relief.
Pourquoi une face à 90° de la lumière est-elle noire ?
Que fait emissive sur un matériau ?
À quoi sert le tone mapping ?
Que règle le threshold d'un UnrealBloomPass ?
Prochaine étape

Tu as vu que la lumière, la matière et l'écran ne parlent que de nombres. La couleur aussi : rouge, vert et bleu forment un vecteur, et la distance entre deux couleurs se mesure comme entre deux points. Leçon 7 : la couleur est un vecteur, avec le k-means et la pipette.

Leçon 7 : La couleur est un vecteur : k-means et pipette →

Une erreur dans cette leçon, un passage flou, une question ? Écrivez-moi : chaque retour améliore ce cours.

Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement