Godot XR Multiplayer : les coulisses du Spatial Sync et du Netcode de Time-Rewind
En bref
Cet article analyse les contraintes physiques et réseau liées à la synchronisation spatiale haute fréquence dans le développement de jeux VR multijoueurs avec Godot 4. Il propose une implémentation en GDScript d'un système d'enregistrement spatial basé sur un buffer circulaire, la delta-compression et l'interpolation SLERP pour éliminer le motion sickness. Enfin, il compare l'approche complexe d'une infrastructure réseau développée manuellement avec l'intégration simplifiée du SDK horizOn.
Développer un jeu godot xr multiplayer est le chemin le plus court vers un motion sickness sévère si votre synchronisation spatiale accuse le moindre retard, ne serait-ce que d'une seule frame. Lorsqu'un joueur déplace sa tête ou ses mains à un taux de rafraîchissement natif de 90Hz, tout jitter réseau ou retard de synchronisation crée un décalage brutal entre son corps physique et le monde virtuel. Résoudre cette latence spatiale nécessite de s'affranchir des paradigmes traditionnels du Netcode pour écrans plats et d'implémenter des buffers de tracking prédictifs personnalisés.
En analysant des mécaniques temporelles avancées et des patterns de Netcode personnalisés, vous pouvez maintenir les caméras clients et les entités physiques en parfaite synchronisation, sans pour autant surcharger la bande passante de votre serveur.
Sous le capot : le défi extrême du Spatial Sync dans le Multiplayer VR
Dans un jeu Multiplayer classique sur écran plat, vous synchronisez un simple collider de type capsule de personnage, composé d'un vecteur de position 3D (12 octets) et d'un float de rotation 1D (4 octets). Envoyer ce payload de 16 octets à 30Hz représente environ 480 octets de données brutes par seconde. Dans un contexte godot xr multiplayer, vous devez synchroniser simultanément trois composants suivis (tracked) distincts à haute fréquence : le casque (HMD) et deux contrôleurs de mouvement. Chaque point suivi nécessite un vecteur de position 3D (12 octets) et un quaternion d'orientation 3D (16 octets), soit un total de 28 octets par point.
La synchronisation de ces trois points représente 84 octets par frame. À un taux de rafraîchissement standard de 90Hz, cela exige 7 560 octets (7,56 KB) par seconde de payload brut. Ajoutez à cela l'overhead classique des paquets UDP (28 octets par en-tête), et un seul client consomme jusqu'à 10,08 KB/s. Dans un lobby à 8 joueurs, le serveur doit traiter et diffuser jusqu'à 564 KB/s de données haute fréquence, ce qui génère une congestion réseau massive et des délais dans la file d'attente des paquets (packet queue delays).
Alors que les jeux sur écran plat traditionnels tolèrent de légers écarts — comme détaillé dans notre guide sur comment corriger les desyncs de position des joueurs dans le multiplayer d'Unreal Engine — les jeux VR ne pardonnent absolument rien. Une désynchronisation spatiale d'à peine 3 frames peut déclencher un conflit vestibulaire aigu, provoquant du motion sickness chez les joueurs. Pour lutter contre cela, les développeurs doivent abandonner la réplication rigide de type server-authoritative au profit de systèmes de client-side prediction avec une réconciliation des deltas historiques (historical delta reconciliation).
5 leçons spatiales tirées des créations de la Godot XR Community Game Jam V
La cinquième Godot XR Community Game Jam V a réuni 98 participants qui ont soumis 23 jeux autour du thème « Rewind ». Ces projets ont repoussé les limites des architectures spatiales de Godot 4, illustrant des applications créatives de manipulation temporelle dans des espaces immersifs. Voici les principales leçons sur la synchronisation spatiale et le Netcode que nous avons tirées des cinq meilleures soumissions de cette jam.
1. Physique tactile et états d'interaction (inspiré de Rewind Tower)
Le grand vainqueur de la jam, Rewind Tower, propose un système de tower defense tactile où les joueurs remontent des unités mécaniques sur un plateau à jetons et les renvoient dans le passé pour les maintenir au combat. Dans un scénario Multiplayer, la synchronisation des saisies physiques (grabs) et des états de remontage de ressorts analogiques introduit des race conditions critiques.
Si deux joueurs tentent de saisir la même unité simultanément, un système de réplication naïf provoquera une téléportation rapide de l'objet entre les deux joueurs alors qu'ils luttent pour obtenir l'autorité réseau. Pour résoudre ce problème, les développeurs doivent implémenter une file d'attente prioritaire déterministe (deterministic priority queue) sur le serveur, qui désigne temporairement un client comme l'autorité physique, bloquant les inputs de saisie externes jusqu'à ce que l'objet soit relâché.
2. Contrôles analogiques portatifs et synchronisation continue des inputs (inspiré de Chrono Crank)
Arrivé en deuxième position, Chrono Crank repose sur un appareil portatif de style steampunk doté d'une grande manivelle physique servant à manipuler le cours du temps. Répliquer la rotation physique de cette manivelle nécessite de synchroniser un float continu de haute précision entre tous les joueurs connectés.
Les leviers et boutons analogiques nécessitent des mises à jour de position continues plutôt que de simples déclencheurs d'états binaires (state triggers). Essayer de synchroniser ces coordonnées physiques interactives à l'aide de RPC haute fréquence est un piège classique qui fait directement écho aux difficultés rencontrées lors de la résolution des problèmes de réplication RPC en multiplayer qui corrompent les états du monde.
Au lieu de cela, vous devriez synchroniser l'angle de rotation local de la manivelle sous forme de variable float compressée, puis interpoler l'état sur les clients récepteurs en utilisant une Hermite spline interpolation.
3. Enregistrement spatial et scrubbing (inspiré de NeuroCorp Training Demo)
Dans le projet classé troisième, NeuroCorp Training Demo, les joueurs sont téléportés dans des enregistrements immersifs d'événements physiques passés, ce qui leur permet de naviguer (scrubber) d'avant en arrière dans le temps pour accomplir des tâches. Dans un lobby partagé, la synchronisation de ces événements de scrubbing temporel nécessite un buffer d'état global (global state buffer).
Comme charger l'historique physique complet d'une pièce frame par frame consomme énormément de mémoire, la couche réseau doit sérialiser les données de frame à l'aide de structures de keyframes indexées. Cela permet aux clients d'interpoler localement entre des échantillons de position discrets au lieu de streamer des frames de transform continues.
4. Timelines spatiales parallèles (inspiré de Last Minute)
Last Minute, qui a terminé à la quatrième place, est un escape game où les joueurs manipulent le temps pour résoudre des énigmes en temps réel. Gérer des états temporels asynchrones dans une room de jeu en multiplayer est un cauchemar architectural.
Si le Joueur A remonte le temps d'une boîte à énigme pour la ramener à son état d'il y a 10 secondes alors que le Joueur B tient actuellement une clé issue de cette boîte, le serveur doit découpler la timeline spatiale de la boîte de la timeline globale des joueurs. Pour y parvenir, le monde du jeu doit être structuré en zones temporelles indépendantes, permettant aux nœuds spatiaux individuels de traiter leurs buffers d'historique de manière autonome tout en maintenant les joueurs actifs verrouillés sur le tick rate du système.
5. Multi-Entity Prediction et mouvements synchronisés sur plateforme (inspiré de ScrewTheTime)
ScrewTheTime a décroché la confiance place en tant que platformer VR dans lequel le joueur contrôle à la fois son personnage et l'état temporel de plateformes mobiles. Synchroniser un joueur debout sur une plateforme mobile qui remonte le temps en VR requiert des transformations d'espace de coordonnées parent-enfant (parent-child coordinate space transforms).
Si le serveur synchronise uniquement les coordonnées globales absolues, les infimes variations de latence entre le mouvement de la plateforme et les mises à jour du HMD du joueur feront glisser ce dernier hors de la plateforme ou le feront trembler de manière incontrôlable. Pour corriger cela, définissez l'origine spatiale du joueur comme enfant (child) du nœud de la plateforme mobile côté client, en traduisant tous les calculs locaux de mouvement de tête par rapport à la base locale de la plateforme avant de diffuser les mises à jour au serveur.
Deep Dive : concevoir un système d'enregistrement spatial avec delta-compression dans Godot 4
Pour concevoir une expérience godot xr multiplayer hautement réactive avec des fonctionnalités de rewind, vous devez implémenter un buffer d'enregistrement spatial qui compresse les positions suivies (tracked positions). La classe GDScript 4 prête pour la production ci-dessous enregistre les données de tracking locales (HMD et contrôleurs) dans un ring buffer, applique une delta-compression pour minimiser le trafic réseau, et effectue une interpolation linéaire sphérique (SLERP) pour un playback fluide sur les clients distants.
# xr_spatial_sync_manager.gd
extends Node
class_name XRSpatialSyncManager
# Inner class to store high-frequency spatial tracking data for a single frame
class TrackingFrame extends RefCounted:
var timestamp: float = 0.0
var head_pos: Vector3 = Vector3.ZERO
var head_rot: Quaternion = Quaternion.IDENTITY
var left_hand_pos: Vector3 = Vector3.ZERO
var left_hand_rot: Quaternion = Quaternion.IDENTITY
var right_hand_pos: Vector3 = Vector3.ZERO
var right_hand_rot: Quaternion = Quaternion.IDENTITY
# Compress the frame into a lightweight dictionary for network serialization
func serialize() -> Dictionary:
return {
"t": timestamp,
"hp": [head_pos.x, head_pos.y, head_pos.z],
"hr": [head_rot.x, head_rot.y, head_rot.z, head_rot.w],
"lp": [left_hand_pos.x, left_hand_pos.y, left_hand_pos.z],
"lr": [left_hand_rot.x, left_hand_rot.y, left_hand_rot.z, left_hand_rot.w],
"rp": [right_hand_pos.x, right_hand_pos.y, right_hand_pos.z],
"rr": [right_hand_rot.x, right_hand_rot.y, right_hand_rot.z, right_hand_rot.w]
}
# Decompress a network dictionary back into a fully typed frame
static func deserialize(data: Dictionary) -> TrackingFrame:
var frame = TrackingFrame.new()
frame.timestamp = data["t"]
frame.head_pos = Vector3(data["hp"][0], data["hp"][1], data["hp"][2])
frame.head_rot = Quaternion(data["hr"][0], data["hr"][1], data["hr"][2], data["hr"][3])
frame.left_hand_pos = Vector3(data["lp"][0], data["lp"][1], data["lp"][2])
frame.left_hand_rot = Quaternion(data["lr"][0], data["lr"][1], data["lr"][2], data["lr"][3])
frame.right_hand_pos = Vector3(data["rp"][0], data["rp"][1], data["rp"][2])
frame.right_hand_rot = Quaternion(data["rr"][0], data["rr"][1], data["rr"][2], data["rr"][3])
return frame
# --- Configuration Properties ---
@export var sync_threshold_position: float = 0.005 # 5mm threshold for delta-compression
@export var sync_threshold_rotation: float = 0.02 # ~1.15 degrees threshold for angular changes
@export var buffer_max_time: float = 2.0 # Store up to 2 seconds of local history
@export var playback_interpolation_delay: float = 0.033 # 33ms interpolation window
# --- Node References ---
@export var xr_origin: XROrigin3D
@export var xr_camera: XRCamera3D
@export var left_controller: XRController3D
@export var right_controller: XRController3D
# --- Internal Buffers ---
var history_buffer: Array[TrackingFrame] = []
var last_sent_frame: TrackingFrame = null
func _ready() -> void:
if not xr_origin or not xr_camera or not left_controller or not right_controller:
push_warning("XRSpatialSyncManager: Tracked nodes are not fully assigned!")
# Record the current spatial state of the VR user
func capture_current_frame() -> TrackingFrame:
var frame = TrackingFrame.new()
frame.timestamp = Time.get_ticks_msec() / 1000.0
# Capture relative transforms inside the XR origin space
frame.head_pos = xr_camera.transform.origin
frame.head_rot = xr_camera.transform.basis.get_rotation_quaternion()
frame.left_hand_pos = left_controller.transform.origin
frame.left_hand_rot = left_controller.transform.basis.get_rotation_quaternion()
frame.right_hand_pos = right_controller.transform.origin
frame.right_hand_rot = right_controller.transform.basis.get_rotation_quaternion()
return frame
# Push a captured frame into our sliding history ring buffer
func store_frame(frame: TrackingFrame) -> void:
history_buffer.append(frame)
# Prune frames older than the maximum buffer timeline window
var expiration_time = frame.timestamp - buffer_max_time
while history_buffer.size() > 0 and history_buffer[0].timestamp < expiration_time:
history_buffer.remove_at(0)
# Evaluate if the current frame differs enough from the last sync frame to justify sending a network packet
func check_delta_compression(current: TrackingFrame) -> bool:
if last_sent_frame == null:
return true # Always send the initial setup frame
# Check spatial distance changes
var head_pos_diff = current.head_pos.distance_squared_to(last_sent_frame.head_pos)
var left_pos_diff = current.left_hand_pos.distance_squared_to(last_sent_frame.left_hand_pos)
var right_pos_diff = current.right_hand_pos.distance_squared_to(last_sent_frame.right_hand_pos)
if head_pos_diff > (sync_threshold_position * sync_threshold_position) or \
left_pos_diff > (sync_threshold_position * sync_threshold_position) or \
right_pos_diff > (sync_threshold_position * sync_threshold_position):
return true
# Check angular orientation changes
var head_rot_diff = current.head_rot.angle_to(last_sent_frame.head_rot)
var left_rot_diff = current.left_hand_rot.angle_to(last_sent_frame.left_hand_rot)
var right_rot_diff = current.right_hand_rot.angle_to(last_sent_frame.right_hand_rot)
if head_rot_diff > sync_threshold_rotation or \
left_rot_diff > sync_threshold_rotation or \
right_rot_diff > sync_threshold_rotation:
return true
return false
# Rewind or playback history buffer: interpolates between tracked frame states at a specific timestamp
func get_interpolated_state(target_time: float) -> TrackingFrame:
if history_buffer.is_empty():
return null
# Clamp target time to available history
if target_time <= history_buffer[0].timestamp:
return history_buffer[0]
if target_time >= history_buffer[-1].timestamp:
return history_buffer[-1]
# Find surrounding frames
var prev_frame = history_buffer[0]
var next_frame = history_buffer[-1]
for i in range(history_buffer.size() - 1):
if history_buffer[i].timestamp <= target_time and history_buffer[i + 1].timestamp >= target_time:
prev_frame = history_buffer[i]
next_frame = history_buffer[i + 1]
break
var span = next_frame.timestamp - prev_frame.timestamp
var factor = 0.0
if span > 0.0001:
factor = (target_time - prev_frame.timestamp) / span
# Perform linear and spherical linear interpolation
var interpolated = TrackingFrame.new()
interpolated.timestamp = target_time
interpolated.head_pos = prev_frame.head_pos.lerp(next_frame.head_pos, factor)
interpolated.head_rot = prev_frame.head_rot.slerp(next_frame.head_rot, factor)
interpolated.left_hand_pos = prev_frame.left_hand_pos.lerp(next_frame.left_hand_pos, factor)
interpolated.left_hand_rot = prev_frame.left_hand_rot.slerp(next_frame.left_hand_rot, factor)
interpolated.right_hand_pos = prev_frame.right_hand_pos.lerp(next_frame.right_hand_pos, factor)
interpolated.right_hand_rot = prev_frame.right_hand_rot.slerp(next_frame.right_hand_rot, factor)
return interpolated
Ce système garantit que même si vous subissez des drops de frames ou des variations de latence réseau, vous pouvez reconstruire fidèlement les transforms passés. Les clients distants peuvent interpoler de manière fluide les états de tracking historiques, assurant ainsi des gestes de la main et des interactions VR d'une fluidité remarquable.
Implémenter le Time-Rewind en Multiplayer : le cauchemar du développement manuel
Si vous choisissez de développer l'intégralité de ce système à la main, vous ferez face à un overhead d'infrastructure colossal. Vous devrez mettre en place des room orchestrators, héberger des instances personnalisées de serveurs authoritatifs dans des environnements Linux, et configurer des sockets UDP bruts via ENetMultiplayerPeer. Pour éviter les déconnexions de joueurs sur les réseaux résidentiels, vous devrez également concevoir des serveurs STUN/TURN personnalisés pour contourner les pare-feu Carrier-Grade NAT (CGNAT).
De plus, maintenir une base de données d'état globale persistante capable de traiter des milliers d'enregistrements haute fréquence par seconde nécessite des configurations de clustering spécialisées. Vous passerez facilement 4 à 6 semaines d'ingénierie pure uniquement à écrire l'infrastructure Backend plutôt qu'à concevoir votre jeu.
Développer cela vous-même exige de configurer des load balancers, du sharding de base de données et la gestion des certificats SSL — soit facilement 4 à 6 semaines de travail. Avec horizOn, ces services Backend sont préconfigurés, vous permettant de livrer votre jeu plutôt que votre infrastructure.
Simplifier le Godot XR Multiplayer avec horizOn
Au lieu de gaspiller des cycles de développement sur du réseau bas niveau complexe et le provisionnement de serveurs, vous pouvez intégrer horizOn directement dans votre projet. Le moteur de rooms temps réel de la plateforme fournit des pipelines WebRTC optimisés et UDP à faible latence, conçus explicitement pour la sérialisation des données VR à haute fréquence.
En tirant parti des structures de rooms préconfigurées de horizOn, votre jeu peut automatiquement associer les utilisateurs en fonction de métriques de latence régionales, maintenant ainsi les synchronisations spatiales optimales. Comme l'ensemble du routage Backend, les jetons de sécurité et le Matchmaking des joueurs sont gérés via un simple SDK, vous pouvez déployer une expérience VR multi-régions et scalable en quelques minutes.
4 bonnes pratiques de Netcode pour le Godot XR Multiplayer
Appliquer des conventions de Netcode rigoureuses garantit le bon fonctionnement de votre jeu, quelle que soit la qualité réseau des différents clients. Intégrez ces quatre techniques à votre prochaine architecture spatiale :
- Découplez le tick rate réseau du frame rate physique : Ne bloquez jamais le tick rate de réplication réseau directement sur le taux de rafraîchissement du HMD du client. Exécutez votre logique de jeu et la synchronisation des transforms à un rythme stable de 30Hz ou 45Hz, et utilisez l'interpolation linéaire et l'interpolation linéaire sphérique côté client pour reconstruire localement les frames natives à 90Hz/120Hz.
- Priorisez les mises à jour du tracking HMD par rapport aux contrôleurs : Le tracking de la tête est le principal facteur du motion sickness lié aux simulateurs VR. Allouez une priorité réseau plus élevée et des paquets de bande passante plus importants au transform de la caméra de l'utilisateur, tout en appliquant une delta-compression plus agressive et des limites de priorité inférieures aux transforms des mains.
- Imposez des seuils de distance et d'orientation : Ne diffusez aucun paquet si les changements spatiaux d'un joueur sont négligeables. Définissez des zones de garde (guard bands) de delta-compression explicites (par exemple, 5 mm de déplacement de position ou 0,02 radian de rotation) pour éliminer les mises à jour inutiles et préserver la bande passante.
- Utilisez l'autorité côté client pour les saisies physiques (physical grabs) : Lorsqu'un joueur saisit un objet interactif, basculez instantanément l'autorité réseau du nœud vers le client qui effectue la saisie. Cela évite que le décalage local ne crée une séparation visible entre son contrôleur et l'objet tenu, tout en permettant au serveur de contrôler de manière asynchrone l'emplacement final de l'objet.
Prochaines étapes : lancez votre expérience XR Multiplayer
Développer des environnements Multiplayer haute fréquence en VR exige un équilibre subtil entre stabilité visuelle et gestion stricte de la bande passante. En intégrant de la delta-compression, un buffering historique local et de l'autorité côté client, vous pouvez concevoir des environnements immersifs qui maintiennent la stabilité et la connexion de vos joueurs.
Prêt à faire passer votre Backend multijoueur à l'échelle ? Essayez horizOn gratuitement ou consultez la documentation de l'API.
Source : Godot XR Community Game Jam V