Godot XR Multiplayer: Hinter den Kulissen von Spatial Sync und Time-Rewind-Netcode
Kurz und knapp
Dieser Artikel analysiert die technischen Hürden bei der Entwicklung von Godot XR Multiplayer-Spielen, insbesondere im Hinblick auf Spatial Sync und die Vermeidung von Motion Sickness. Er untersucht praxiserprobte Konzepte aus dem Godot XR Community Game Jam V, wie kontinuierliche Input-Synchronisation und plattformsynchronisierte Bewegungen. Zudem zeigt er auf, wie ein delta-komprimierter Verlaufs-Puffer in GDScript implementiert wird und wie Plattformen wie horizOn die komplexe Netzwerk-Infrastruktur vereinfachen.
Die Entwicklung eines godot xr multiplayer-Spiels führt auf direktem Weg zu schwerer Motion Sickness, wenn die räumliche Synchronisation auch nur um einen einzigen Frame hinterherhinkt. Wenn ein Spieler seinen Kopf oder seine Hände mit einer nativen Bildwiederholrate von 90 Hz bewegt, führt jeder Netzwerk-Jitter oder jede Late-State-Synchronisation zu einer unangenehmen Diskrepanz zwischen dem physischen Körper und der virtuellen Welt. Die Lösung dieser räumlichen Latenz erfordert die Umgehung traditioneller Flat-Screen-Netcode-Paradigmen und die Implementierung benutzerdefinierter prädiktiver Tracking-Buffer.
Durch die Untersuchung fortgeschrittener zeitlicher Mechanismen und benutzerdefinierter Netcode-Muster können Sie Client-Kameras und Physik-Entities absolut synchron halten, ohne die Bandbreite Ihres Servers zu überlasten.
Unter der Haube: Die extreme Herausforderung von Spatial Sync im VR-Multiplayer
In einem typischen Flat-Screen-Multiplayer-Spiel synchronisiert man einen einzelnen Kapsel-Collider des Charakters, der aus einem 3D-Positionsvektor (12 Bytes) und einem 1D-Rotations-Float (4 Bytes) besteht. Das Senden dieses 16-Byte-Payloads bei 30 Hz führt zu etwa 480 Bytes Rohdaten pro Sekunde. In einem godot xr multiplayer-Kontext müssen Sie jedoch drei separate, hochfrequent getrackte Komponenten gleichzeitig synchronisieren: das Head-Mounted-Display (HMD) und zwei Motion-Controller. Jeder getrackte Punkt benötigt einen 3D-Positionsvektor (12 Bytes) und ein 3D-Orientierungs-Quaternion (16 Bytes), also insgesamt 28 Bytes pro Punkt.
Die Synchronisation aller drei Punkte entspricht 84 Bytes pro Frame. Bei einer standardmäßigen Bildwiederholrate von 90 Hz erfordert dies einen Payload von 7.560 Bytes (7,56 KB) Rohdaten pro Sekunde. Rechnet man den Standard-UDP-Paket-Overhead (28 Bytes pro Header) hinzu, lastet ein einzelner Client bis zu 10,08 KB/s aus. In einer Lobby mit 8 Spielern muss der Server bis zu 564 KB/s an Hochfrequenzdaten verarbeiten und broadcasten, was zu massiven Downstream-Engpässen und Verzögerungen in der Paketwarteschlange führt.
Während Standard-Flat-Screen-Spiele geringfügige Abweichungen tolerieren können – wie in unserem Leitfaden zur Behebung von Desyncs der Spielerposition im Unreal Engine-Multiplayer beschrieben –, verzeihen VR-Spiele überhaupt keine Fehler. Selbst ein räumlicher Desync von nur 3 Frames kann eine akute vestibuläre Diskrepanz auslösen, die bei den Spielern physische Übelkeit verursacht. Um dies zu bekämpfen, müssen Entwickler von einer starren, server-authoritativen Replikation zu Client-Side-Prediction-Verfahren mit historischer Delta-Reconciliation übergehen.
5 räumliche Lektionen aus den Beiträgen des Godot XR Community Game Jam V
Am fünften Godot XR Community Game Jam V nahmen 98 Entwickler teil, die 23 Spiele zum Thema „Rewind“ einreichten. Diese Projekte erweiterten die Grenzen der räumlichen Architekturen von Godot 4 und demonstrierten kreative Anwendungen der zeitlichen Manipulation in immersiven Räumen. Nachfolgend sind die wichtigsten Lektionen zur räumlichen Synchronisation und zum Netcode aufgeführt, die wir aus den fünf besten Beiträgen des Jams gezogen haben.
1. Haptische Physik und Interaktionszustände (inspiriert von Rewind Tower)
Der Turniersieger, Rewind Tower, bietet ein haptisches Tower-Defense-System, bei dem Spieler mechanische Einheiten auf einem münzbetriebenen Spielbrett aufziehen und zurückspulen, um sie im Kampf zu halten. In einem Multiplayer-Szenario führt die Synchronisation von physischen Grabs und analogen Federaufzugszuständen zu kritischen Race Conditions.
Wenn zwei Spieler versuchen, dieselbe Einheit gleichzeitig zu greifen, führt ein naives Replikationssystem dazu, dass das Objekt schnell zwischen den beiden Spielern hin- und herteleportiert, während sie um die Netzwerk-Autorität kämpfen. Um dies zu lösen, müssen Entwickler eine deterministische Priority Queue auf dem Server implementieren, die vorübergehend einen Client als physische Autorität festlegt und externe Grab-Inputs sperrt, bis das Objekt losgelassen wird.
2. Analoge Handsteuerungen und kontinuierliche Input-Synchronisation (inspiriert von Chrono Crank)
Der zweitplatzierte Beitrag Chrono Crank basiert auf einem Handgerät im Steampunk-Stil mit einer großen physischen Kurbel, mit der der Fluss der Zeit manipuliert wird. Die Replikation der physischen Kurbeldrehung erfordert die Synchronisation eines hochpräzisen, kontinuierlichen Float-Werts über alle verbundenen Spieler hinweg.
Analoge Hebel und Knöpfe erfordern kontinuierliche Positions-Updates anstelle von binären Status-Triggern. Der Versuch, diese interaktiven Physik-Koordinaten mithilfe von hochfrequenten RPCs zu synchronisieren, ist eine typische Falle, die genau den Problemen entspricht, mit denen Entwickler konfrontiert sind, wenn sie RPC-Replikationsprobleme im Multiplayer beheben, die Weltzustände zerstören.
Stattdessen sollten Sie den lokalen Rotationswinkel des Reglers als komprimierte Float-Variable synchronisieren und den Zustand auf den empfangenden Clients mittels Hermite-Spline-Interpolation interpolieren.
3. Räumliche Aufzeichnung und Scrubbing (inspiriert von NeuroCorp Training Demo)
Im drittplatzierten Projekt NeuroCorp Training Demo werden Spieler in immersive Aufzeichnungen vergangener physischer Ereignisse teleportiert, was es ihnen ermöglicht, in der Zeit vor- und zurückzuspulen (Scrubbing), um Aufgaben zu lösen. In einer gemeinsamen Lobby erfordert die Synchronisation dieser zeitlichen Scrub-Ereignisse einen globalen State Buffer.
Wenn ein Spieler die Aufzeichnung scrubbt, muss das System den Wiedergabe-Zeitstempel auf allen Clients synchronisieren. Da das Laden des gesamten physischen Verlaufs eines Raums Frame für Frame erheblichen Speicherplatz verbraucht, muss der Network Layer die Frame-Daten unter Verwendung indexbasierter Keyframe-Strukturen serialisieren. Dies ermöglicht es den Clients, lokal zwischen diskreten Positions-Samples zu interpolieren, anstatt kontinuierliche Transform-Frames zu streamen.
4. Parallele räumliche Zeitlinien (inspiriert von Last Minute)
Last Minute, das den vierten Platz belegte, ist ein Escape-Room-Spiel, bei dem Spieler die Zeit manipulieren, um Rätsel in Echtzeit zu lösen. Das Verwalten asynchroner Zeitzustände in einem Multiplayer-Spielraum ist ein architektonischer Albtraum.
Wenn Spieler A eine Rätselkiste auf ihren Zustand vor 10 Sekunden zurücksetzt, während Spieler B gerade einen Schlüssel aus dieser Kiste in den Händen hält, muss der Server die räumliche Zeitlinie der Kiste von der globalen Spieler-Zeitlinie entkoppeln. Um dies zu erreichen, muss die Spielwelt in unabhängige zeitliche Zonen unterteilt werden. Dadurch können einzelne räumliche Nodes Verlaufs-Puffer (History Buffer) unabhängig voneinander verarbeiten, während aktive Spieler an die aktuelle System-Tick-Rate gebunden bleiben.
5. Multi-Entity-Prediction und plattformsynchronisierte Bewegung (inspiriert von ScrewTheTime)
ScrewTheTime sicherte sich den fünften Platz als VR-Platformer, bei dem der Spieler sowohl seinen Charakter als auch den zeitlichen Zustand von sich bewegenden Plattformen steuert. Die Synchronisation eines Spielers, der in VR auf einer sich bewegenden, zurückspulenden Plattform steht, erfordert Parent-Child-Koordinatenraum-Transformationen.
Wenn der Server nur absolute globale Koordinaten synchronisiert, führen die winzigen Latenzunterschiede zwischen der Bewegung der Plattform und den HMD-Updates des Spielers dazu, dass der Spieler von der Plattform rutscht oder unkontrolliert zittert. Um dies zu beheben, machen Sie den räumlichen Ursprungspunkt des Spielers auf der Client-Seite zum Child des sich bewegenden Plattform-Nodes. Rechnen Sie alle lokalen Kopfbewegungen relativ zur lokalen Basis der Plattform um, bevor Sie Updates an den Server broadcasten.
Deep Dive: Entwicklung eines delta-komprimierten räumlichen Aufzeichnungssystems in Godot 4
Um eine extrem reaktionsschnelle godot xr multiplayer-Erfahrung mit Rewind-Funktionen zu erstellen, müssen Sie einen räumlichen Aufzeichnungs-Puffer implementieren, der getrackte Positionen komprimiert. Die folgende produktionsbereite GDScript 4-Klasse zeichnet lokale Tracking-Daten (HMD und Controller) in einen Ring-Buffer auf, führt eine Delta-Kompression durch, um den Netzwerkverkehr zu minimieren, und führt eine sphärische lineare Interpolation (SLERP) für eine flüssige Wiedabe auf Remote-Clients aus.
# 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
Dieses System stellt sicher, dass Sie vergangene Transformationen auch dann genau rekonstruieren können, wenn Frames verloren gehen oder Schwankungen in der Netzwerklatenz auftreten. Remote-Clients können historische Tracking-Zustände nahtlos interpolieren, sodass VR-Handgesten und -Interaktionen unglaublich flüssig wirken.
Implementierung von Time-Rewind im Multiplayer: Der manuelle Albtraum
Wenn Sie sich dafür entscheiden, dieses gesamte System von Hand zu schreiben, stehen Sie vor einem erheblichen Infrastruktur-Overhead. Sie müssen Room-Orchestratoren einrichten, benutzerdefinierte authoritative Server-Instanzen in Linux-Umgebungen hosten und rohe UDP-Sockets über ENetMultiplayerPeer konfigurieren. Um Verbindungsabbrüche von Spielern in Heimnetzwerken zu vermeiden, müssen Sie zudem eigene STUN/TURN-Traversal-Server aufbauen, um Carrier-Grade NAT (CGNAT)-Firewalls zu umgehen.
Darüber hinaus erfordert die Pflege einer persistenten globalen Zustandsdatenbank, die Tausende von Hochfrequenzdatensätzen pro Sekunde verarbeiten kann, spezielle Clustering-Konfigurationen. Sie werden 4 bis 6 Wochen reine Entwicklungszeit aufwenden, nur um die Backend-Infrastruktur zu schreiben, anstatt Ihr Spiel zu designen.
Dies selbst zu bauen erfordert das Einrichten von Load Balancern, Database Sharding und SSL-Zertifikatsmanagement – problemlos 4 bis 6 Wochen Arbeit. Mit horizOn sind diese Backend-Dienste vorkonfiguriert, sodass Sie Ihr Spiel ausliefern können, anstatt Ihre Infrastruktur zu verwalten.
Vereinfachung von Godot XR Multiplayer mit horizOn
Anstatt Entwicklungszyklen für komplexe Low-Level-Netzwerkprogrammierung und Server-Provisionierung zu verschwenden, können Sie horizOn direkt in Ihr Projekt integrieren. Die Echtzeit-Room-Engine der Plattform bietet optimierte WebRTC- und latenzarme UDP-Pipelines, die explizit für die hochfrequente VR-Datenserialisierung entwickelt wurden.
Durch die Nutzung der vorkonfigurierten Room-Strukturen von horizOn kann Ihr Spiel Benutzer automatisch basierend auf regionalen Latenzkennzahlen paaren, wodurch die räumliche Synchronisation präzise bleibt. Da das gesamte Backend-Routing, die Sicherheitstoken und das Matchmaking über ein einfaches SDK verwaltet werden, können Sie in wenigen Minuten ein skalierbares, multiregionales VR-Erlebnis bereitstellen.
4 Netcode Best Practices für Godot XR Multiplayer
Die Anwendung der richtigen Netcode-Konventionen stellt sicher, dass Ihr Spiel unabhängig von der Netzwerkqualität des einzelnen Clients sauber läuft. Integrieren Sie diese vier Techniken in Ihr nächstes räumliches Layout:
- Netzwerk-Tick-Rate von der Physik-Frame-Rate entkoppeln: Koppeln Sie Ihre Netzwerk-Replikation-Tick-Rate niemals direkt an die HMD-Bildwiederholrate des Clients. Führen Sie Ihre Spiellogik und Transform-Synchronisation mit stabilen 30 Hz oder 45 Hz aus und verwenden Sie clientseitige lineare und sphärische lineare Interpolation, um die nativen 90-Hz-/120-Hz-Frames lokal zu rekonstruieren.
- HMD-Tracking-Updates gegenüber Controllern priorisieren: Head-Tracking ist die Hauptursache für VR-Simulator-Sickness. Weisen Sie dem Kamera-Transform des Benutzers eine höhere Netzwerkpriorität und größere Paketbandbreiten-Anteile zu, während Sie eine stärkere Delta-Kompression und niedrigere Prioritätsgrenzen auf Hand-Transforms anwenden.
- Abstands- und Ausrichtungsschwellenwerte erzwingen: Senden Sie keine Pakete, wenn die räumlichen Änderungen eines Spielers vernachlässigbar sind. Richten Sie explizite Delta-Kompressions-Schwellenwerte (z. B. 5 mm Positionsänderung oder 0,02 Radiant Rotation) ein, um unnötige Updates zu vermeiden und Bandbreite zu sparen.
- Clientseitige Autorität für physische Grabs nutzen: Wenn ein Spieler ein interaktives Objekt greift, übertragen Sie die Netzwerk-Autorität des Nodes sofort auf den greifenden Client. Dies verhindert, dass lokale Latenzen eine sichtbare Trennung zwischen dem Controller und dem gehaltenen Objekt erzeugen, während der Server die endgültige Objektplatzierung asynchron überprüfen kann.
Nächste Schritte: Bringen Sie Ihre XR-Multiplayer-Erfahrung an den Start
Die Entwicklung von Hochfrequenz-Multiplayer-Umgebungen in VR erfordert eine sorgfältige Balance zwischen visueller Stabilität und striktem Bandbreitenmanagement. Durch die Integration von Delta-Kompression, lokalem historischem Buffering und Client-Side-Authority können Sie immersive Welten erschaffen, die Spieler stabil und latenzfrei verbinden.
Sind Sie bereit, Ihr Multiplayer-Backend zu skalieren? Testen Sie horizOn kostenlos oder werfen Sie einen Blick in die API-Dokumentation.
Quelle: Godot XR Community Game Jam V