Construire un monde ouvert dans le navigateur, partie 20 : simuler la profondeur sur un plan plat
Par Oleg Sidorkin, directeur technique 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 19 utilisait un quadrilatère plat pour simuler un arbre entier à distance. Cette partie utilise un quadrilatère plat pour simuler la profondeur de près : le parallax occlusion mapping, l'astuce qui donne à une route pavée l'apparence de joints creusés sur 5 cm sans ajouter le moindre sommet. L'objectif était de l'intégrer à la pile de production (Three.js r184, WebGPU, TSL), afin que les matériaux détaillés du terrain puissent profiter de cette illusion de profondeur là où elle compte et ne payer ailleurs que le coût d'une texture plate.
Trois façons de simuler la profondeur, côte à côte
Ouvrir le spike 39 dans un nouvel onglet ↗ · Voir le code source
Le spike place côte à côte trois plans plats de 5 × 5 m, tous fondés sur la même structure de matériau et ne différant que par les UV transmis aux échantillonneurs. La version plate échantillonne directement la texture et sert de référence. La parallaxe à échantillon unique décale une fois les UV dans la direction de vue selon la hauteur à cet endroit ; elle est peu coûteuse et acceptable avec une faible amplitude, mais flotte aux angles rasants. Le POM effectue un ray marching dans l'espace tangent : il avance le long du rayon de vue, trouve la première couche où le rayon passe sous le champ de hauteur, puis affine l'intersection. L'espace tangent reste simple, car chaque plan de test est aligné sur les axes : quelques inversions de signe suffisent donc à exprimer la direction de vue dans l'espace tangent, au lieu d'une matrice TBN complète par sommet. Le jeu de textures est récupéré en direct depuis l'API de fichiers de Polyhaven, par le même chemin que celui utilisé par la recherche de modèles de la partie 17.
Deux obstacles WebGPU et un ray marching sans branchement
La boucle POM classique interrompt la recherche à la première intersection. Avec r184, cela ne fonctionne pas, pour deux raisons distinctes. If(...).and(...) se compilait sans erreur, mais produisait du WGSL dans lequel le corps de la boucle ne s'exécutait jamais ; l'affinement après la boucle travaillait donc sur des données invalides et le plan apparaissait presque blanc. De plus, Break() sous forme de nœud autonome n'était pas encore disponible dans la version r184. Même avec un If fonctionnel, il était donc impossible d'exprimer « s'arrêter à la première intersection ». Ces deux problèmes renvoient à des défauts connus de three.js concernant la sur-optimisation du contrôle de flux TSL à travers les limites de If et de Loop dans cette série de versions.
La réécriture est dépourvue de branchements. Chaque itération échantillonne systématiquement la texture, ce qui maintient l'accès à celle-ci dans un flux de contrôle uniforme, comme l'exige la spécification WGSL, puis intègre le nouvel état à l'aide d'un indicateur done stocké sous forme de nombre flottant. Une fois done passé à 1, les appels à mix de chaque itération reviennent à « conserver l'état inchangé », l'équivalent sans branchement d'un break. L'indicateur done est construit avec un utilitaire step implémenté sous la forme 0.5 + 0.5 × sign(x + ε), car la conversion des booléens en nombres flottants s'est montrée irrégulière dans la série r18x, tandis que sign() est fiable partout. En contrepartie, chaque fragment exécute les 64 itérations, quel que soit l'endroit où l'intersection se produit réellement. Mais c'est le bon compromis à l'échelle du fragment : le moteur limite de toute façon l'exécution au nombre maximal d'étapes, et un vrai GPU spéculerait également au-delà d'un « véritable » arrêt. Un repli propre est essentiel ici : un dernier mix(baseUV, refined, done) garantit qu'à amplitude nulle, à l'extrémité lointaine du fondu selon la distance, aucun fragment ne rencontre la surface, done reste à 0 et le matériau POM est strictement identique bit à bit à la version plate. C'est précisément l'intérêt de cette technique de LOD selon la distance : revenir au coût d'une texture plate là où l'effet est de toute façon inférieur à un pixel.
Le bug venait d'un manque de rigueur, pas des mathématiques
La version sans branchement fonctionnait, mais son apparence restait déformée : des artefacts horizontaux étirés avec une amplitude modérée, et un résultat subtilement incorrect et peu net à faible amplitude. La correction est venue d'une consigne d'une ligne : aller lire la référence canonique. Le tutoriel de LlamAcademy qui a inspiré ce travail n'est qu'un nœud Unity ShaderGraph ; la véritable implémentation se trouve donc dans le fichier PerPixelDisplacement.hlsl d'Unity. Sa lecture ligne par ligne a révélé trois différences sémantiques que j'avais introduites sans m'en rendre compte : un décalage d'une unité dans la hauteur de référence du rayon (Unity effectue une première avancée avant la boucle ; mon référentiel était donc déphasé d'une étape entière, ce qui plaçait l'intersection dans la mauvaise couche environ une fois sur deux), une convention de signe pour le décalage maximal dont dépend l'étape d'affinement, et un choix de suivi entre décalage cumulé et UV cumulés qui compliquait mes calculs d'affinement et embrouillait le signe.
La cause profonde n'était pas une erreur isolée, mais le mélange de deux références. Je m'étais appuyé sur le tutoriel POM de LearnOpenGL, qui utilise des conventions de signe semblables mais différentes ainsi qu'une autre formule d'affinement. J'avais ainsi abouti à un hybride dont les deux tiers des calculs correspondaient à une source et le tiers restant à l'autre. La réécriture est un portage presque littéral du HLSL d'Unity vers TSL : mêmes noms de variables, même avancée initiale, même affinement, avec en plus l'indicateur done sans branchement. La leçon mérite d'être retenue : lorsque vous portez depuis une autre pile un shader dont le bon fonctionnement est établi, commencez par le transposer ligne par ligne avec les mêmes noms, puis adaptez-le au style local. Ne le redérivez pas à partir d'une deuxième référence en plein portage.
Un plan de référence qui ne peut pas mentir
Il manquait au comparatif côte à côte l'élément le plus évident : un plan en géométrie réelle. Sans lui, l'affirmation « le POM rend plutôt bien » est irréfutable. Plutôt bien par rapport à quoi ? Le spike a donc ajouté un quatrième plan, dont les positions réelles des sommets sont déplacées à partir de la même heightmap. WebGPU ne propose aucune tessellation matérielle — elle ne figure tout simplement pas dans la spécification, notamment pour préserver la compatibilité avec Metal. Le substitut est donc un plan très densément subdivisé (256 × 256 segments, soit 131 072 triangles), avec un déplacement des sommets dans l'étape de vertex shader. La même uniforme d'amplitude pilote à la fois le POM et le plan géométrique, de sorte que leurs effets s'estompent ensemble et que la comparaison reste équitable à toutes les distances.
Avec la vérité terrain affichée à l'écran, les observations qualitatives sont devenues mesurables. Sur une orbite inclinée à 16° vers le sol, le POM et le plan tessellé produisent le même ombrage interne. Aux angles rasants, ils divergent exactement là où ils le doivent : le POM reste contraint par le bord rectangulaire parfaitement droit de la géométrie, tandis que le vrai maillage révèle à l'horizon le profil irrégulier de véritables pics et creux captant la lumière. Le « flottement » du POM sur les bords est donc désormais manifestement inhérent à l'algorithme, et non causé par la texture ou l'éclairage. Les deux profils de coût apparaissent également clairement : le POM est limité par les fragments — son coût dépend du nombre de pixels couverts — tandis que le plan tessellé est limité par les sommets — son coût dépend de la densité du maillage, quelle que soit sa couverture à l'écran. Pour un morceau de terrain, qui paie déjà le coût en sommets d'un plan piloté par une heightmap, le POM est la bonne solution pour les détails plus fins que le maillage.
Le plan de référence a également révélé un bug d'expérience utilisateur plus subtil. L'utilisateur avait l'impression que la surface s'enfonçait à mesure que l'amplitude augmentait. Cela venait de la convention d'Unity, qui considère le plan géométrique comme le sommet du champ de hauteur : les pics restent donc alignés avec lui tandis que tout le reste est déplacé vers le bas par parallaxe, ce qui abaisse la surface moyenne sous le plan de référence d'une valeur de (1 − mean_h) × amplitude. La correction recentre la convention afin que h = 0.5 corresponde au plan : les pics se soulèvent vers la caméra et les creux s'enfoncent. L'algorithme s'exécute exactement comme le prescrit Unity ; le spike se contente de post-traiter le résultat avec un demi-décalage afin que l'« amplitude » corresponde à ce qu'une personne s'attend à voir en manipulant un curseur.
Le plan de référence a permis de trancher un dernier point. Un curseur « Étapes » semblait ne rien faire, donnant l'impression d'un bug de raccordement alors que ce n'en était pas un. L'affinement par sécante en trois itérations, effectué après la recherche linéaire, est si efficace — l'article de Tatarchuk de 2006 sur le POM indique qu'une recherche en 4 étapes suivie de 3 étapes de sécante est visuellement indiscernable d'une recherche en 64 étapes — que, sur une heightmap lisse, tous les nombres d'étapes de 4 à 64 convergent vers les mêmes UV sous-texel. La solution a été d'ajouter une option d'activation plutôt que de refaire le raccordement : lorsque la sécante est désactivée, le curseur d'étapes devient le seul réglage de la précision de l'intersection. Descendre à 4 fait donc apparaître des paliers visibles sur les pavés, tandis que remonter à 64 les lisse de nouveau. Cette option repose sur une uniforme 0/1 qui utilise mix pour transformer chaque mise à jour de l'état de la sécante en opération neutre lorsqu'elle est désactivée. Son basculement ne reconstruit donc jamais le matériau et ne provoque aucune saccade.
Technologies abordées dans ce chapitre
Parallax occlusion mapping en TSL. Le POM fait progresser par ray marching la direction de vue à travers un champ de hauteur dans l'espace tangent, trouve la première couche où le rayon passe sous la surface, puis affine l'intersection. Il produit ainsi la profondeur de joints en retrait sur un quadrilatère plat, sans géométrie supplémentaire. Un dernier mix(baseUV, refined, done) rend le matériau strictement identique bit à bit à la version plate lorsqu'aucun fragment ne rencontre la surface. C'est ce qui permet à l'atténuation de l'amplitude par LOD selon la distance de ramener le coût à celui d'une texture plate au loin. Voir les matériaux de terrain.
Boucles sans branchement pour le contrôle de flux WebGPU. Avec Three.js r184, un If(...).and(...) TSL peut produire du WGSL dont le corps de boucle ne s'exécute jamais, et la forme autonome de Break() n'est pas disponible. La solution portable consiste à échantillonner la texture sans condition à chaque itération — ce qui maintient l'accès à la texture dans un flux de contrôle uniforme conformément à la spécification WGSL — et à utiliser un indicateur done stocké sous forme de nombre flottant, qui transforme avec mix chaque mise à jour d'état en opération neutre une fois activé. Un utilitaire step construit à partir de sign(x + ε) évite les conversions peu fiables des booléens en nombres flottants. Le coût reste égal au nombre maximal d'itérations, quel que soit le point de sortie anticipée : c'est le bon compromis à l'échelle du fragment.
Portage littéral des shaders. Le portage depuis un autre moteur d'un shader dont le bon fonctionnement est établi doit d'abord reprendre l'original ligne par ligne et conserver ses noms de variables, avant d'être adapté au style local. Le mélange de deux références — le fichier PerPixelDisplacement.hlsl d'Unity et le tutoriel LearnOpenGL — a produit un hybride avec un décalage d'une unité dans la hauteur de référence du rayon, un signe de décalage inversé et une formule d'affinement dont la limitation masquait les poids hors plage sous forme de discontinuités spatiales. Il faut une seule vérité terrain canonique, pas une redérivation.
Référence en géométrie réelle déplacée par sommets. En l'absence de tessellation matérielle dans WebGPU, un plan densément subdivisé — 256² segments — et déplacé dans l'étape de vertex shader tient lieu de géométrie réelle pour valider une simulation exécutée à l'étape de fragment shader. Piloter les deux avec la même uniforme d'amplitude garantit une comparaison honnête à toutes les distances. Le POM est limité par les fragments — son coût dépend du nombre de pixels couverts — tandis que le plan géométrique est limité par les sommets — son coût dépend de la densité du maillage. Ils divergent donc précisément sur les silhouettes, ce qui prouve que le flottement des bords du POM est inhérent à l'algorithme et non un artefact.
Partie 20 sur 29. Précédent : Partie 19 - L'imposteur qui doit survivre à une forêt Suivant : Partie 21 - Un moteur de rendu plus rapide qui ne l'était pas Guide de la série : /fr/blog/2026-02-25-open-world-browser-series-guide