Créer un monde ouvert dans le navigateur, partie 15 : remplacer la base, puis la synchroniser
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.
Les quatorze premières parties couvraient les spikes 1 à 30. Cette série s'était achevée sur un système de terrain sculptable en temps réel, accompagné d'un personnage capable d'y marcher, d'y planer et d'y tomber. Cette partie reprend au spike 31, et notre première décision n'était pas technique. Il fallait décider quoi faire de tout ce code de spike.
La décision « remplacer, ne pas rétroporter »
Nous avions 30 fichiers HTML autonomes, chacun validant un concept isolé, et aucune intégration. Le répertoire de production world/client/ utilisait encore l'ancienne pile : WebGL, une heightmap simple, un contrôleur de personnage de 75 lignes et un protocole MessagePack comportant neuf types de messages. Pas d'édition, pas de WebGPU, pas de matériaux, pas de végétation.
Le plan évident consistait à rétroporter un à un les résultats des spikes dans cette base de code de production. Nous l'avons abandonné. Le spike 30 disposait déjà d'un meilleur terrain, d'une meilleure physique, de meilleurs matériaux, d'une meilleure végétation et d'une meilleure caméra que tout ce que world/client/ avait jamais proposé. Rétroporter ces éléments dans l'ancien code WebGL aurait signifié lutter sans cesse contre celui-ci. Nous avons donc décidé de remplacer l'implémentation du monde par le spike le plus abouti et de continuer à construire à partir de là. Le spike 30 est devenu notre nouvelle base, et world/client/ du code mort.
Cela a redéfini le travail restant. Pour passer d'une « excellente démo technique solo » à un « produit », il nous fallait du multijoueur, de la persistance, du streaming de monde infini et du placement d'objets. La synchronisation multijoueur du terrain est passée en premier, car c'est elle qui impose l'architecture. La question à laquelle elle répond est simple à poser, mais une mauvaise réponse coûte cher : lorsque le joueur A sculpte, qu'est-ce qui transite réellement sur le réseau ?
Rejouer les paramètres du pinceau plutôt que synchroniser les pixels
Ouvrir le spike 31 dans un nouvel onglet ↗ · Voir le code source
Avant d'écrire la moindre ligne de code réseau, nous avons suivi précisément le déroulement d'un coup de pinceau. Le pinceau de heightmap parcourt un rayon autour du curseur dans un Float32Array côté CPU, applique une atténuation smoothstep, puis ajoute, soustrait, lisse ou aplatit. Le pinceau SDF fait la même chose en 3D sur une sphère de voxels. Les deux chemins reposent exclusivement sur des calculs de tableaux côté CPU. Aucun calcul GPU dans la boucle, aucun aléatoire, aucune opération à virgule flottante non déterministe. Le même tableau d'entrée et les mêmes paramètres produisent le même résultat sur toutes les machines.
C'est là toute l'astuce. Nous n'envoyons pas le terrain modifié. Nous envoyons les paramètres du pinceau, soit 56 octets par tick de tracé, et chaque client rejoue la même fonction déterministe. Le protocole de synchronisation comprend quatre types de messages : la découverte des pairs, le message de pinceau {op, wx, wy, wz, radius, strength, flattenTarget} et un message de position du joueur à 20 Hz.
Pour ce spike, nous avons complètement contourné le serveur et utilisé BroadcastChannel, l'API du navigateur permettant la communication entre onglets d'une même origine. Ouvrez deux onglets : ils communiquent sans aucune infrastructure. Cela permet d'isoler la question de la synchronisation de la latence, de l'authentification et du raccordement aux Durable Objects. Si le rejeu des paramètres converge entre les onglets, il convergera aussi via un WebSocket.
Les opérations dépendantes de l'ordre constituent le seul cas où le rejeu peut diverger. Élever et abaisser sont des opérations commutatives : val + strength * falloff donne donc le même résultat, quel que soit l'ordre d'application. Le lissage et l'aplatissement lisent les valeurs voisines ; deux clients qui lissent exactement le même endroit au même instant peuvent donc diverger de quelques fractions de millimètre par tick. En pratique, cela ne se produit jamais, et la solution pour la production est déjà évidente : faire transiter les modifications par le DO, lui laisser attribuer un numéro de séquence monotone, appliquer les changements de manière optimiste côté client, puis corriger l'ordre si la séquence faisant autorité diffère. Il s'agit d'une concurrence optimiste classique, et le DO constitue de toute façon un point de sérialisation naturel.
La capsule du pair qui n'arrêtait pas de disparaître
Les modifications se sont synchronisées dès le premier essai. Ce ne fut pas le cas de la capsule du joueur distant. Elle apparaissait et disparaissait sans cesse dans l'autre onglet, et il a fallu corriger trois bugs distincts pour qu'elle reste visible.
La capsule apparaissait à l'origine du monde, enfouie sous le terrain, car le message join arrive avant toute donnée de position. Correction : la masquer initialement et ne l'afficher qu'à la première mise à jour de position. La diffusion de la position se trouvait dans la boucle de rendu, et Chrome bride requestAnimationFrame dans les onglets inactifs. La vérification d'obsolescence de l'autre onglet supprimait donc le pair, puis le message suivant le recréait. Correction : déplacer la diffusion vers un setInterval, qui n'est pas bridé pour les onglets visibles. Enfin, le délai d'obsolescence de 5 secondes était trop agressif et pouvait être déclenché par n'importe quelle pause du ramasse-miettes. Correction : le porter à 30 secondes et s'appuyer sur le message leave propre pour les fermetures normales.
Persistance et arrivée tardive, même format
Nous avons intégré la persistance au même spike plutôt que d'en créer un nouveau, car le format de sérialisation est identique, que la destination soit IndexedDB ou un autre onglet. Un instantané contient la heightmap complète (un Float32Array de 129×129, soit environ 66 Ko), uniquement les chunks SDF modifiés (chacun de
Le premier test de persistance a révélé un joli bug d'ordonnancement. L'herbe est répartie de manière synchrone à l'initialisation à partir des hauteurs procédurales, mais la restauration IndexedDB est asynchrone et remplace ensuite la heightmap, ce qui laisse chaque brin en lévitation ou enfoui. La correction consiste en une passe refreshAllGrass() qui rééchantillonne la hauteur sous chaque instance et masque tout brin qui se trouve désormais sur une pente ou à une altitude inadaptée. La même fonction sert à la fois au chargement et à l'arrivée tardive.
La saga des pentes
Le terrain sculpté est plus accidenté que la base procédurale lisse, et il a révélé trois bugs de physique que l'ancien terrain n'aurait jamais pu exposer. En marchant tout droit vers le haut d'une pente, la capsule glissait latéralement. La cause était une projection de vélocité destinée à maintenir le mouvement tangent au sol, mais écrite en utilisant uniquement les composantes horizontales de la normale. Sur une pente diagonale de normale
La dérive persistait en raison d'une seconde source. Les sondes de collision SDF repoussent le corps le long du gradient, à hauteur de la profondeur de pénétration. Sur une pente, le gradient comporte des composantes horizontales. Une pénétration de 0,1 m sur une pente à 15° pousse donc le corps latéralement d'environ 0,026 m par étape ; à 120 Hz, cela représente près de 3 m/s de dérive invisible. Correction : séparer la réponse en fonction de la pente. Sur un sol praticable (
Le troisième bug immobilisait la capsule aux frontières des chunks, car les sondes de collision échantillonnaient le SDF d'un seul chunk et obtenaient la sentinelle « profondément dans l'air » lorsqu'une sonde franchissait la limite vers le chunk voisin. La correction a consisté à créer sdfSampleWorld(wx, wy, wz) et sdfGradientWorld(...), qui trouvent le bon chunk pour n'importe quelle position dans le monde et utilisent une estimation de distance fondée sur la heightmap lorsqu'aucun SDF n'existe. La transition des collisions entre SDF et heightmap est désormais continue.
L'eau complète le monde
Ouvrir le spike 32 dans un nouvel onglet ↗ · Voir le code source
Jusqu'ici, tous les spikes reposaient sur le principe « la terre au-dessus de l'eau ». Le spike 32 a ajouté un océan et, avec lui, une nouvelle action de déplacement. Nous avons fixé le niveau de l'eau à 22 dans un terrain dont l'altitude varie approximativement de 8 à 58. Cela inonde les vallées basses, crée des plages sur le littoral et laisse suffisamment de terre ferme pour jouer.
La surface utilise un MeshStandardNodeMaterial construit en TSL, selon la même approche nodale que le terrain. Trois ondes sinusoïdales superposées de fréquences différentes déplacent les sommets, et la normale de surface est calculée à partir des dérivées cosinus analytiques de ces ondes plutôt qu'à partir des normales du maillage. La couleur passe des eaux turquoise peu profondes aux profondeurs bleu-vert sombre à l'aide d'une estimation de profondeur
La nage repose sur un ressort de flottabilité. Le joueur passe en mode nage lorsque ses pieds descendent sous le niveau de l'eau et que le centre de son corps se trouve à moins d'une demi-hauteur de capsule de la surface. Un ressort attire le corps vers une cible située juste sous la surface. Avec une constante de flottabilité de 12 et un amortissement dans l'eau de 4, le joueur flotte de manière stable, la tête hors de l'eau, sans oscillation. La vitesse de nage est inférieure à celle de la marche, avec une accélération flottante et de la traînée. Sauter près de la surface vous propulse hors de l'eau à 60 % de la vélocité normale de saut, et l'entrée dans l'eau limite la vélocité descendante à -5 m/s afin d'éviter de plonger trop profondément. Les collisions avec le terrain restent actives sous l'eau, ce qui permet de marcher sur le fond du lac lorsqu'il remonte au-dessus de la cible de nage. L'indicateur de nage est transmis avec la diffusion de position afin que les autres joueurs vous voient nager, et un dégradé HTML teinte la vue lorsque la caméra passe sous la surface.
Technologies abordées dans ce chapitre
Rejeu déterministe des paramètres du pinceau. Au lieu de diffuser le terrain modifié, chaque client envoie uniquement les paramètres du pinceau et rejoue la même fonction CPU. Cela fonctionne parce que les pinceaux de heightmap et de SDF reposent tous deux sur de purs calculs Float32Array, sans aléatoire ni non-déterminisme GPU. Des entrées identiques produisent donc partout des sorties identiques bit à bit. La charge utile est de 56 octets par tick de tracé. Les opérations commutatives (élever, abaisser) convergent quel que soit leur ordre, tandis que les opérations qui lisent les valeurs voisines (lisser, aplatir) nécessitent un point de sérialisation pour garantir la convergence. En production, le Durable Object remplit ce rôle grâce à des numéros de séquence monotones.
BroadcastChannel comme substitut à WebSocket. Une API de navigateur permettant la communication entre onglets d'une même origine sans aucun serveur. Elle sert ici à tester le protocole de synchronisation indépendamment de la latence réseau et de l'authentification. Le format de sérialisation (heightmap Float32Array brute, chunks SDF modifiés et identifiants des chunks verrouillés en MC) utilise exactement les mêmes octets pour la persistance IndexedDB et le transfert d'état lors d'une arrivée tardive. Un seul format remplit donc trois fonctions.
Réponse aux collisions SDF séparée selon la pente. Lorsqu'une sonde de capsule pénètre dans un terrain volumétrique, la solution naïve consiste à repousser le corps le long du gradient SDF selon la profondeur de pénétration. Sur les pentes, ce gradient comporte des composantes horizontales qui introduisent une dérive latérale. En séparant la réponse afin que les surfaces praticables (
Eau TSL avec normales de vagues analytiques. L'océan utilise un matériau nodal dont les sommets sont déplacés par la somme de trois ondes sinusoïdales. Au lieu de recalculer les normales du maillage après le déplacement, la normale de surface est dérivée analytiquement à partir des dérivées cosinus des fonctions d'onde. Cette méthode est moins coûteuse et évite les artefacts des normales calculées par différences finies sur une grille grossière. La couleur, l'écume du littoral et la transparence sont toutes déterminées par une même estimation de profondeur.
Nage par ressort de flottabilité. La physique de nage modélise le corps comme un ressort amorti attiré vers une cible située juste sous la surface. Avec une constante de flottabilité de 12 et un amortissement de 4, le joueur se stabilise à la surface sans osciller. Des constantes de déplacement distinctes — vitesse réduite, accélération flottante et forte traînée — donnent à la nage des sensations différentes de la marche, tandis que les collisions existantes entre la capsule et le terrain continuent de fonctionner sous l'eau.
Partie 15 sur 29. Précédent : Partie 14 — Le monde prend vie Suivant : Partie 16 — Une structure pour un monde qui ne cesse de grandir Guide de la série : /fr/blog/2026-02-25-open-world-browser-series-guide