Skip to content

Construire un monde ouvert dans le navigateur, partie 16 : une structure pour un monde qui ne cesse de grandir

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.

Depuis la partie 13, chaque spike suivait la même recette : copier le monolithe précédent, puis ajouter une fonctionnalité. À la fin du spike 32, ce monolithe comptait 6 285 lignes d’index.html dans un unique <script type="module">. Les recherches dans le code produisaient trop de bruit, trouver où ajouter une fonctionnalité prenait plus de temps que l’écrire, et toute modification architecturale touchait un fichier trop volumineux pour en appréhender mentalement le diff. Avant d’ajouter une autre fonctionnalité, nous avons donc remis de l’ordre dans la structure.

Découper le monolithe sans modifier le moindre comportement

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

La contrainte était stricte : chaque découpage devait être une pure refactorisation, pas une reconception. Le monolithe est devenu 19 fichiers .mjs accompagnés d’une enveloppe hôte de 151 lignes. Des modules de premier niveau gèrent la scène, l’eau, l’herbe, le personnage, la physique, le multijoueur et l’interface, tandis qu’un fichier wgsl.mjs contient toutes les chaînes de code source WGSL et constitue l’unique source de vérité côté GPU. Un sous-dossier terrain/ regroupe la heightmap, le SDF, les chunks, les enveloppes de buffers GPU, la brosse, le LOD et la persistance. Le total atteint 6 555 lignes, soit essentiellement le monolithe augmenté du code standard des imports. Aucun changement net de volume, mais une nette amélioration de la navigabilité.

Puis la page s’est chargée sur un écran noir. Deux messages d’erreur, pour deux causes profondes sans rapport. Le premier était une plainte de WebGPU concernant la liaison d’un buffer de zéro octet. Dans le monolithe, le buffer de la brosse SDF était alloué à la demande lors de l’apparition du premier chunk de marching cubes, et la fabrique de groupes de liaison s’exécutait fortuitement plus tard, une fois le buffer créé. La séparation de terrain/gpu.mjs et terrain/brush.mjs a réordonné l’évaluation des modules : la fabrique s’exécutait désormais en premier et tentait de lier un espace réservé null. La solution consistait à différer la création du groupe de liaison jusqu’au premier dispatch grâce à un helper getOrCreateBindGroup(chunk). Le modèle « tout créer dès le départ » n’était qu’un artefact de l’unique chemin d’initialisation du monolithe.

Le deuxième message était un avertissement inquiétant à propos d’un squelette FBX, mais il s’est révélé être une fausse piste. Il s’affichait depuis le spike 25 et était sans conséquence. Le personnage ne manquait qu’en raison de la propagation du premier bug : chaque dispatch de calcul levait une exception, la heightmap n’était jamais écrite, les échantillons de hauteur renvoyaient 0, et le personnage apparaissait à l’origine avant de tomber à travers le monde. Une fois le buffer corrigé, le personnage s’animait normalement, avertissement compris.

Voilà la véritable leçon de cette refactorisation. Le monolithe dissimulait toutes les relations du type « X doit exister avant de construire Y » dans l’ordre d’exécution du script, de haut en bas. La modularisation a bouleversé cet ordre et révélé trois autres bugs d’ordonnancement latents : la dispersion de l’herbe avant le téléversement de la heightmap, l’ajout du plan d’eau avant la fin du décodage de la carte d’environnement et le chargement de la persistance après la première frame. Tous trois se corrigeaient en une ligne, et aucun n’aurait été découvert sans ce découpage.

Une centaine d’accessoires sans rien faire grossir

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

Le spike 34 devait vérifier si cette structure portait ses fruits. L’objectif était de créer une palette en vue subjective permettant de placer des arbres, des rochers, des buissons, des champignons et des chemins issus d’un pack de modèles CC0, avec alignement sur le terrain, persistance, synchronisation multijoueur et colliders physiques, le tout sans quitter les commandes. Chaque ligne de nouveau code a été ajoutée dans cinq nouveaux fichiers sous src/props/, et aucun module existant n’a gagné plus de dix lignes de raccordement.

Le parcours des assets a connu un détour qu’il vaut la peine de consigner, car c’est exactement le genre de problème qui fait perdre une journée. Nous avons commencé avec le pack Ultimate Nature de Quaternius, une bibliothèque FBX dépourvue de textures intégrées. Les matériaux FBX étaient fournis en MeshPhong sans map ; nous avons donc câblé manuellement une table de correspondance entre noms de matériaux et fichiers PNG, converti les matériaux Phong en Standard et défini les espaces colorimétriques à la main. Environ 30 % des matériaux n’avaient aucun PNG correspondant, et plusieurs noms étaient ambigus entre des arbres similaires. Un deuxième pack FBX présentait la même lacune. La solution n’était pas d’ajouter encore des tables de correspondance, mais de choisir un pack mieux conçu : le Stylized Nature MegaKit de Quaternius fournit 116 fichiers glTF complets avec matériaux PBR intégrés et normales précalculées. Remplacer FBXLoader par GLTFLoader a supprimé la conversion d’échelle des centimètres vers les mètres, la table des textures et la conversion Phong, tout en réduisant library.mjs d’environ 80 lignes. Conclusion : le glTF avec PBR est le pipeline adapté aux packs CC0 qui le proposent, tandis que le FBX avec association manuelle des textures demandait deux fois plus de code pour deux fois moins de qualité.

Le parcours glTF comportait aussi quelques pièges. La palette affiche 116 miniatures, et avec WebGPU, canvas.toDataURL() renvoie une image vide pour une surface GPUCanvasContext. Les miniatures sont donc rendues dans un RenderTarget, relues avec readRenderTargetPixelsAsync, puis copiées dans un canvas 2D en tenant compte de l’alignement des lignes sur 256 octets imposé par WebGPU. Les aperçus fantômes clonent chaque matériau pour le teinter en vert, ce qui cassait sur les maillages dont la propriété material est un tableau ; une branche Array.isArray a résolu le problème. Enfin, les normal maps 16 bits qui occupaient environ 200 Mo ont été converties une fois pour toutes avec mogrify -depth 8, ramenant leur taille à environ 32 Mo sans différence visuelle, puisque les navigateurs réduisent de toute façon leur précision lors du téléversement.

Quand la géométrie rendue n’existe que sur le GPU

Le bug le plus instructif concernait l’aperçu fantôme, qui se déplaçait par à-coups de 1 à 2 mètres avec le curseur. Les maillages du terrain stockent les positions des sommets dans un StorageBufferAttribute, car le pipeline de calcul les écrit directement sur le GPU. Le Raycaster CPU de three.js ne peut donc pas les voir et ne renvoie rien. La solution de repli était un ray marching grossier, par pas de 1,5 m, sur la heightmap analytique ; ce pas fixe produisait la grille visible par l’utilisateur. Nous l’avons remplacé par un parcours adaptatif : pas de 2,5 m loin au-dessus de la surface, réduction à 0,4 m à moins de 5 m de celle-ci, puis 14 itérations de bissection lorsque le signe de (rayyterrainy) s’inverse. On obtient ainsi une précision inférieure au millimètre avec environ 30 grands pas et 14 bissections par lancer. Lorsque la géométrie rendue ne réside que sur le GPU, inutile de lutter contre le raycaster : parcourez plutôt la source analytique.

Des colliders qui restent fidèles après les modifications

Nous avons choisi des approximations primitives plutôt que des enveloppes convexes ou des colliders de maillage. Les accessoires Quaternius sont arrondis, low-poly et dépourvus de concavités significatives. Les enveloppes auraient donc demandé environ 50 fois plus de code et coûté 10 fois plus à l’exécution pour un gameplay identique. Chaque accessoire est réduit à une forme unique dérivée de sa boîte englobante : une capsule verticale pour les arbres et les cactus, une sphère pour les rochers, une capsule horizontale orientée selon l’axe longitudinal pour les troncs, et rien pour les buissons et fleurs décoratifs. Il est possible de marcher sur les rochers et les troncs — la poussée est uniquement verticale afin de pouvoir tenir dessus — tandis que les arbres et les cactus sont bloquants — la poussée s’exerce dans les trois dimensions pour empêcher d’escalader un tronc. Une table de hachage spatiale de 8 m limite les tests à chaque frame au voisinage 3×3 du joueur, soit généralement entre zéro et six accessoires.

Deux décisions de conception ont préservé la cohérence du système. L’alignement sur le terrain est un indicateur du manifeste, et non une énumération de catégories codée en dur. L’aperçu fantôme et le placement définitif lisent donc la même valeur placement.alignToTerrain et ne peuvent pas diverger. De plus, les accessoires placés réagissent aux modifications du terrain par l’intermédiaire d’un helper unique : après un coup de brosse, qu’il soit local ou rejoué depuis un pair, refreshPlacementsInRadius rééchantillonne le sol sous chaque accessoire dans le disque concerné, réapplique l’alignement et recalcule les extrémités du collider. Sculptez une colline sous un arbre, et l’arbre montera avec elle. La persistance et le multijoueur réutilisent exactement le modèle du spike 31 : une liste plate de {uid, propId, x, y, z, rotY, scale} est stockée, tandis que les événements de placement, de suppression et d’ajustement sont répliqués via BroadcastChannel.

Technologies abordées dans ce chapitre

Décomposition en modules ES sous WebGPU. Découper un <script type="module"> monolithique en imports .mjs avec chemins simples ne nécessite aucun bundler lorsque les modules sont servis comme assets statiques, et TSL de three.js fonctionne parfaitement au-delà des frontières entre modules. Le coût caché réside dans l’ordre d’initialisation : un monolithe encode « construire X avant Y » dans l’ordre du script, de haut en bas, tandis que les modules sont évalués selon l’ordre des imports. Une fabrique de groupes de liaison GPU peut donc s’exécuter avant que son buffer n’existe. Le modèle de correction consiste à employer une initialisation à la demande (getOrCreate... lors de la première utilisation) et à attendre la bonne promesse plutôt qu’à dépendre de l’ordre des déclarations.

glTF avec PBR intégré contre FBX avec association manuelle. Le glTF utilise les mètres, référence ses propres textures et fournit directement des MeshStandardMaterial. Un pack CC0 conçu en glTF s’intègre donc directement dans un pipeline PBR. Les packs FBX dépourvus de métadonnées de liaison des textures nécessitent une table manuelle de correspondance entre noms de matériaux et fichiers PNG, qui devient obsolète à chaque mise à jour du pack, ainsi qu’une conversion de Phong vers Standard et un étiquetage manuel des espaces colorimétriques. Un filet de sécurité pour la végétation transforme les matériaux transparent sans alphaTest en cartes découpées avec alphaTest: 0.5, afin qu’elles soient correctement triées derrière la géométrie opaque.

Miniatures hors écran avec WebGPU. canvas.toDataURL() renvoie une image vide pour un canvas adossé à un GPUCanvasContext, car aucune voie ne permet de revenir d’une surface de présentation vers un contexte 2D. Le rendu dans un RenderTarget, la lecture des pixels avec readRenderTargetPixelsAsync et leur copie dans un canvas 2D fonctionnent, à condition de parcourir les données selon le pas de lecture aligné sur 256 octets de WebGPU. Les résultats sont mis en cache dans localStorage sous une clé dont la version est incrémentée, afin qu’une modification du pack invalide les rendus obsolètes.

Ray marching adaptatif sur une heightmap analytique. Lorsque les sommets du terrain résident dans un StorageBufferAttribute GPU, le raycaster CPU ne peut pas les voir. Parcourir la fonction analytique de hauteur avec un grand pas loin de la surface, un petit pas à proximité, puis affiner par recherche binaire lors de l’inversion du signe de (rayyterrainy) offre une précision inférieure au millimètre pour un nombre borné d’échantillons. La même primitive alimente le curseur de la brosse et l’aperçu fantôme des accessoires.

Colliders capsules primitifs avec table de hachage spatiale. Chaque accessoire est réduit, selon sa catégorie, à une capsule ou une sphère dérivée de sa boîte englobante, enregistrée sous la forme {kind, walkable, radius, p1, p2} dans chaque cellule de hachage de 8 m qu’elle recouvre. À chaque frame, le joueur teste uniquement les accessoires présents dans son voisinage de cellules 3×3, avec une résolution capsule contre capsule pour chacun. Les approximations sur lesquelles on peut marcher — rochers et troncs — appliquent une poussée uniquement verticale ; les approximations bloquantes — arbres — appliquent une poussée complète en 3D. Consultez les collisions avec un terrain SDF pour découvrir les calculs de capsules sur lesquels repose cette méthode.


Partie 16 sur 29. Précédente : Partie 15 — Remplacer la base de référence, puis la synchroniser Suivante : Partie 17 — Des animations sans retargeting et une recherche d’assets en direct Guide de la série : /fr/blog/2026-02-25-open-world-browser-series-guide