L'environnement : le soleil et le cycle jour/nuit
Dans le chapitre précédent, j’ai corrigé la normale des
brins d’herbe pour qu’un brin qui se penche face à la lumière s’éclaircisse. Sauf que je n’ai
jamais dit d’où venait cette lumière. Le shader d’herbe parle depuis le début d’un
uSunDirection que personne n’a présenté.
C’est le sujet de ce chapitre : la classe Sun. Elle reprend exactement le contrat du vent,
posséder la donnée et la muter en place, mais elle y ajoute quelque chose que le vent n’avait
pas, le temps qui passe.
Une seule direction, tout le monde s’y branche
Sun possède la position du soleil sous la forme d’un vecteur unitaire qui pointe depuis la
scène vers le soleil. C’est la source unique de vérité :
// Sun.js
// Source unique de vérité, partagée PAR RÉFÉRENCE dans les uniforms.
// Mutée en place par updateSun, jamais réassignée.
this.sunDirection = new THREE.Vector3();
// Intensité ambiante consommée par les shaders (snapshot non réactif)
this.ambientIntensity = 0.4;
L’herbe reçoit le soleil dans son constructeur, exactement comme elle recevait le vent, et branche le vecteur dans ses uniforms :
// Grass.js
uSunDirection: { value: this.sun.sunDirection }, // partagé (réf) : muté en place par updateSun
uAmbientLight: { value: this.sun.ambientIntensity },
C’est le même mécanisme qu’avec uWindDirection : sunDirection est un objet, donc
l’uniform et Sun pointent sur le même Vector3 en mémoire. Quand le soleil bouge, Sun mute
le vecteur et l’herbe voit la nouvelle valeur à la frame suivante, sans une ligne de code
côté herbe. Et comme pour le vent, l’intensité ambiante est un nombre, donc une copie figée :
la changer à chaud demanderait de la recopier explicitement, ce que je ne fais pas ici parce
qu’elle ne bouge jamais.
Deux lumières pour le terrain, un vecteur pour l’herbe
Sun crée aussi deux vraies lumières Three.js :
setLights() {
this.ambientLight = new THREE.AmbientLight('#ffffff', 0.4)
this.scene.add(this.ambientLight)
this.directionalLight = new THREE.DirectionalLight('#ffffff', 1.5)
this.scene.add(this.directionalLight)
}
Elles ne servent pas à l’herbe. Le terrain, lui, utilise une MeshStandardMaterial (voir le
chapitre sur le terrain), donc Three.js gère son
éclairage tout seul à partir des lumières présentes dans la scène. L’herbe a un
ShaderMaterial : elle ne voit aucune lumière, elle ne connaît que le vecteur qu’on lui a
passé et calcule son ombrage à la main.
D’où la dernière ligne d’updateSun, qui garde les deux mondes d’accord :
this.directionalLight.position.copy(this.sunDirection);
Le terrain est éclairé par une DirectionalLight dont la position suit le vecteur, l’herbe
lit le vecteur directement. Une seule source, deux façons de la consommer, et un 0.4 qu’on
retrouve des deux côtés (AmbientLight et ambientIntensity) pour que le sol et les brins
aient la même base ambiante.
Dessiner l’arc : quatre paramètres
Reste à calculer ce vecteur. Un vrai calcul d’éphéméride serait précis et parfaitement inutile ici : la scène est stylisée, ce que je veux c’est un arc crédible et surtout réglable. Quatre paramètres suffisent :
this.sunParams = {
hour: 12, // heure de la journée (0-24)
inclination: 0.6, // inclinaison du PLAN de l'arc : 0 = passe par le zénith
orientation: 0.0, // azimut : fait pivoter tout l'arc autour de la verticale
declination: 0.41, // remonte tout l'arc : écarte lever et coucher (valeur trouvée en testant)
};
updateSun les transforme en direction, en quatre étapes qui se lisent comme une
construction géométrique :
updateSun() {
// 0 a 8h (levé) Pi a 20h (couché)
const arcAngle = (this.sunParams.hour - 8) * (Math.PI / 12)
// on applique l'angle de l'arc pour obtenir la direction du soleil dans le plan vertical (X,Y)
this.sunDirection.set(Math.cos(arcAngle), Math.sin(arcAngle), 0)
// on applique l'inclinaison
this.sunDirection.applyAxisAngle(SUN_AXIS_X, this.sunParams.inclination)
// on applique l'orientation (azimut) : rotation autour de l'axe vertical Y
this.sunDirection.applyAxisAngle(SUN_AXIS_Y, this.sunParams.orientation)
// 12 heures de jour est trop court (levé 8h couché 20h), on remonte l'arc en entier pour allonger le jour
this.sunDirection.y += this.sunParams.declination
this.sunDirection.normalize()
this.directionalLight.position.copy(this.sunDirection)
}
Le set(cos, sin, 0) pose un demi-cercle vertical parfait : le soleil se lève plein Est
(arcAngle = 0, donc le vecteur (1, 0, 0)), passe pile au zénith (PI/2, donc (0, 1, 0)),
se couche plein Ouest (PI, donc (-1, 0, 0)). Le sin sur .y est ce qui donne sa hauteur
au soleil, et le zéro sur Z force l’arc à passer exactement au-dessus de la scène. C’est
géométriquement propre et visuellement plat, parce que personne n’a jamais vu ça ailleurs qu’à
l’équateur un jour d’équinoxe.
L’inclinaison corrige ça en penchant le plan de l’arc autour de l’axe Est-Ouest. Le détail important : les points de lever et de coucher sont posés sur cet axe X, donc la rotation ne les bouge pas. Seul le point haut de la course bascule vers le Sud. Le soleil culmine plus bas, la lumière reste rasante plus longtemps, et l’herbe passe sa journée dans une lumière plus intéressante qu’un éclairage vertical de midi.
L’orientation fait tourner l’arc entier autour de la verticale. C’est le réglage de cadrage : il choisit où le soleil se lève par rapport à la caméra, sans rien changer à la forme de la course.
La déclinaison mérite qu’on s’y arrête, parce que c’est une triche assumée. Ajouter une
constante à .y remonte tout l’arc en bloc, ce qui écarte le lever et le coucher : le soleil
passe sous l’horizon plus tard, la journée s’allonge. Avec le midi solaire calé à 14h et une
déclinaison de 0.41, j’obtiens un lever vers 6h et un coucher vers 22h, à zéro, la journée se
réduit à 8h-20h. Je règle donc la durée du jour avec un seul curseur, ce qu’aucun calcul
astronomique honnête ne me permettrait.
Le normalize() final rattrape le tout, puisqu’ajouter 0.41 à .y a évidemment cassé la
norme du vecteur. Et le point le plus discret est le plus important : ces quatre opérations
restent périodiques sur 24h. Rien ne se réinitialise, rien ne saute à minuit. L’heure peut
boucler indéfiniment, la direction suit sans discontinuité.
Une heure qui avance toute seule
Le cycle jour/nuit, ce n’est rien de plus que cette heure qu’on fait avancer :
this.dayDuration = 120 // secondes pour un cycle 24h complet
this.autoPlay = true
update() {
if (this.autoPlay) {
this.sunParams.hour = (this.sunParams.hour + (this.time.delta / this.dayDuration) * 24) % 24
this.updateSun()
if (this.hourController) this.hourController.updateDisplay() // le slider suit
}
}
Deux détails valent le détour. Le % 24 boucle sans faire de cas particulier, précisément
parce que l’arc est périodique : 23h59 mène à 0h01 sans le moindre à-coup visuel. Et l’avancée
est proportionnelle à time.delta, pas à un incrément fixe par frame. Le cycle dure donc 120
secondes réelles, que la machine tourne à 144 fps ou peine à 30.
Côté debug, la saisie manuelle coupe l’automatique, sinon les deux se battent pour la même variable et le curseur revient sans arrêt sous les doigts :
this.hourController = folder
.add(this.sunParams, "hour", 0, 24, 0.1)
.name("Heure")
.onChange(() => {
this.autoPlay = false;
this.updateSun();
});
Éclairer un brin
Une fois sunDirection dans le shader d’herbe, la donnée la plus utile n’est pas le vecteur
entier, c’est sa composante verticale. sunDirection.y mesure la hauteur du soleil : positif
il est levé, négatif il est couché, zéro c’est l’horizon. Cette seule valeur suffit à savoir
qu’il fait nuit.
// grass/fragment.glsl
float AmbientLight = uAmbientLight;
vec3 normal = normalize(vNormal);
if (!gl_FrontFacing) normal = -normal; // face arrière (DoubleSide) : on utilise SA normale sortante
float sunOrientation = dot(uSunDirection, normal);
// 0 quand le soleil est sous l'horizon, 1 quand il est bien levé
float dayFactor = smoothstep(-0.15, 0.05, uSunDirection.y);
float diffuse = max(sunOrientation * 0.5 + 0.5, 0.0) * dayFactor; // half lambert
Le dot(uSunDirection, normal) est l’éclairage classique : un brin face au soleil reçoit
plus de lumière qu’un brin de profil. C’est ici que la correction de normale du chapitre
précédent est payée, puisque c’est cette normale corrigée qui entre dans le produit scalaire.
Le * 0.5 + 0.5 en fait un half lambert, ce qui remonte les faces détournées du soleil au
lieu de les laisser tomber à zéro : plus doux, plus stylisé, moins réaliste, et c’est très
bien.
Le smoothstep(-0.15, 0.05, ...), lui, est le crépuscule résumé en une ligne. La lumière
directe ne s’éteint pas au moment exact où le soleil touche l’horizon : elle décline sur une
petite bande autour de zéro. Sans ce fondu, la nuit tomberait comme un interrupteur.
Vient ensuite la ligne qui sauve la nuit :
vec3 col = mix(uBaseColor, uTipColor, vColor.x); // dégradé base -> pointe
col *= AmbientLight + diffuse * (1.0 - AmbientLight); // ambiant additif
col = smoothstep(0.1, 0.9, col); // accentue les contrastes, rendu plus stylisé
Le dayFactor ne multiplie que le diffus, jamais l’ambiant. Quand le soleil est couché,
diffuse tombe à zéro et il reste le 0.4 d’ambiant : la scène devient sombre mais reste
lisible, au lieu de virer au noir absolu. Et le fait d’ajouter l’ambiant plutôt que de
l’appliquer par-dessus garde un ombrage directionnel visible même en lumière rasante, au lever
comme au coucher.
Le spéculaire suit la même logique, filtré par dayFactor :
float specularFilter = pow(vColor.x, 30.0); // seulement la pointe du brin
vec3 lightReflection = reflect(-uSunDirection, normal);
float specular = pow(max(-dot(lightReflection, viewDirection), 0.0), 40.0);
col += specular * specularFilter * dayFactor * specularStrength;
Le pow(vColor.x, 30.0) réutilise l’attribut de courbure du chapitre sur le vent : cet
exposant énorme écrase tout sauf le tout dernier centimètre du brin, donc seules les pointes
accrochent la lumière. Le dayFactor fait le reste et évite l’absurdité d’un reflet de soleil
sur l’herbe à deux heures du matin.
Ce que ça donne
Le soleil de cette scène, c’est un Vector3 et quatre nombres. updateSun reconstruit l’arc
à chaque frame par quatre opérations géométriques, mute le vecteur en place, et s’arrête là.
Il ne notifie personne et ne pousse aucun uniform : le terrain suit via une DirectionalLight
positionnée sur le vecteur, l’herbe le lit directement dans son shader.
Le cycle jour/nuit, lui, ne demande presque rien de plus qu’une heure qu’on fait avancer et un
modulo. Toute la richesse visuelle vient d’ailleurs : d’un arc incliné plutôt que vertical,
d’un smoothstep autour de l’horizon plutôt qu’un test binaire, et d’un ambiant qui refuse de
s’éteindre.
Mais pour l’instant, la nuit tombe sur un fond vide : la scène s’assombrit sans que rien au-dessus ne le raconte. Le prochain chapitre s’attaque au ciel.