Construire un monde ouvert dans le navigateur, partie 24 : sauvegarder un monde et rendre le vent visible
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.
La partie 23 a peuplé le monde et donné une voix à ses habitants. Cette partie explique comment faire en sorte que le monde se souvienne de ce qu'ils y ont fait et paraisse vivant même lorsque personne n'y touche. Le Spike 47 porte sur la persistance : un créateur sculpte le terrain et place des accessoires, puis ces modifications survivent à un rechargement, se synchronisent avec tous les autres pairs et sont arbitrées proprement lorsque deux personnes modifient le monde simultanément. Le Spike 49 porte sur le vent : nous avons porté les shaders de végétation d'un pack de nature stylisée afin que les arbres, les buissons et l'herbe bougent comme l'artiste l'avait prévu, ce qui s'est davantage transformé en combat contre le compilateur de shaders que contre les mathématiques.
Un monde qui se souvient
Ouvrir le Spike 47 dans un nouvel onglet ↗ · Voir le code source
Le scénario est une session de création partagée. Les joueurs parcourent un monde, placent des accessoires d'un clic, les suppriment d'un clic droit et sculptent le sol en faisant glisser un pinceau, soit pour élever ou abaisser la heightmap, soit pour creuser des grottes volumétriques dans le terrain SDF, ce qui fait passer un chunk aux marching cubes. Tout ce qu'ils font est conservé dans un WorldChunkDO Cloudflare, un Durable Object faisant autorité côté serveur et reposant sur son propre stockage SQLite. Les clients n'écrivent pas directement dans l'état. Ils envoient une intention, le DO arbitre puis diffuse le résultat. Comme le DO constitue l'unique source de vérité, un nouvel arrivant reçoit un instantané et rejoint exactement le même monde que celui vu par tous les autres.
Deux détails de la persistance se sont révélés particulièrement utiles. Le terrain n'est pas stocké sous forme de journal d'événements à rejouer lors de la connexion ; il est conservé sous forme de blobs binaires par chunk qui constituent la source de vérité et sont téléversés à la fin de chaque coup de pinceau. Un nouvel arrivant charge donc directement les octets validés au lieu de réexécuter des milliers d'échantillons du pinceau. Ces blobs utilisent également une clé de stockage par chunk plutôt qu'une seule grosse ligne, car un chunk SDF pèse 168 Ko et le DO impose une limite de 2 Mo par ligne. Un index des chunks répertorie les clés existantes afin que le DO puisse réhydrater toute la carte à son réveil. Côté client, l'identité s'appuie sur deux types de stockage : l'identifiant du joueur réside dans sessionStorage, de sorte que deux onglets correspondent à deux pairs distincts plutôt qu'à un même pair qui s'écrase lui-même dans la table des joueurs du DO, tandis que le nom affiché réside dans localStorage, afin qu'un changement de nom effectué dans un onglet soit répercuté dans tous les autres.
Faire converger les modifications simultanées
La vraie difficulté de la création multijoueur apparaît lorsque deux personnes modifient une même zone du sol exactement au même instant. Le spike répartit les modifications selon leurs propriétés algébriques. Les opérations additives — élever et abaisser la heightmap, ainsi qu'ajouter et soustraire dans le SDF — sont commutatives : leur application dans n'importe quel ordre produit le même résultat. Elles empruntent donc un chemin optimiste fondé sur des empreintes, dans lequel chaque client applique localement l'opération et envoie l'empreinte, que le DO diffuse à tout le monde sans aucune coordination. L'ordre n'a réellement aucune importance, il n'y a donc rien à coordonner.
Les opérations dépendantes de l'ordre, le lissage et l'aplanissement, constituaient le cas intéressant. La première conception leur attribuait un verrou régional : un client demandait un verrou lors de l'appui sur le pointeur, le DO l'accordait ou le refusait, le client mettait les échantillons en mémoire tampon pendant l'appui et, lorsque le pointeur était relâché, le DO appliquait tout le tracé de manière atomique. Cela fonctionnait, mais nécessitait un protocole distinct, avec sa propre durée de vie du verrou et son propre aller-retour d'acceptation ou de refus. La solution plus élégante qui l'a remplacé consiste à précalculer un delta : l'émetteur exécute localement le pinceau de lissage ou d'aplanissement, puis envoie la liste obtenue des deltas par cellule. Chaque pair se contente alors d'ajouter ces deltas à ses propres cellules sans rien recalculer. En figeant son résultat à la source, cette approche transforme une opération dépendante de l'ordre en opération commutative. L'ensemble du système de modification repose ainsi sur un protocole commutatif unique et uniforme, offrant une convergence identique sans aucun verrou. Les verrous d'accessoires subsistent pour une autre raison : le verrouillage par enregistrement a remplacé la suppression réservée au propriétaire. N'importe quel pair peut donc supprimer n'importe quel accessoire, sauf si quelqu'un l'a verrouillé, auquel cas seul le détenteur du verrou peut le retirer. L'annulation et le rétablissement fonctionnent en permettant au client de choisir l'identifiant d'un accessoire avant de le placer. Il connaît ainsi cet identifiant avant même le retour du serveur et peut inverser ses propres actions de manière déterministe. Les WebSockets en hibernation permettent à une salle inactive de ne rien coûter, comme pour le relais d'avatars bon marché de la partie précédente.
Un portage fidèle du vent, suivi d'un combat
Ouvrir le Spike 49 dans un nouvel onglet ↗ · Voir le code source
Le Spike 49 reprend le Stylized Nature MegaKit de Quaternius et porte son système de vent vers notre pile technologique. Le pack fournit ses shaders Godot sources — quatre au total — et la bonne décision consistait à les traduire fidèlement plutôt qu'à les réinventer. La fonction de vertex shader de l'écorce est vide, les troncs restent donc rigides. Une première tentative reposant sur un masque procédural faisait onduler les troncs, et la correction a simplement consisté à ne plus leur appliquer de vent du tout. Les feuilles reçoivent un balancement chaotique par sommet à partir d'un hash en impulsion triangulaire, masqué selon la hauteur afin que les cimes bougent tandis que la base reste enracinée. Le feuillage de base reçoit un balancement sinusoïdal modulé par du bruit dans l'espace monde. L'herbe reprend ce feuillage de base en y ajoutant une ondulation en lignes de vent : une texture de bruit défilante est échantillonnée à travers une courbe de puissance, de sorte que seules les bandes claires de la texture contribuent au mouvement, ce qui produit les ondulations visibles qui traversent une prairie. La répartition suit la convention de nommage des matériaux du pack : un matériau nommé Leaves_Birch est donc dirigé vers le chemin des feuilles et Grass_Common vers celui de l'herbe, sans aucune déduction approximative.
L'une des surprises du portage est que la couleur des feuilles ne se trouve pas dans la texture. Quaternius construit entièrement leur apparence à partir d'un dégradé vertical et d'un liseré de Fresnel : l'albédo mélange une couleur supplémentaire située au bas de la cime avec la couleur des feuilles au sommet, en fonction de la hauteur. Une teinte de diffusion sous-surfacique est ajoutée sous forme d'émission, mise à l'échelle par un terme de Fresnel heightFactor précalculé plutôt que la coordonnée Y locale brute. Cet attribut est normalisé pour chaque groupe de feuilles au chargement à partir de la coordonnée Y dans l'espace monde, afin que le dégradé et le masque du vent se comportent correctement, quelle que soit la manière dont l'importation FBX a orienté les axes locaux de chaque maillage. Les constantes de couleur Godot sont définies en sRGB et converties en espace linéaire avant d'atteindre le shader. Le portage effectue donc la même conversion au lieu de fournir directement les valeurs sRGB lumineuses comme des valeurs linéaires, ce qui délaverait le feuillage.
La recompilation qui a englouti le débit d'images
Si cette version a d'abord été livrée avec une base FBX, tandis que l'ensemble complet des matériaux de vent était mis de côté dans un fichier .bak, c'est à cause d'un bug de recompilation à chaque frame qui faisait chuter la scène à environ 1 fps. L'ajout de TSL personnalisé aux matériaux chargés depuis le FBX poussait Three.js à reconstruire les programmes de shaders à chaque frame, comme si needsUpdate restait constamment activé. La méthode de diagnostic consistait à réduire chaque matériau de feuillage à une simple passe texturée sans nœuds personnalisés, puis à vérifier si la boucle de recompilation persistait. Si elle s'arrêtait, le graphe personnalisé était responsable ; si elle continuait, la cause se trouvait en amont, dans la configuration des matériaux FBX ou dans Three.js lui-même. La correction qui a permis de rétablir les véritables shaders consistait à lier sous forme d'uniformes toutes les différences propres aux matériaux — couleur des feuilles, couleur SSS, intensité et mélange. Tous les éléments feuillus partagent ainsi un même programme compilé, au lieu que le compilateur produise un nouveau shader pour chaque combinaison de couleurs unique et sature la file de compilation.
Deux autres éléments méritent d'être conservés. L'herbe est rendue sous la forme d'un InstancedMesh par fichier source, et son mouvement sous l'effet du vent est calculé dans l'espace monde, car les phases sinusoïdales dépendent de la position dans cet espace. Mais le déplacement doit être appliqué dans l'espace local avant l'application de la matrice d'instance, et WGSL ne fournit aucune fonction inverse(). Pour une matrice d'instance composée d'une translation, d'une rotation autour de Y et d'une échelle uniforme, l'inverse de la partie 3×3 supérieure correspond simplement à sa transposée divisée par le carré de l'échelle. Le shader multiplie donc le déplacement dans l'espace monde par la matrice de modèle transposée, puis le divise par la longueur au carré de la première colonne de la matrice, ce qui permet de retrouver l'échelle sans racine carrée. Lorsque la transformation des sommets réapplique ensuite la matrice, le mouvement se retrouve dans l'espace monde exactement comme prévu, indépendamment de la rotation ou de l'échelle de chaque touffe. Chaque touffe effectue également un culling GPU par sommet contre le frustum : le centre de l'instance est projeté dans l'espace de clipping et, s'il se trouve hors du frustum en tenant compte d'une marge, tous ses sommets sont rabattus sur l'origine locale afin que les trois sommets de chaque triangle coïncident. Le rastériseur élimine alors le triangle dégénéré et aucun travail de fragment, de test alpha ou d'ombre n'est effectué pour l'herbe hors écran. Cette technique s'ajoute au culling grossier par sphère englobante de chunk déjà effectué par Three.js. Elle est construite avec la fonction step sur des flottants plutôt qu'avec des booléens, afin que son résultat puisse être directement multiplié dans le mélange des positions.
Technologies abordées dans ce chapitre
Persistance du monde faisant autorité côté serveur. Un Durable Object WorldChunkDO arbitre le placement des accessoires et les modifications du terrain, conserve des blobs binaires par chunk comme source de vérité — afin que les nouveaux arrivants chargent les octets validés au lieu de rejouer un journal d'événements — et utilise une clé de stockage par chunk pour rester sous la limite de 2 Mo par ligne du DO. L'identifiant du joueur réside dans sessionStorage, afin que chaque onglet soit un pair distinct ; le nom affiché réside dans localStorage, afin que les changements de nom se propagent entre les onglets.
Convergence commutative des modifications. Les opérations additives sur le terrain — élévation et abaissement, ajout et soustraction SDF — sont commutatives et empruntent un chemin optimiste fondé sur des empreintes, sans coordination. Les opérations dépendantes de l'ordre — lissage et aplanissement — deviennent commutatives grâce à l'envoi de deltas précalculés par cellule plutôt qu'à l'acquisition d'un verrou régional. L'ensemble du système converge ainsi au moyen d'un protocole uniforme sans verrou. Les verrous par accessoire remplacent la suppression réservée au propriétaire, tandis que les identifiants d'objet choisis par le client permettent des annulations et rétablissements déterministes avant le retour du serveur. Voir le LOD piloté par le GPU.
Portage fidèle des shaders de Godot vers TSL. Les quatre shaders de vent sources de Quaternius sont traduits ligne par ligne : écorce rigide, balancement des feuilles masqué selon la hauteur, balancement du feuillage dans l'espace monde et herbe animée par une ondulation en lignes de vent défilantes. Ils sont sélectionnés selon la convention de nommage des matériaux du pack. La couleur des feuilles provient d'un dégradé de hauteur associé à un liseré SSS piloté par Fresnel plutôt que de la texture, et les constantes créées en sRGB sont converties en espace linéaire afin que le rendu corresponde aux images de référence.
Éviter les recompilations de shaders à chaque frame. Ajouter du TSL personnalisé aux matériaux FBX peut maintenir needsUpdate actif et reconstruire les programmes à chaque frame, faisant chuter les performances à environ 1 fps. Lier sous forme d'uniformes toutes les différences propres aux matériaux permet à toutes les variantes de partager un même programme compilé, au lieu de produire un shader distinct pour chaque jeu de paramètres unique. Une passe de diagnostic simplement texturée permet de déterminer si la cause vient du graphe personnalisé ou de la configuration en amont.
Vent dans l'espace monde sur du feuillage instancié. Le vent de l'herbe est calculé dans l'espace monde, puis reconverti dans l'espace local au moyen d'un inverse calculé manuellement — transposée divisée par le carré de l'échelle — car WGSL ne fournit pas de fonction inverse(). Un culling GPU par sommet contre le frustum réduit les touffes hors écran à des triangles dégénérés, évitant tout travail de fragment ou d'ombre. Il s'ajoute au culling par sphère englobante de chunk effectué par Three.js.
Partie 24 sur 29. Précédente : Partie 23 - Cinquante avatars et une voix dans la salle Suivante : Partie 25 - Un seul squelette pour toutes les tenues Guide de la série : /fr/blog/2026-02-25-open-world-browser-series-guide