Skip to content

Construire un monde ouvert dans le navigateur, partie 12 : anneaux, brume céleste et ce que nous referions

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 contient les liens vers toutes les parties.

Le spike 24 devait simplement « ajouter des anneaux de clipmap au terrain ». Il s’est transformé en véritable final, touchant à la fois au rendu, aux shaders, à l’infrastructure des modules et à l’intégration visuelle.

La tâche principale liée au terrain consistait à générer des anneaux de clipmap concentriques dans le vertex shader. Chaque anneau est un maillage en grille plane centré sur la caméra, dont les sommets sont déplacés en fonction des échantillons de la heightmap. L’anneau intérieur utilise la pleine résolution. Chaque anneau suivant double l’espacement des sommets et couvre une zone plus vaste. La difficulté réside dans la frontière entre les anneaux : lorsqu’un anneau haute résolution rencontre un anneau basse résolution, les sommets de bord du maillage le plus fin doivent s’aligner sur le milieu de l’arête du maillage plus grossier. Nous avons réalisé un morphing de bord 2:1 en détectant les sommets de frontière — ceux dont la coordonnée de grille est impaire le long du bord de l’anneau — puis en alignant leur hauteur sur le milieu de leurs deux voisins pairs, h=12(hleft+hright). C’est exactement le point où se trouverait l’unique arête de l’anneau grossier ; les bords fin et grossier décrivent donc la même ligne. Cela produit des raccords étanches sans géométrie de transition.

Ouvrir le spike 24 dans un nouvel onglet ↗ · Voir le code source

Puis est venue l’intégration de la brume et du ciel. Nous voulions que le terrain lointain se fonde dans la couleur réelle du ciel, et non dans une constante uniforme. Le shader de brume devait donc connaître la couleur qu’aurait le ciel dans la direction de chaque fragment. Nous avons chargé une texture HDR de skybox équirectangulaire et l’avons échantillonnée dans le fragment shader à partir de la direction de vue allant de la caméra au fragment, convertie en coordonnées UV équirectangulaires grâce au nœud equirectUV de TSL. Le facteur de brume dépendait de la distance, avec positionView.z.negate() pour la profondeur dans l’espace caméra, et était mélangé avec smoothstep entre une distance proche et une distance lointaine.

Le raccordement des modules s’est révélé plus pénible que n’importe quelle partie de la géométrie. Nous sommes passés à Three.js 0.183.1, qui avait restructuré ses fichiers de build. L’import three/tsl devait être résolu vers three.tsl.js, tandis que TSL importait en interne three/webgpu sous forme de spécificateur nu. Les deux correspondances devaient être déclarées explicitement dans l’import map HTML. L’absence de l’une ou de l’autre produisait des erreurs cryptiques comme « does not provide an export » ou « failed to resolve module specifier », sans indiquer quelle correspondance était incorrecte. Une fois les deux ajoutées à l’import map, le graphe de shaders s’est chargé correctement.

Nous avons également rencontré un problème d’orientation du skybox : la texture était affichée à l’envers. La solution consistait à définir flipY = true sur la texture équirectangulaire. Il s’agit de la valeur par défaut de Three.js pour les textures chargées, mais notre code initial la définissait sur false.

La première implémentation de la brume échantillonnait le ciel dans une direction presque constante, ce qui produisait une fine bande aux couleurs de l’horizon au lieu d’un dégradé naturel. La solution a été de calculer, pour chaque pixel, la véritable direction dans l’espace monde allant de la caméra au fragment avec positionWorld.sub(cameraPosition).normalize(), puis de la transmettre à equirectUV pour rechercher la couleur de la brume. Les fragments du terrain se fondent ainsi dans la couleur du ciel qui se trouve réellement derrière eux, ce qui reste correct quel que soit l’angle de la caméra.

Au-delà de toutes ces corrections individuelles, le résultat essentiel est resté intact. Nous disposons désormais d’un système de terrain combinant l’édition volumétrique en champ proche — marching cubes avec raccords Transvoxel —, des chunks de heightmap à moyenne distance et des anneaux de clipmap à longue distance, le tout piloté par une couche de stratégie qui détermine le mode, le LOD et le comportement des transitions.

Si je devais citer les méthodes que je réutiliserais dans le prochain projet, ce seraient celles-ci :

Commencer par des spikes consacrés aux risques avant de développer les fonctionnalités. Le spike 1 a tranché la question « sommes-nous seulement capables d’effectuer le rendu assez vite ? » avant que nous n’investissions dans les pipelines de contenu.

Figer des bases de référence fiables avant les grands sauts d’intégration. Les spikes 13 et 14 nous ont épargné des jours de recherche dichotomique des régressions.

Imposer une couche de stratégie et de l’observabilité avant de se lancer dans des marathons d’optimisation. Le spike 23 a transformé des bugs mystérieux en conditions nommées assorties de règles de déclenchement.

Tester en mouvement, pas avec des captures d’écran. Les sauts de clipmap, le scintillement des raccords et les à-coups de streaming restent tous invisibles dans les images fixes.

Mesurer le coût en temps de frame de chaque fonctionnalité, et non les FPS moyens. Les moyennes masquent les pics que les utilisateurs ressentent réellement.

Et publier les parties chaotiques. Les fausses pistes, les traques de fantômes causés par des buffers obsolètes, les deux jours passés à accuser la logique de transition alors que la plage de dessin était incorrecte. Ce sont les parties dont les gens peuvent vraiment tirer des enseignements.

Vérification externe : les journaux de développement de Vuntra City

Après avoir terminé cette série, nous avons examiné les journaux de développement de @VuntraCity afin de confronter nos hypothèses sur les mondes ouverts à une implémentation externe. Il s’agit d’un projet UE5 natif, et non d’une stack pour navigateur, mais les architectures des systèmes sont suffisamment proches pour rendre la comparaison utile.

Le premier enseignement est que la vitesse de déplacement doit être traitée comme un paramètre de contrôle du streaming, et pas seulement comme un élément de gameplay. Dans Vuntra City, les déplacements à grande vitesse sont volontairement dirigés au-dessus de la plupart des intérieurs, et la portée des détails s’adapte à la vitesse de déplacement afin d’éviter la création et la suppression incessantes d’objets ainsi que les blocages (système de transport, techniques de performance). Cela correspond à l’orientation de notre couche de stratégie : le mode de déplacement doit influencer directement le rayon des chunks, l’activation des intérieurs et la quantité de travail autorisée par frame.

Le deuxième enseignement concerne l’architecture. Leur système de cartes et d’adresses nécessitait de séparer la topologie du monde des objets rendus afin que les requêtes globales puissent s’exécuter sur les régions non chargées (cartes et adresses). C’est la même séparation dont nous avons besoin dans le navigateur pour la recherche dans le monde, le calcul des itinéraires de quêtes, les analyses de modération et l’indexation des points d’intérêt, sans imposer des chemins de données liés au rendu.

Le troisième enseignement est la hiérarchisation de la simulation. Leur conception à un million de PNJ maintient un état global et peu coûteux des emplois du temps, puis ne consacre le budget des comportements complexes qu’aux PNJ proches du joueur (présentation du million de PNJ, analyse approfondie du système). Cela conforte notre propre modèle de simulation centré sur la zone d’intérêt, dans lequel la fidélité en champ proche et le déterminisme en champ lointain constituent des préoccupations distinctes dotées de budgets séparés.

Le quatrième enseignement porte sur la qualité de la conception, et non sur l’échelle brute. Leurs meilleurs moments d’exploration reposent sur des distributions pondérées, de rares valeurs atypiques et des indices de navigation diégétiques plutôt que sur des superpositions d’interface permanentes (notes sur les environnements procéduraux, boucle sans minicarte). Pour nous, cela rappelle que les systèmes techniques doivent être réglés afin de produire une diversité propice à la découverte, et non simplement un débit maximal.

Technologies abordées dans ce chapitre

Géométrie des anneaux de clipmap. Chaque anneau est un maillage en grille plane centré sur la caméra, dont les sommets sont déplacés en fonction des échantillons de la heightmap. L’anneau intérieur utilise la pleine résolution. Chaque anneau suivant double l’espacement des sommets et couvre une zone plus vaste. La difficulté réside dans la frontière : lorsqu’un anneau haute résolution rencontre un anneau basse résolution, les sommets de bord du maillage le plus fin s’alignent sur le milieu de l’arête du maillage plus grossier. Cette technique provient de l’article de Losasso et Hoppe publié à SIGGRAPH en 2004 (PDF) et est détaillée dans le chapitre 2 de GPU Gems 2. Consultez notre guide sur les paysages consacré aux geometry clipmaps.

Morphing de bord 2:1. À la frontière entre deux anneaux de clipmap, l’anneau le plus fin possède des sommets à des positions que l’anneau plus grossier ne partage pas. Les sommets de frontière dont la coordonnée de grille est impaire le long du bord de l’anneau sont détectés, puis leur hauteur est interpolée entre les deux sommets pairs voisins. Cela produit des raccords étanches sans géométrie de transition dédiée. L’interpolation s’exécute dans le vertex shader : morphedHeight = mix(heightLeft, heightRight, 0.5) pour les sommets de frontière, en utilisant le même mécanisme de géomorphing que celui décrit dans notre guide.

Projection de skybox équirectangulaire. Une image 2D unique représentant l’ensemble des directions de la sphère céleste au moyen d’une projection longitude-latitude. L’axe horizontal couvre 0 à 360 degrés et l’axe vertical 0 à 180 degrés. Une direction de vue normalisée d^=(dx,dy,dz) est convertie en coordonnées UV à partir de sa longitude et de sa latitude :

u=12+atan2(dz,dx)2π,v=12arcsin(dy)π

Dans Three.js, définir texture.mapping = EquirectangularReflectionMapping avec SRGBColorSpace permet de l’utiliser comme arrière-plan de la scène. Dans TSL, equirectUV(direction) applique la même conversion et transforme une direction de vue 3D en coordonnées UV 2D permettant d’échantillonner la texture.

Couleur de brume par fragment tirée du ciel. La brume standard fond les fragments vers une couleur constante unique. Dans une scène dotée d’un skybox détaillé, le résultat paraît incorrect, car la couleur du ciel varie selon la direction. La solution consiste à calculer, pour chaque pixel, la direction dans l’espace monde allant de la caméra au fragment (positionWorld.sub(cameraPosition).normalize()), puis à échantillonner le skybox dans cette direction pour obtenir la couleur de la brume. Chaque fragment se fond vers la couleur du ciel qui se trouve réellement derrière lui, ce qui produit un mélange correct quel que soit l’angle de la caméra. Le facteur de brume utilise smoothstep(nearDist, farDist, viewDepth) avec positionView.z.negate() pour la profondeur dans l’espace caméra.

Import maps pour les modules ES. Un mécanisme natif du navigateur (<script type="importmap">) qui associe des spécificateurs de modules nus — comme three/tsl — à des URL réelles. Lorsque Three.js 0.183.1 a restructuré ses fichiers de build, three/tsl devait être résolu vers three.tsl.js, tandis que TSL importait en interne three/webgpu sous forme de spécificateur nu. Les deux correspondances devaient être déclarées explicitement dans l’import map, faute de quoi le navigateur produisait des erreurs comme « does not provide an export » ou « failed to resolve module specifier ».

Pour aller plus loin

Pour une présentation plus approfondie des technologies employées dans cette série, consultez nos guides complémentaires :

Merci d’avoir suivi cette aventure en douze parties.

Partie 1 : Nous avons commencé par essayer de tout casser
Partie 2 : La physique dans un worker et la crainte de la latence des commandes
Partie 3 : Les spikes peu spectaculaires qui nous ont sauvés
Partie 4 : Le streaming avant les terrains sophistiqués
Partie 5 : Budgéter les éléments visuels
Partie 6 : Les clipmaps ont changé la donne
Partie 7 : Les marching cubes et les premières véritables grottes
Partie 8 : Intégrer sans perdre notre base de référence
Partie 9 : Transvoxel a commencé par une ossature
Partie 10 : Le chaos des raccords et le boss final des coins
Partie 11 : Un mode piloté par une stratégie, pas codé en dur


Partie 12 sur 14.
Précédent : Partie 11 - Un mode piloté par une stratégie, pas codé en dur
Suivant : Partie 13 - Sculpture du terrain et mort de la fonction mathématique
Guide de la série : /fr/blog/2026-02-25-open-world-browser-series-guide