Créer un monde ouvert dans le navigateur, partie 19 : l’imposteur qui doit survivre à une forêt
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 18 a donné aux créateurs un pinceau capable de remplir un versant d’arbres. Le problème, c’est le coût de ces arbres lorsqu’ils sont des dizaines de milliers à l’écran. Un arbre lointain n’a pas besoin de 2 000 triangles pour occuper quatre pixels. Cette partie traite du niveau de LOD le plus profond : l’imposteur, un quad plat portant une image de l’arbre, et du chemin parcouru entre « un arbre qui paraît juste » et un million d’arbres sur le GPU.
Un arbre, ce sont deux textures sur un billboard
Ouvrir le spike 38 dans un nouvel onglet ↗ · Voir le code source
Un imposteur pré-rend un objet depuis une grille d’angles de vue dans deux atlas de textures, l’un pour la couleur et l’autre pour les normales en espace monde, puis affiche à l’exécution un unique quad orienté vers la caméra qui échantillonne la tuile correspondant à la vue actuelle. La cuisson se fait en deux passes par tuile : la couleur diffuse avec un matériau non éclairé, afin qu’aucun éclairage ne soit intégré à la texture, et les normales encodées sous la forme normalWorld × 0.5 + 0.5, l’alpha étant repris depuis la source afin que la silhouette corresponde pixel pour pixel. Le matériau d’exécution est un MeshStandardNodeMaterial complet : l’imposteur reçoit donc toujours le soleil et l’IBL de la scène comme n’importe quelle autre surface. Le gain vient de la géométrie : un seul quad au lieu de milliers de triangles, les détails étant stockés dans une texture de 1 Mo.
Le fait d’isoler ce travail dans son propre spike a constitué une leçon en soi. L’imposteur avait commencé comme le LOD le plus profond du système de dispersion du spike 37, et chaque itération sur la cuisson devait être testée à travers toute la chaîne de dispersion, la validité de la cuisson étant entremêlée avec la migration des matrices d’instances et les changements de LOD. Le fait de le séparer pour n’avoir qu’un objet et un quad, côte à côte avec l’original, a réduit le temps d’itération de plusieurs minutes à quelques secondes.
Quand la réponse académique est la mauvaise
La première implémentation utilisait un encodage octaédrique, la méthode classique pour projeter les directions d’une sphère dans un carré. Elle réussissait les tests numériques d’aller-retour, et pourtant l’utilisateur continuait à envoyer des captures où l’imposteur sautait sur une tuile montrant l’arbre légèrement vu du dessus plutôt que de face. Six séries de corrections ont suivi — une uniforme de direction de vue par quad, une cuisson au format carré, des matériaux de débogage et un billboarding statique — et chacune était réellement nécessaire, mais aucune ne corrigeait le véritable bug. La solution n’est apparue qu’avec cette consigne : « tout repenser depuis zéro, KISS, sans rustines ».
La réécriture a abandonné le repliement octaédrique au profit d’un simple azimut par élévation : az = atan2(dir.x, dir.z), el = asin(dir.y), uv = (az/2π, el/π + ½). C’est tout l’encodage : aucune normalisation L1, aucun cas limite lié au signe de zéro. Cette méthode n’est pas meilleure ici parce qu’elle serait plus précise — elle échantillonne la sphère de façon moins uniforme — mais parce que le sélecteur de cellules sur le CPU et le shader sur le GPU utilisent les mêmes primitives. Ils ne peuvent donc pas diverger sur une direction située à la limite, contrairement à ce qui se produisait discrètement avec la paire octaédrique. Dans la surimpression, l’atlas se lit comme une planche-contact : la colonne indique l’angle autour de l’objet et la ligne son élévation, ce qui est immédiatement compréhensible.
Même après cela, le reproche de l’aspect « légèrement vu du dessus » persistait, et sa cause était un choix de quantification, pas l’encodage. Avec une grille 4×4, les centres des lignes se trouvent à ±22,5° et ±67,5° : il n’existe donc aucune ligne à exactement 0° d’élévation. Un observateur regardant à l’horizontale — le cas de loin le plus fréquent — tombe toujours sur une ligne cuite avec une inclinaison. La solution consiste à utiliser un N impair : dans une grille 5×5, les centres des lignes se trouvent à 0°, ±36° et ±72°, de sorte que l’observateur horizontal obtient une tuile cuite exactement à l’horizontale. La même famille d’erreurs de « décalage d’une demi-cellule » réapparaît dans le travail sur la parallaxe de la partie suivante, avec le même remède : mon point d’échantillonnage discret se trouve-t-il réellement là où je pense pour l’entrée canonique ?
Deux autres éléments ont compté. Le billboard doit être statique par morceaux, et non continuellement orienté vers la caméra. L’imposteur est une image plate prise depuis une direction de cuisson précise : le plan de l’image du quad à l’exécution doit donc correspondre au plan de cette caméra de cuisson. Cela signifie qu’il conserve son orientation sur tout l’arc où la même cellule reste sélectionnée, puis saute à la limite. Quant au budget, il doit privilégier les directions dans lesquelles les joueurs regardent réellement : une passe ultérieure a entièrement supprimé les lignes inclinées vues du dessus au profit de 24 emplacements disposés sur un anneau horizontal, espacés de 15°, plus une tuile vue directement du dessus, car les arbres sont presque toujours observés à hauteur des yeux.
Le saut, et comment le mélange l’a effacé
Ouvrir le spike 42 dans un nouvel onglet ↗ · Voir le code source
Le billboard statique par morceaux est imperceptible à distance, mais produit un saut de près, ce qui ne pose aucun problème jusqu’à ce qu’on tourne autour. Le spike 42 a placé quatre variantes côte à côte — l’objet original, la version de référence azimut/élévation 5×5 et deux grilles hémi-octaédriques — afin d’isoler le scintillement et de le supprimer. Deux artefacts sont à l’origine du saut. Le changement de cellule se produit parce que le fragment shader quantifie la direction de vue dans l’une des 25 cellules : franchir une limite remplace donc la tuile échantillonnée et réoriente le quad au cours de la même image. La dégénérescence au pôle correspond à la vue du dessus, où tous les azimuts se réduisent à un même point et où la jonction entre l’anneau et la tuile supérieure constitue la pire transition de l’atlas.
La projection hémi-octaédrique corrige ces deux problèmes. Elle projette continûment l’hémisphère supérieur sur le carré unité : des directions 3D adjacentes aboutissent ainsi sur des UV adjacents, sans singularité au pôle ni besoin d’une tuile spéciale vue du dessus. Le scintillement est supprimé grâce au mélange bilinéaire des cellules : au lieu de sauter vers la tuile la plus proche, on trouve le groupe de 2×2 tuiles encadrant la direction encodée et on mélange les quatre, pour un total de 8 lectures de texture — 4 diffuses et 4 normales. Les vues adjacentes se fondent désormais l’une dans l’autre au lieu de produire un saut. Le mélange de deux vecteurs unitaires n’étant pas lui-même de longueur unitaire, la normale obtenue est renormalisée, ce qui se comporte comme une slerp pour les petits angles séparant les tuiles voisines. Le coût est réel — un atlas 12×12 pèse environ 9 Mo contre 1,6 Mo pour la version azimut/élévation, et la cuisson prend à peu près cinq fois plus de temps avec 288 passes vers des cibles de rendu — mais la cuisson n’a lieu qu’une seule fois au chargement, et le mélange produit un résultat sans saut. C’est ce qui rend les imposteurs utilisables lorsque la caméra se déplace réellement.
Un million d’arbres, une seule copie de la position de la caméra par image
Ouvrir le spike 41 dans un nouvel onglet ↗ · Voir le code source
Le code d’exécution du spike 38 effectue un lookAt sur le CPU pour chaque quad à chaque image, ce qui convient pour un arbre mais devient fatal pour une forêt. Avec un million d’arbres, les mises à jour des matrices à chaque image et le transfert du tampon d’instances domineraient tout le reste. Le spike 41 transfère l’intégralité de la chaîne de traitement par image sur le GPU. Le centre, le lacet et l’échelle de chaque instance sont envoyés une seule fois à la construction sous forme d’attributs instanciés, puis ne changent plus. Le vertex shader construit la base du billboard à partir de la direction de vue en espace monde camPos − center et déploie un quad unité partagé dans l’espace monde. Le fragment shader effectue l’encodage hémi-octaédrique et le mélange bilinéaire pour chaque pixel. Pour toute la forêt, l’unique travail du CPU à chaque image est un seul Vector3.copy mettant à jour l’uniforme de position de la caméra, un coût qui ne dépend absolument pas du nombre d’arbres.
Un aspect élégant des mathématiques est que le lacet propre à chaque instance s’annule lors du décodage des normales. La cuisson stocke les normales dans le repère de la caméra de cuisson et, puisqu’une rotation de lacet autour de l’axe vertical du monde conserve +Y et que le produit vectoriel est équivariant par rotation, la base d’exécution construite avec une référence verticale en espace monde correspond déjà à la base de cuisson après rotation. Le shader décode donc directement les normales à travers les varyings de la base d’exécution, sans jamais utiliser le lacet propre à l’instance. Le placement utilise une grille avec jitter plutôt qu’une dispersion purement aléatoire : la zone est divisée en cellules, puis un arbre est placé dans chacune d’elles au centre avec un décalage limité. Cela garantit un espacement minimal — aucun arbre ne se superpose à un autre — tout en conservant l’apparence d’une forêt naturelle. Un détail facile à oublier concerne la sphère englobante. Le modèle géométrique n’est qu’un quad unité : three.js éliminerait donc toute la forêt par culling de frustum dès que la caméra ne regarderait plus vers l’origine. Définir explicitement une sphère englobante couvrant toute la zone, plus la marge d’un quad, empêche les arbres situés aux angles d’être tronqués lorsque la caméra les observe sous un angle rasant.
Technologies présentées dans ce chapitre
Atlas d’imposteurs octaédriques et azimut-élévation. Un imposteur cuit un objet depuis une grille de directions de vue dans un atlas diffus et un atlas de normales en espace monde, puis affiche un unique billboard échantillonnant la tuile correspondante, remplaçant ainsi des milliers de triangles par deux textures. La projection octaédrique classique offre une couverture uniforme de la sphère, mais elle est sujette aux divergences CPU/GPU aux limites du repliement. Une simple grille azimut par élévation échantillonne la sphère de manière moins uniforme, mais garantit par construction que le sélecteur de cellules et le shader donnent le même résultat. Utilisez un N impair afin qu’une ligne tombe exactement à 0° d’élévation, et consacrez le budget de tuiles à l’anneau horizontal puisque les objets sont principalement observés à hauteur des yeux.
Orientation du billboard statique par morceaux. Un imposteur est une image prise depuis une direction de cuisson précise : le plan de l’image du quad à l’exécution doit donc correspondre au plan de la caméra de cuisson, plutôt que de faire continuellement face à la caméra d’exécution. Le quad conserve son orientation sur tout l’arc où une même cellule reste sélectionnée, puis saute à la limite. Ce saut est invisible à la distance d’utilisation d’un imposteur et ne devient perceptible que de près, là où les imposteurs ne sont pas utilisés.
Atlas hémi-octaédrique avec mélange bilinéaire des cellules. La projection continue de l’hémisphère supérieur sur le carré unité supprime la singularité au pôle et la tuile spéciale vue du dessus. Le saut disparaît en échantillonnant le groupe de 2×2 tuiles encadrant la direction de vue encodée et en mélangeant bilinéairement les quatre tuiles — soit 8 lectures — afin que les vues adjacentes se fondent l’une dans l’autre. Les normales mélangées sont renormalisées, ce qui approxime une slerp sur le faible angle séparant les tuiles. Le prix à payer est un atlas plus grand — environ 9 Mo en 12×12 — et une cuisson unique plus longue, en échange d’un éclairage sans saut lorsque la caméra se déplace.
Imposteurs instanciés pilotés par le GPU. Le centre, le lacet et l’échelle propres à chaque instance sont envoyés une seule fois sous forme d’attributs instanciés. Le vertex shader construit la base du billboard et déploie un quad unité partagé, tandis que le fragment shader effectue l’encodage et le mélange pour chaque pixel. Pour toute la forêt, le coût CPU par image se limite à une seule copie de l’uniforme de position de la caméra, indépendamment du nombre d’instances. Le lacet propre à chaque instance s’annule lors du décodage des normales, car le lacet autour de l’axe vertical du monde est préservé par la construction de la base au moyen du produit vectoriel. Une sphère englobante explicite couvrant toute la forêt empêche three.js d’éliminer par culling de frustum l’ensemble du maillage instancié lorsque la caméra ne regarde plus vers l’origine du quad unité servant de modèle. Voir LOD piloté par le GPU.
Partie 19 sur 29. Précédente : Partie 18 — Un pinceau de dispersion qui semble guidé par l’IA Suivante : Partie 20 — Simuler la profondeur sur un plan plat Guide de la série : /blog/2026-02-25-open-world-browser-series-guide