Construire un monde ouvert dans le navigateur, partie 26 : trois façons de créer de l’eau
Par Oleg Sidorkin, CTO et cofondateur de Cinevva
Vous découvrez la série ? Consultez le guide de la série. Il explique ce qu’est un spike et renvoie vers toutes les parties.
La partie 25 habillait les avatars. Cette partie traite de l’eau et comporte trois spikes, car l’eau est la surface sur laquelle un raccourci bon marché et la bonne solution paraissent identiques sur une capture d’écran, mais complètement différents en mouvement. Le spike 51 construit les réflexions en espace écran, la solution tentante, et se heurte directement à sa limite intrinsèque. Le spike 52 passe à la méthode réellement utilisée par tous les jeux commercialisés. Le spike 53 intègre une bibliothèque d’eau aboutie pour mesurer la distance qui nous sépare d’une solution « terminée ». Les trois partagent la même couche de réfraction : entre les deux premiers, la seule variable qui change est donc la manière dont la réflexion est calculée.
Une réflexion tirée de l’image déjà rendue
Ouvrir le spike 51 dans un nouvel onglet ↗ · Voir le code source
La réflexion en espace écran réutilise l’image déjà rendue. Pour chaque pixel de l’eau, on réfléchit le rayon de vue sur la surface, on fait progresser ce rayon réfléchi dans le tampon de profondeur et, lorsque le rayon passe derrière une surface enregistrée, on a trouvé ce que l’eau reflète, directement échantillonné depuis le tampon de couleur. Le portage suit ligne par ligne le SSRNode de three.js, lui-même inspiré du guide d’introduction au SSR de lettier. La progression est un parcours DDA en espace écran : on projette le début et la fin du rayon dans les coordonnées des pixels, puis on avance le long de l’axe le plus long, à raison d’un échantillon par pixel. À chaque étape, la profondeur du rayon réfléchi nécessite une interpolation corrigée pour la perspective,
Deux éléments rendent cette technique utilisable plutôt que terriblement lente. La progression grossière est limitée à 64 étapes, car un long rayon projeté sur un millier de pixels effectuerait sinon des centaines d’itérations par fragment ; avec un plan d’eau d’un million de fragments, multiplié par des centaines d’itérations et plusieurs échantillonnages de texture, la scène tomberait à 30 i/s. Le réglage de qualité contrôle le pas effectif à l’intérieur de cette limite plutôt que le nombre d’itérations. Et comme une progression grossière plafonnée laisse apparaître des bandes en escalier, un raffinement binaire de six itérations divise successivement l’intervalle entre le dernier échec et l’intersection. Il offre ainsi une précision 64 fois supérieure au pas initial, suffisante pour empêcher les fragments d’eau voisins de se verrouiller sur la même position d’intersection grossière. Un dernier contrôle de distance entre le point et la ligne confirme que le candidat se trouve réellement sur le rayon réfléchi, et pas seulement à la même profondeur. Il utilise une tolérance d’épaisseur automatiquement ajustée à la largeur en espace vue d’un pixel à cette profondeur : plus stricte de près et plus permissive au loin.
La partie honnête de ce spike figure dans ses propres commentaires : le SSR ne peut pas refléter ce que la caméra principale n’a jamais échantillonné. Le dessous d’un arbre, tout ce qui se trouve hors champ ou derrière un objet : rien de cela n’existe dans les tampons et ne peut donc apparaître dans la réflexion. C’est cette « perte d’informations du mauvais côté » qu’aucun réglage de qualité de la progression ne peut corriger, et c’est précisément la raison d’être du spike suivant.
Le miroir qui ne peut pas mentir
Ouvrir le spike 52 dans un nouvel onglet ↗ · Voir le code source
Une réflexion planaire rend la scène une seconde fois depuis une caméra reflétée par rapport au plan de l’eau, dans une cible hors écran que le shader de l’eau échantillonne ensuite. C’est la technique de référence, celle qu’utilisent UE5 Water, le WaterMesh de three.js, ABZÛ et Sea of Thieves, car elle dispose de chaque pixel de la scène, y compris de la géométrie que le SSR ne pourra jamais voir. En TSL, c’est presque déconcertant de simplicité : reflector() alloue la caméra auxiliaire reflétée et sa cible de rendu, on ajoute sa cible au maillage pour qu’elle soit mise à jour à chaque image, puis on échantillonne sa couleur. Lorsque les vagues seront ajoutées, la réflexion ondulera grâce à un décalage de distorsion appliqué au nœud UV du réflecteur, exactement comme dans la ligne utilisée par WaterMesh.
Le bug qui mérite d’être consigné ne se trouvait pas dans le miroir, mais dans le filet de sécurité. Une version antérieure mélangeait la sortie du réflecteur avec un ciel procédural utilisé comme solution de repli, pondérée par l’intensité de la couleur réfléchie, selon l’hypothèse qu’une réflexion proche de zéro signifiait que la cible ne contenait rien à cet endroit. Mais une ombre sombre sous la canopée possède elle aussi une faible intensité : la pondération n’atteignait donc jamais sa valeur maximale et ces pixels réellement sombres se retrouvaient mélangés au ciel lumineux. Le symptôme signalé par l’utilisateur était précis : la cible brute du miroir affichait des arbres sombres parfaits en mode débogage, alors que le rendu composé présentait des réflexions délavées. Le diagnostic était que le shader « fait disparaître les couleurs sombres ». La correction a consisté à supprimer ce mécanisme. La cible du réflecteur est fiable après la première image ; aucune solution de repli n’est donc nécessaire. Les deux spikes partagent la même réfraction sous-jacente : ils échantillonnent la scène derrière la surface, reconstruisent la profondeur de chaque pixel sous la ligne d’eau et appliquent une extinction de Beer-Lambert par canal, de sorte que le rouge disparaît en quelques mètres tandis que le bleu persiste. Un masque de ciel empêche l’arrière-plan situé au plan lointain d’être noyé dans la brume. L’effet de Fresnel de Schlick mélange la réfraction lorsque l’on regarde directement vers le fond et la réflexion lorsque l’on regarde à l’horizontale.
À quoi ressemble une solution aboutie
Ouvrir le spike 53 dans un nouvel onglet ↗ · Voir le code source
Le spike 53 sert à comparer le développement en interne à l’adoption d’une solution existante. Il intègre threejs-water-pro tel qu’il est distribué, avec son préréglage tropical, son clipmap océanique par défaut, son suivi de caméra et son ciel de Rayleigh, puis charge le même glTF d’île que celui utilisé dans la démo de la bibliothèque. L’objectif est de mesurer l’écart entre un shader d’eau plane artisanal et un système océanique complet, et cet écart est considérable : celui-ci possède un système de flottabilité qui échantillonne la hauteur des vagues en plusieurs points sous la coque d’un navire, afin qu’il tangue et roule au lieu de simplement monter et descendre, ainsi qu’un générateur de sillage, de l’écume sur le rivage et à la surface, et une passe de masque qui empêche l’eau d’être rendue à l’intérieur de la coque pour éviter que les ondulations ne traversent le pont.
Son intégration a révélé le genre de détail que l’on ne découvre qu’en utilisant une bibliothèque plutôt qu’en lisant son README. Les textures d’écume sont référencées par leur nom de fichier dans le préréglage, mais leur chargement incombe à l’utilisateur de la bibliothèque. Sans elles, le rivage apparaît comme une ligne de flottaison nette plutôt que comme des vagues qui se brisent. L’île est positionnée de manière à ce que sa géométrie sous-marine descende sous le fond de l’océan, permettant au maillage du fond fourni par la bibliothèque de masquer l’anneau extérieur du modèle sans laisser apparaître le bord du plan. C’est le mode d’utilisation prévu par la bibliothèque : fournir un modèle 3D plutôt que générer le terrain de manière synthétique. Enfin, l’anticrénelage est volontairement désactivé dans le moteur de rendu au profit d’une passe FXAA en post-traitement, car le MSAA mélange les fragments de bord avec l’arrière-plan avant l’application de la brume atmosphérique sensible à la profondeur. Cela laisse une fine frange sombre partout où la géométrie rencontre la brume. Résoudre le crénelage après la brume plutôt qu’avant supprime cette frange. Ce spike réduit surtout le risque lié à la décision elle-même : un océan de qualité production constitue un vaste système spécialisé et, lorsque nous en avons besoin, adopter une bibliothèque maintenue est préférable à reconstruire de zéro les sillages, la flottabilité et l’écume. Le shader à miroir planaire du spike 52 reste quant à lui la bonne solution pour les petites étendues d’eau intérieures qu’un créateur place dans son propre monde.
Technologies abordées dans ce chapitre
Eau avec réflexion en espace écran. Un rayon de vue réfléchi progresse dans le tampon de profondeur en espace écran au moyen d’un DDA, avec une interpolation corrigée pour la perspective en 1/z, une limite stricte du nombre d’étapes afin de plafonner le coût par fragment et une passe de raffinement binaire pour éliminer les bandes laissées par une progression plafonnée. Une vérification de la distance entre le point et la ligne, avec une épaisseur adaptée à la profondeur, rejette les fausses intersections. La limite absolue de cette méthode est qu’elle ne peut refléter que la géométrie déjà échantillonnée par la caméra principale : les surfaces hors champ ou vues du mauvais côté n’apparaissent donc jamais. Voir les matériaux de terrain.
Réflexion par miroir planaire. Une caméra auxiliaire reflétée par rapport au plan de l’eau rend la scène dans une cible hors écran que le shader de l’eau échantillonne, produisant des réflexions au pixel près, y compris pour la géométrie invisible au SSR. C’est la technique utilisée par UE5 Water et le WaterMesh de three.js, la distorsion des vagues étant appliquée sous forme de décalage au nœud UV du réflecteur. Une solution de repli vers le ciel, pondérée par l’intensité, effaçait à tort les pixels de réflexion sombres. La cible du réflecteur étant fiable après la première image, la suppression de cette solution de repli a corrigé le problème.
Réfraction teintée par la profondeur selon Beer-Lambert. Les deux shaders échantillonnent la scène derrière la surface, reconstruisent la profondeur de chaque pixel d’arrière-plan sous la ligne d’eau et appliquent une extinction exponentielle par canal — le rouge disparaît en quelques mètres, tandis que le bleu persiste — composée vers une couleur de brume aquatique. Un masque de ciel empêche le plan lointain d’être noyé dans la brume. L’effet de Fresnel de Schlick mélange la réfraction à incidence normale et la réflexion aux angles rasants.
Adoption d’une bibliothèque d’eau prête pour la production. threejs-water-pro fournit un clipmap océanique, un ciel de Rayleigh, une flottabilité multipoint pour le tangage et le roulis des navires, des sillages, de l’écume et une passe de masque de coque. Les détails côté utilisateur comptent : les textures d’écume doivent être chargées explicitement, la géométrie sous-marine de l’île doit descendre sous le fond de l’océan afin que celui-ci masque ses bords, et l’anticrénelage doit être appliqué sous forme de passe FXAA en post-traitement plutôt qu’avec le MSAA, afin d’éviter une frange sombre de brume sur les contours de la géométrie.
Partie 26 sur 29. Précédent : Partie 25 — Un seul squelette, toutes les tenues Suivant : Partie 27 — Une île créée à partir de bruit, un sol qui ressemble à un vrai sol Guide de la série : /fr/blog/2026-02-25-open-world-browser-series-guide