L'environnement : le ciel
Depuis le chapitre sur le soleil, la scène sait quelle
heure il est. Une direction, un vecteur muté en place, et tout le monde s’y branche : l’herbe
pour son éclairage, la pluie pour son dayFactor. Sauf qu’un cycle jour/nuit sans ciel visible,
c’est une scène qui s’assombrit sans qu’on sache pourquoi. Il manque le décor : le dégradé, le
disque du soleil, la lune et les étoiles.
C’est l’objet de ce chapitre. Tout ce qui suit ne fait que lire la direction du soleil, sans jamais rien lui demander.
Un dôme qui suit la caméra
Le ciel est une sphère de rayon 90 rendue par l’intérieur :
// Sky.js, setMesh()
this.skyGeometry = new THREE.SphereGeometry(this.radius, 32, 16);
this.skyMaterial = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
// ...
});
BackSide retourne les faces pour qu’on voie l’intérieur de la sphère, et depthWrite: false
lui interdit d’écrire dans le buffer de profondeur, sinon le dôme masquerait tout ce qui est
plus loin que lui.
Le rayon de 90 n’est pas un chiffre rond posé au hasard : il doit rester inférieur au far de
la caméra, sans quoi le dôme se ferait tronquer par le plan de clipping. C’est le genre de
valeur qu’on ajuste une fois et qu’on oublie, jusqu’au jour où on touche à la caméra.
Enfin, le dôme se recentre sur la caméra à chaque frame :
// Sky.js, update()
this.sky.position.copy(camera.position);
Sans ça, en s’éloignant de l’origine on finirait par sortir de la sphère et voir le ciel de l’extérieur. En le collant à la caméra, l’horizon reste toujours à la même distance, ce qui est exactement le comportement d’un vrai ciel.
Sept keyframes pour une journée
La couleur du ciel ne se calcule pas, elle se choisit. J’ai posé sept keyframes le long des vingt-quatre heures, chacune donnant trois couleurs : l’horizon, le zénith, et la teinte du halo solaire.
// Sky.js
this.gradients = [
{ hour: 0, horizon: "#0a1030", zenith: "#05060f", sun: "#ffd9a0" }, // nuit profonde
{ hour: 5, horizon: "#3a2a55", zenith: "#141a40", sun: "#ff9a6b" }, // aube
{ hour: 6.5, horizon: "#ffae6b", zenith: "#5a7fb5", sun: "#ffb070" }, // lever
{ hour: 9, horizon: "#bfe3ff", zenith: "#2f7bd6", sun: "#fff4e0" }, // plein jour
{ hour: 20, horizon: "#bfe3ff", zenith: "#2f7bd6", sun: "#fff4e0" }, // jour maintenu
{ hour: 22, horizon: "#ff7a4a", zenith: "#3a5aa0", sun: "#ffb070" }, // coucher
{ hour: 23, horizon: "#ff5e3a", zenith: "#2a3a7a", sun: "#ff7a4a" }, // crépuscule rouge
];
Deux choses à remarquer. D’abord, les keyframes de 9h et 20h sont identiques : c’est volontaire, ça fige la couleur du plein jour sur une longue plage plutôt que de la faire dériver lentement pendant onze heures. Les moments intéressants sont les transitions, pas le milieu de journée.
Les heures sont évidemment calées sur le cycle du chapitre sur le soleil avec un levé vers 6h, le midi à 14h et le coucher vers 22h.
L’interpolation cherche les deux keyframes qui encadrent l’heure courante :
// Sky.js
updateColors(hour) {
const stops = this.gradients;
const count = stops.length;
let nextIndex = 0;
while (nextIndex < count && stops[nextIndex].hour <= hour) nextIndex++;
const to = stops[nextIndex % count];
const from = stops[(nextIndex - 1 + count) % count];
const span = (to.hour - from.hour + 24) % 24 || 24;
const t = ((hour - from.hour + 24) % 24) / span;
this.horizonColor.lerpColors(from.horizon, to.horizon, t);
this.zenithColor.lerpColors(from.zenith, to.zenith, t);
this.sunColor.lerpColors(from.sun, to.sun, t);
}
Les modulos par 24 gèrent le bouclage. Entre 23h et minuit, la keyframe suivante est celle
d’indice 0, donc to.hour - from.hour vaut -23, et le + 24) % 24 le ramène à 1 heure. Sans ce
traitement, la nuit se terminerait par un saut de couleur brutal au passage de minuit. Le || 24
couvre le cas dégénéré où les deux keyframes tomberaient sur la même heure.
Ces couleurs sont alors utilisées comme uniform pour le ciel (par référence).
Le dégradé et le halo
Le fragment shader du dôme tient en six lignes. Le vertex ne fait que lui transmettre la direction du point sur la sphère :
// sky/vertex.glsl
vDir = normalize(position);
// sky/fragment.glsl
// Couleur du ciel
//vdir.y =1 au sommet
float h = smoothstep(0.0, 0.4, vDir.y);
vec3 col = mix(uHorizonColor, uZenithColor, h);
// halo chaud autour du soleil
float d = max(dot(normalize(vDir), normalize(uSunDirection)), 0.0); // d=1 => pixel sur le soleil
float glow = pow(d, 32.0) * smoothstep(-0.1, 0.2, uSunDirection.y);
col += uSunColor * glow;
Le dégradé est un simple mix entre horizon et zénith piloté par la hauteur. Le smoothstep(0.0, 0.4, ...)
concentre la transition dans le bas du ciel : au-delà de 0.4 en hauteur normalisée, on est déjà
en couleur de zénith. C’est ce qui donne une bande d’horizon marquée plutôt qu’un dégradé mou
réparti sur tout le dôme.
Le halo est un produit scalaire entre la direction du pixel et celle du soleil : il vaut 1 quand
on regarde pile le soleil et décroît en s’en éloignant. Le pow(d, 32.0) resserre brutalement
cette décroissance, ce qui transforme un gros dégradé flou en une auréole compacte autour du
disque. L’exposant est le bouton de réglage : plus il est grand, plus le halo est serré.
Le smoothstep(-0.1, 0.2, uSunDirection.y) en facteur éteint le halo quand le soleil passe sous
l’horizon. Sans lui, une tache lumineuse continuerait de traverser le ciel nocturne à l’endroit
où le soleil se trouve, sous le sol.
Deux billboards : le soleil et la lune
Le disque du soleil est un simple plan de 10 par 10, repositionné chaque frame :
// Sky.js, update()
this.sunMesh.position
.copy(camera.position)
.addScaledVector(sunDir, this.radius * 0.9);
this.sunMesh.quaternion.copy(camera.quaternion);
this.sunMaterial.uniforms.uOpacity.value = THREE.MathUtils.smoothstep(
sunDir.y,
-0.1,
0.05,
);
On part de la caméra, on avance de 0.9 rayon dans la direction du soleil, et on est posé sur le dôme, légèrement en avant pour ne pas z-fighter avec lui. Le billboard est obtenu en copiant purement et simplement le quaternion de la caméra : le plan adopte son orientation, donc il lui fait toujours face. C’est la version la moins chère du billboard, et elle suffit ici puisque le disque est rond et qu’aucune rotation propre n’a de sens.
La lune est le même objet, à l’opposé :
this.moon.position
.copy(camera.position)
.addScaledVector(sunDir, -this.radius * 0.9);
this.moonMaterial.uniforms.uOpacity.value =
1.0 - THREE.MathUtils.smoothstep(sunDir.y, -0.2, 0.1);
Le -sunDir la place à l’antipode du soleil, ce qui lui donne gratuitement un cycle cohérent :
elle se lève quand le soleil se couche. Son opacité est l’exacte inverse de celle du soleil, avec
des seuils légèrement différents pour que le croisement à l’aube et au crépuscule ne soit pas
symétrique au pixel près.
Les deux ont renderOrder = 1 pour passer après le dôme, et depthWrite: false pour ne pas
bloquer le décor. Ils gardent en revanche le test de profondeur par défaut, donc une falaise
peut toujours les occulter, ce qui est le comportement voulu.
Le croissant de la lune est un joli petit truc de shader, obtenu en soustrayant un disque à un autre :
// moon/fragment.glsl
float dist = length(vUv - 0.5) * 2.0;
float disk = smoothstep(0.5, 0.45, dist); // disque plein
// Disque sombre décalé : creuse le croissant en le soustrayant au disque.
float shadow = smoothstep(0.5, 0.45, length(vUv - vec2(0.62, 0.5)) * 2.0);
float crescent = clamp(disk - shadow, 0.0, 1.0);
Deux disques identiques, l’un décalé de 0.12 en UV, une soustraction, et il reste le croissant. Décaler davantage l’affine, décaler moins donne une lune presque pleine. C’est un paramètre de phase lunaire déguisé, même si je ne l’anime pas.
Sept cents étoiles qui se révèlent une par une
Les étoiles reprennent exactement le pattern de la pluie : un seuil aléatoire figé par élément, comparé à une valeur globale. Sauf qu’ici la valeur globale n’est pas une densité, c’est l’avancement de la nuit.
À la construction, chaque étoile reçoit une position sur la calotte supérieure du dôme, un seuil, une taille et une phase :
// Sky.js, setStars()
const theta = Math.random() * Math.PI * 2;
const y = 0.05 + Math.random() * 0.95; // hauteur normalisée (évite l'horizon pile)
const rxz = Math.sqrt(1 - y * y);
positions[i * 3 + 0] = Math.cos(theta) * rxz * r;
positions[i * 3 + 1] = y * r;
positions[i * 3 + 2] = Math.sin(theta) * rxz * r;
thresholds[i] = Math.random(); // 0 = apparaît tôt, 1 = seulement en nuit profonde
scales[i] = 0.5 + Math.random() * 1.5; // tailles variées
phases[i] = Math.random() * Math.PI * 2; // scintillement déphasé
Le rxz = sqrt(1 - y * y) est ce qui garde les points sur la sphère : une fois la hauteur y
tirée, le rayon disponible dans le plan horizontal en découle par Pythagore. Le tirage part de
0.05 et non de 0 pour éviter d’aligner des étoiles pile sur l’horizon, où elles se retrouveraient
à moitié enfoncées dans le terrain.
Côté shader, l’apparition est une comparaison entre le seuil de l’étoile et la profondeur de la nuit :
// stars/vertex.glsl
float appear = smoothstep(aThreshold, aThreshold + 0.15, uNight);
float twinkle = 0.65 + 0.35 * sin(uTime * 2.0 + aPhase);
vAlpha = appear * twinkle;
Et uNight est poussé depuis le JS, comme l’inverse de la hauteur du soleil :
this.starsMaterial.uniforms.uNight.value =
1.0 - THREE.MathUtils.smoothstep(sunDir.y, -0.35, 0.05);
Le résultat est qu’au crépuscule, seules les étoiles au seuil bas s’allument. Puis la nuit
s’installe, uNight monte, et les autres se joignent progressivement. Au lever, le mouvement
s’inverse. Comme les seuils sont figés une fois pour toutes, les mêmes étoiles apparaissent
toujours en premier et à la même place : c’est très exactement la propriété qu’on cherchait pour
la densité de pluie, et pour la même raison. Un tirage refait chaque frame donnerait un ciel qui
grésille.
Le twinkle est un sinus déphasé par étoile qui fait osciller l’intensité entre 0.3 et 1. Trois
détails de configuration comptent autant que le shader :
blending: (THREE.AdditiveBlending, // points lumineux qui s'ajoutent au ciel sombre
// ...
(this.stars.frustumCulled = false)); // on déplace l'objet chaque frame (suit la caméra)
Le blending additif est ce qui fait qu’une étoile ajoute de la lumière au ciel au lieu de
peindre par-dessus, donc les plus faibles se fondent naturellement dans le fond. Et le
frustumCulled = false est obligatoire : Three.js calcule la boîte englobante une fois, à la
construction, alors que l’objet est déplacé sur la caméra à chaque frame. Sans ça, le moteur le
jugerait hors champ et les étoiles disparaîtraient par blocs entiers selon l’orientation.
Ce que ça donne
Le ciel de cette scène est un empilement de trois objets qui ne font que lire l’heure et la direction du soleil : un dôme dont la palette est interpolée entre sept keyframes, deux billboards antipodaux pour le soleil et la lune, et un nuage de points dont chaque étoile a son propre seuil d’apparition.
Aucun d’eux ne connaît les autres. Chacun s’abonne à la même source, celle du chapitre sur le soleil, et en tire ce dont il a besoin. C’est la même discipline que le vent et la pluie : une donnée mutée à un seul endroit, des consommateurs qui la lisent.
Il reste pourtant un uniform que ce chapitre a soigneusement évité. Sky possède un
weatherBrightness qui multiplie les couleurs de l’horizon et du zénith après le calcul horaire.
Il vaut 1 ici, c’est-à-dire ciel parfaitement dégagé, parce que rien ne le pilote encore. C’est
le sujet du prochain chapitre : brancher une classe Weather qui, d’un seul preset, fera
basculer le ciel, le vent et la pluie du beau temps à la tempête.