Construire un monde ouvert dans le navigateur, partie 13 : sculpture du terrain et mort de la fonction mathématique
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.
Pendant douze parties, nous avons construit un moteur de terrain que l’on pouvait regarder. Survoler. Admirer sans voir les raccords se fissurer. Cette fois, nous voulions pouvoir le toucher.
L’objectif semblait simple : permettre à un joueur de sculpter le terrain avec une brosse, en temps réel, sans casser aucun des systèmes que nous avions mis 24 spikes à construire. Il nous a fallu trois tentatives, deux bugs qui ressemblaient à des erreurs de rendu mais qui provenaient en réalité du modèle de données, et une remise en question fondamentale du fonctionnement des données de terrain.
Trois faux départs
Le spike 25 était censé être facile. La base de code de production disposait déjà d’un raycaster capable de détecter les intersections avec les maillages du terrain. L’outil de placement l’utilise pour déposer des objets. Un outil de sculpture à la brosse suit le même principe, sauf qu’il modifie les valeurs de la heightmap au lieu d’instancier un prefab. Simple.
Première tentative : je l’ai intégré directement au code TypeScript de production dans world/client/. Nouveau fichier terrain-brush.ts, modifications de chunk.ts, changements de protocole, mises à jour de composants Vue. En moins d’une heure, j’avais une brosse qui fonctionnait plus ou moins, mais les frontières entre chunks présentaient des discontinuités visibles dans les normales. Impossible de savoir si le bug venait de mon code de brosse, du raccordement existant entre les chunks ou d’une interaction avec la boucle de rendu complète. C’est exactement le type de situation que la méthodologie des spikes vise à éviter. J’avais ignoré la règle et je l’ai immédiatement payé. J’ai tout annulé.
Deuxième tentative : un spike autonome, mais j’ai choisi Three.js 0.170.0 et WebGL. Le code de production utilise WebGL, donc ce choix semblait naturel. Mais les spikes 13 à 24 étaient tous passés à WebGPU. Construire un spike de brosse avec WebGL aurait prouvé que cela fonctionnait avec l’ancien moteur de rendu, et non avec celui vers lequel nous migrons. Mauvaise direction. J’ai recommencé.
Troisième tentative : WebGPU, WebGPURenderer, shader de calcul pour générer les sommets, avec la même stack que le spike 22. Cette fois, l’architecture était la bonne. Cinq opérations de brosse fonctionnaient : élever, abaisser, lisser, aplanir, ajouter du bruit. P95 du cycle de brosse inférieur à 4 ms sur M1.
Et le bug de raccord était toujours là.
Ouvrir le spike 25 dans un nouvel onglet ↗ · Voir le code source
Le bug de raccord qui refusait de mourir
La solution standard pour calculer les normales entre plusieurs chunks consiste à faire chevaucher leurs bordures : chaque chunk stocke un anneau de données supplémentaire provenant de ses voisins, afin que le calcul des normales à la frontière puisse échantillonner les deux côtés. Je l’ai mise en œuvre. J’ai copié les données des bords voisins dans un buffer étendu. Les raccords se fissuraient toujours.
J’ai examiné les calculs. À la frontière entre le chunk A (cx=-1) et le chunk B (cx=0), les deux chunks doivent calculer la même normale au sommet partagé. Le shader du chunk A échantillonnait mix(own_col31, own_col32, 0.85). Celui du chunk B échantillonnait mix(neighbor_edge, own_col0, 0.85). Il s’agit de deux chemins d’interpolation bilinéaire différents à travers des données différentes. Même avec des données de bordure correctes, les deux chunks calculent des normales différentes pour le même point.
C’est à ce moment-là que j’ai compris que la copie des bordures n’était pas le véritable bug. Le véritable bug était le modèle de données.
Tous les spikes 1 à 24 utilisaient une fonction mathématique procédurale appelée height_at(). On lui fournit des coordonnées dans le monde, elle renvoie une hauteur. Propre, globale, sans état. La brosse ne pouvait pas modifier une fonction mathématique, j’avais donc ajouté par-dessus un buffer displacement. Le terrain était désormais height_at(x,z) + displacement[i]. Le shader GPU embarquait 30 lignes de fonctions de bruit pour le terrain de base, auxquelles s’ajoutait le code d’interpolation bilinéaire pour la couche de déplacement. Pour déterminer quelle valeur de déplacement produirait la hauteur cible, la brosse d’aplanissement devait soustraire height_at(). Deux systèmes superposés, calculant des choses différentes avec des stratégies d’échantillonnage différentes.
Ce n’est pas ainsi que fonctionne un vrai jeu. En production, un terrain créé par un artiste repose sur des données échantillonnées stockées dans des buffers. La fonction procédurale était une approximation pratique héritée des premiers spikes. Elle avait rempli son rôle. Désormais, elle provoquait activement des bugs.
Je l’ai supprimée.
Chaque chunk possède désormais une heightmap sous la forme d’un Float32Array contenant les véritables valeurs de hauteur. À sa création, elle est remplie par du bruit procédural. Ensuite, la fonction de bruit n’est plus jamais appelée. La brosse modifie directement les hauteurs stockées. Le shader GPU lit un seul buffer à l’aide d’une seule fonction : hm_at(i,j). Les normales utilisent des différences centrales alignées sur la grille à partir des mêmes données. Plus d’ambiguïté liée à l’interpolation bilinéaire. Plus d’incohérence entre deux systèmes. Le shader est passé de 90 à 40 lignes.
Les raccords se sont corrigés d’eux-mêmes. Au niveau d’un bord partagé, les deux chunks lisent désormais les mêmes valeurs de hauteur discrètes dans leurs buffers respectifs, avec les bons points intérieurs du voisin dans la bordure de chevauchement. Mêmes données en entrée, mêmes normales en sortie.
Ce n’était pas une leçon sur les brosses. C’était une leçon d’architecture des données révélée par la brosse.
Le maillage qui explose
Le spike 26 était l’équivalent volumétrique. Modifier à la brosse un volume SDF de 64 au cube, puis recréer le maillage avec marching cubes. La même question que pour le spike 25, mais en 3D.
La première fois que je l’ai exécuté, le maillage a explosé. De longues pointes partaient dans toutes les directions, comme un oursin qui passe une très mauvaise journée.
Ouvrir le spike 26 dans un nouvel onglet ↗ · Voir le code source
La table de cas MC que j’avais générée contenait 3840 entrées au lieu de 4096. La table complète fait
se situe dans
La correction était d’une simplicité déconcertante : copier, octet par octet, la table validée du spike 12. Leçon retenue. Ne jamais régénérer une table de correspondance lorsqu’il en existe déjà une version éprouvée.
Le deuxième bug était plus subtil. La brosse de lissage était censée adoucir les formes du terrain. Au lieu de cela, elle créait des plis très marqués. Le problème : je ramenais chaque valeur SDF vers zéro, c’est-à-dire vers l’isosurface. Intuitivement, cela semble devoir lisser les choses, mais cette opération écrase le champ de distance. Les voxels situés au-dessus et au-dessous de la surface convergent tous vers zéro, aplatissant tout ce qui se trouve dans le rayon de la brosse. À la frontière, les voxels lissés rencontrent les voxels non lissés avec une rupture nette. La brosse de « lissage » était en réalité une génératrice de plis.
La solution était un véritable lissage laplacien. Au lieu de ramener chaque valeur vers zéro, il faut la rapprocher de la moyenne de ses 6 voisins directs :
Le terme entre parenthèses est un laplacien discret, et
Tout à la fois
Le spike 27 constituait la validation de l’intégration. Il fallait prendre le pipeline complet du spike 24 — patches de heightmap, chunks MC, raccords Transvoxel et LOD avec géomorphing — et le combiner avec le modèle de données échantillonnées du spike 25 et les deux types de brosses.
Ouvrir le spike 27 dans un nouvel onglet ↗ · Voir le code source
La première chose que j’ai faite a été d’arracher height_at() de tous les shaders. Les trois shaders de calcul — remplissage SDF, patch de heightmap et raccord Transvoxel — se lient désormais au même buffer GPU de heightmap 129x129 et utilisent la même fonction d’interpolation bilinéaire hm_sample() via un préambule WGSL partagé. Une seule source de données, plusieurs consommateurs. Les fonctions de bruit procédural présentes dans chaque shader depuis le spike 1 avaient disparu.
C’est alors que les problèmes intéressants ont commencé.
Lorsqu’une brosse SDF verrouille un chunk en mode MC, le raccord Transvoxel entre ce chunk et son voisin sous forme de heightmap doit échantillonner le volume SDF, et non la heightmap. J’ai étendu le shader de raccord avec des liaisons de buffers de stockage supplémentaires et des indicateurs MC propres à chaque chunk. Quatre combinaisons de frontières à gérer : HM-HM, HM-MC, MC-HM, MC-MC.
Le LOD constituait une autre énigme. Dans les spikes précédents, passer un chunk MC à un LOD inférieur impliquait de remplir à nouveau le SDF avec une résolution plus grossière. J’ai remplacé cette méthode par un échantillonnage fondé sur un pas : les données SDF restent en pleine résolution, soit 65 points de grille. Le shader MC calcule un pas à partir du rapport entre la taille de la grille et le nombre de cellules. Au LOD0, le pas vaut 1. Au LOD1, il vaut 2, ce qui revient à échantillonner un voxel sur deux. Les chunks peuvent changer librement de LOD sans modifier leurs données SDF.
La correction la plus satisfaisante concernait la création dynamique de chunks verticaux. Sculptez vers le haut au-delà du sommet d’un chunk et un nouveau chunk exclusivement MC apparaît au-dessus, avec un SDF initialisé à partir de la face limite du chunk inférieur. Sculptez vers le bas et la même chose se produit. Le monde s’agrandit pour accueillir les modifications.
Le dernier piège était la brosse de heightmap, qui ne faisait silencieusement rien sur les chunks verrouillés en mode MC. La brosse HM modifie heightmapCPU, puis la téléverse à nouveau. Les chunks MC ne lisent plus la heightmap, car leur SDF a été rempli à partir de celle-ci avant de diverger. J’ai ajouté syncHeightmapToSdf() : après toute modification de la heightmap, les colonnes SDF des chunks MC situés dans le rayon de la brosse sont recalculées et les nouvelles valeurs sont téléversées. Les deux types de brosses fonctionnent désormais sur les deux types de chunks.
Ce que nous avons réellement appris
Les spikes consacrés aux brosses étaient censés répondre à une question de performances : la sculpture peut-elle respecter le budget par frame ? Oui. C’était la partie facile.
La partie difficile a été de découvrir qu’après 24 spikes utilisant height_at() comme source de vérité du terrain, nous avions créé une dépendance invisible qui s’est brisée dès que nous avons tenté de modifier quoi que ce soit. La fonction procédurale était propre, globale et sans état — jusqu’au moment où elle a cessé d’être le terrain.
Voici les règles que nous avons notées et que nous n’oublierons pas :
- La hauteur du terrain provient de données échantillonnées. Les chunks possèdent leurs propres buffers.
- La génération procédurale remplit les données initiales. Elle n’est pas la source de vérité à l’exécution.
- La brosse modifie directement les données des chunks. Aucune couche de déplacement.
- Les normales sont calculées à partir des mêmes données au moyen de différences centrales alignées sur la grille.
- Le chevauchement des bordures — 1 cellule provenant de l’intérieur du voisin — gère les normales entre chunks.
- Ne jamais régénérer une table de correspondance lorsqu’il en existe déjà une version éprouvée.
Dans la partie 14, nous cessons de sculpter de la géométrie de débogage pour commencer à lui donner l’apparence et l’atmosphère d’un véritable lieu.
Technologies abordées dans ce chapitre
Architecture de heightmap échantillonnée. Le terrain est stocké sous forme de données appartenant à chaque chunk, au lieu d’être évalué à partir d’une fonction procédurale à l’exécution. Chaque chunk contient un Float32Array de valeurs de hauteur réelles. Le bruit procédural remplit les données initiales au moment de la création, puis la fonction n’est plus jamais appelée. Cette approche élimine l’incohérence entre le terrain fondé sur une fonction mathématique et les couches de modification, simplifie les opérations de brosse — qui modifient directement les valeurs stockées — et rend le shader GPU extrêmement simple : lecture du buffer et calcul des normales au moyen de différences centrales alignées sur la grille. Pour un monde ouvert avec streaming, l’approche standard repose sur des données propres à chaque chunk avec une bordure de chevauchement d’une cellule provenant des voisins. Consultez notre guide de génération de paysages.
Opérations de brosse SDF. Modification d’un champ de distance signé pour sculpter le terrain. L’ajout — gonflement — utilise une atténuation smooth-step autour d’une sphère. La soustraction — creusement — utilise la même forme avec le signe opposé. Le lissage utilise une moyenne laplacienne : lire les 6 voisins directs, calculer leur moyenne et rapprocher la valeur de cette moyenne. L’approche naïve consistant à ramener les valeurs vers zéro écrase le champ de distance et crée des arêtes vives. Le lissage laplacien préserve le gradient du champ tout en adoucissant les formes. Consultez la section Représentation du terrain par SDF.
Transvoxel avec des sources de données mixtes. Les cellules de transition situées à la frontière entre un chunk MC et un chunk de heightmap doivent échantillonner des données différentes de chaque côté. Le shader de raccord utilise des indicateurs propres à chaque chunk et des liaisons de buffers pour gérer les quatre combinaisons : HM-HM, HM-MC, MC-HM et MC-MC. Lorsqu’un côté est verrouillé en mode MC, le shader interpole trilinéairement le buffer SDF au lieu d’échantillonner la heightmap. LOD basé sur le pas d’échantillonnage pour les marching cubes. Les données SDF sont stockées à pleine résolution, quel que soit le niveau de LOD actuel du chunk. Le shader MC calcule un pas d’échantillonnage à partir du rapport entre les points de la grille SDF et les cellules MC. À pleine résolution, le pas est de 1 ; à mi-résolution, il est de 2. Cela dissocie les données SDF des changements de LOD, ce qui permet aux chunks de changer librement de LOD sans reconstruire le SDF.
Partie 13 sur 14. Précédent : Partie 12 - Anneaux, brume céleste et ce que nous referions Suivant : Partie 14 - Le monde prend vie Guide de la série : /fr/blog/2026-02-25-open-world-browser-series-guide