Godot XR Multiplayer: Pod maską spatial sync i netcode z mechaniką cofania czasu
W skrócie
Artykuł szczegółowo analizuje wyzwania związane z latencją przestrzenną w grach VR multiplayer na silniku Godot 4 oraz metody ich rozwiązywania za pomocą zaawansowanego netcode. Opisano techniczne lekcje z Godot XR Jam V, koncentrując się na predykcji, synchronizacji wejścia ciągłego oraz separacji osi czasu dla interakcji fizycznych. Przedstawiono gotowy do wdrożenia skrypt GDScript realizujący buforowanie historii klatek z kompresją różnicową (delta) i interpolacją SLERP. Na koniec porównano nakłady pracy przy samodzielnym tworzeniu infrastruktury z gotowym rozwiązaniem backendowym horizOn.
Tworzenie gry godot xr multiplayer to najprostsza droga do wywołania poważnego motion sickness, jeśli Wasza synchronizacja przestrzenna opóźni się choćby o jedną klatkę. Kiedy gracz porusza głową lub rękami z natywnym odświeżaniem 90Hz, jakikolwiek sieciowy jitter czy opóźniona synchronizacja stanu (late-state synchronization) tworzy uderzający rozdźwięk między ich fizycznym ciałem a wirtualnym światem. Rozwiązanie tej latencji przestrzennej wymaga ominięcia tradycyjnych paradygmatów netcode dla płaskich ekranów i wdrożenia własnych predykcyjnych buforów śledzenia.
Analizując zaawansowane mechaniki czasowe i niestandardowe wzorce netcode, możecie utrzymać kamery klienckie i obiekty fizyki w absolutnej synchronizacji, nie przeciążając przy tym przepustowości serwera.
Pod maską: Ekstremalne wyzwanie spatial sync w VR multiplayer
W typowej grze multiplayer na płaski ekran synchronizuje się pojedynczy collider kapsuły postaci, składający się z wektora pozycji 3D (12 bajtów) i zmiennoprzecinkowej rotacji 1D (4 bajty). Wysyłanie tego 16-bajtowego payloadu przy 30Hz daje w przybliżeniu 480 bajtów surowych danych na sekundę. W kontekście godot xr multiplayer musisz jednocześnie synchronizować trzy oddzielne, śledzone z wysoką częstotliwością komponenty: gogle (HMD) oraz dwa kontrolery ruchu. Każdy śledzony punkt wymaga wektora pozycji 3D (12 bajtów) i kwaterniona orientacji 3D (16 bajtów), co daje łącznie 28 bajtów na punkt.
Synchronizacja wszystkich trzech punktów oznacza 84 bajty na klatkę. Przy standardowym odświeżaniu 90Hz wymaga to 7 560 bajtów (7,56 KB) surowego payloadu na sekundę. Dodaj do tego standardowy overhead pakietu UDP (28 bajtów na nagłówek), a pojedynczy klient zużywa do 10,08 KB/s. W 8-osobowym lobby serwer musi przetworzyć i rozesłać (broadcastować) do 564 KB/s danych o wysokiej częstotliwości, co powoduje ogromne przeciążenia downstreamu i opóźnienia w kolejkach pakietów.
Podczas gdy standardowe gry na płaskich ekranach mogą tolerować niewielkie rozbieżności — jak szczegółowo opisano w naszym poradniku o tym, jak naprawić desynchronizację lokalizacji gracza w Unreal Engine multiplayer — gry VR są całkowicie bezlitosne. Nawet desynchronizacja przestrzenna rzędu 3 klatek może wywołać ostry konflikt przedsionkowy, powodując u graczy fizyczne nudności. Aby z tym walczyć, deweloperzy muszą przejść od sztywnej replikacji server-authoritative do mechanizmów client-side prediction z historycznym uzgadnianiem różnic (historical delta reconciliation).
5 lekcji przestrzennych z projektów zgłoszonych do Godot XR Community Game Jam V
W piątej edycji Godot XR Community Game Jam V wzięło udział 98 uczestników, którzy zgłosili 23 gry oparte na motywie „Rewind”. Projekty te przesunęły granice architektur przestrzennych Godot 4, demonstrując kreatywne zastosowania manipulacji czasem w immersyjnych przestrzeniach. Poniżej przedstawiamy kluczowe lekcje dotyczące synchronizacji przestrzennej i netcode, które wyciągnęliśmy z pięciu najlepszych zgłoszeń tego jamu.
1. Fizyka dotykowa i stany interakcji (Inspirowane Rewind Tower)
Zwycięski projekt turnieju, Rewind Tower, oferuje dotykowy system tower defense, w którym gracze nakręcają mechaniczne jednostki na planszy działającej na monety i cofają je w czasie, aby utrzymać je w walce. W scenariuszu multiplayer synchronizacja fizycznego chwytania (grabs) i analogowych stanów nakręcania sprężyny wprowadza krytyczne wyścigi (race conditions).
Jeśli dwóch graczy spróbuje chwycić tę samą jednostkę jednocześnie, prymitywny system replikacji spowoduje, że obiekt będzie gwałtownie teleportować się między nimi, gdy będą walczyć o autorytet sieciowy (network authority). Aby to rozwiązać, deweloperzy muszą zaimplementować na serwerze deterministyczną kolejkę priorytetową, która tymczasowo wyznacza jednego klienta jako fizyczny autorytet, blokując zewnętrzne sygnały chwytu do momentu zwolnienia obiektu.
2. Ręczne kontrolery analogowe i ciągła synchronizacja wejścia (Inspirowane Chrono Crank)
Zajmujący drugie miejsce Chrono Crank opiera się na ręcznym urządzeniu w stylu steampunk z dużą fizyczną korbą służącą do manipulowania upływem czasu. Replikacja obrotu fizycznej korby wymaga synchronizacji wysokiej precyzji, ciągłej wartości float pomiędzy wszystkimi połączonymi graczami.
Dźwignie i pokrętła analogowe wymagają ciągłych aktualizacji pozycji, a nie tylko binarnych triggerów stanu. Próba synchronizacji tych interaktywnych współrzędnych fizyki za pomocą wysokoczęstotliwościowych wywołań RPC to częsty błąd, który odzwierciedla dokładnie te same problemy, z którymi mierzą się deweloperzy podczas naprawiania problemów z replikacją RPC w multiplayer, które psują stan świata.
Zamiast tego powinieneś synchronizować lokalny kąt obrotu tarczy jako skompresowaną zmienną float, interpolując stan na odbierających klientach za pomocą interpolacji splajnami Hermite'a.
3. Rejestracja przestrzenna i przewijanie (Inspirowane NeuroCorp Training Demo)
W projekcie z trzeciego miejsca, NeuroCorp Training Demo, gracze są teleportowani do immersyjnych nagrań minionych zdarzeń fizycznych, co pozwala im przewijać czas w tył i w przód w celu wykonywania zadań. We wspólnym lobby synchronizacja tych zdarzeń przewijania w czasie (scrubbing) wymaga globalnego bufora stanu.
Gdy gracz przewija nagranie, system musi zsynchronizować znacznik czasu odtwarzania (playback timestamp) na wszystkich klientach. Ponieważ ładowanie całej fizycznej historii pokoju klatka po klatce zużywa ogromne ilości pamięci, warstwa sieciowa musi serializować dane klatek przy użyciu struktur klatek kluczowych (keyframes) opartych na indeksach. Pozwala to klientom na lokalną interpolację między dyskretnymi próbkami pozycji zamiast ciągłego strumieniowania klatek transformacji.
4. Równoległe osie czasu (Inspirowane Last Minute)
Last Minute, które zajęło czwarte miejsce, to gra typu escape room, w której gracze manipulują czasem, aby rozwiązywać zagadki w czasie rzeczywistym. Zarządzanie asynchronicznymi stanami czasu wewnątrz pokoju gry multiplayer to architektoniczny koszmar.
Jeśli gracz A cofnie skrzynkę z zagadkami do jej stanu sprzed 10 sekund, podczas gdy gracz B trzyma aktualnie klucz z tej skrzynki, serwer musi odseparować przestrzenną oś czasu skrzynki od globalnej osi czasu graczy. Aby to osiągnąć, świat gry musi być podzielony na niezależne strefy czasowe (temporal zones), umożliwiając poszczególnym węzłom przestrzennym samodzielne przetwarzanie buforów historii, podczas gdy aktywni gracze pozostają zsynchronizowani z aktualnym systemowym tick rate.
5. Predykcja wielu obiektów i ruch zsynchronizowany z platformą (Inspirowane ScrewTheTime)
ScrewTheTime zdobyło piąte miejsce jako platformówka VR, w której gracz kontroluje zarówno swoją postać, jak i stan czasowy ruchomych platform. Synchronizacja gracza stojącego na ruchomej, cofającej się w czasie platformie w VR wymaga transformacji przestrzeni współrzędnych rodzic-dziecko (parent-child coordinate space transforms).
Jeśli serwer synchronizuje tylko absolutne współrzędne globalne, niewielkie różnice w latencji między ruchem platformy a aktualizacjami HMD gracza spowodują, że gracz będzie zsuwał się z platformy lub trząsł w niekontrolowany sposób. Aby to naprawić, należy przypisać punkt początkowy (spatial origin point) gracza jako dziecko węzła ruchomej platformy po stronie klienta, przeliczając wszystkie lokalne ruchy głowy względem lokalnej bazy platformy przed wysłaniem aktualizacji do serwera.
Deep Dive: Budowanie systemu rejestracji przestrzennej z kompresją delta w Godot 4
Aby stworzyć wysoce responsywny projekt godot xr multiplayer z możliwością cofania czasu, musisz zaimplementować bufor rejestracji przestrzennej kompresujący śledzone pozycje. Poniższa produkcyjna klasa GDScript 4 zapisuje lokalne dane śledzenia (HMD i kontrolery) do bufora kołowego (ring buffer), wykonuje kompresję delta (delta compression) w celu zminimalizowania ruchu sieciowego oraz przeprowadza sferyczną interpolację liniową (SLERP) dla płynnego odtwarzania na zdalnych klientach.
# 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_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
System ten gwarantuje, że nawet przy gubieniu klatek lub wahaniach latencji sieciowej można dokładnie zrekonstruować minione transformacje. Zdalni klienci mogą płynnie interpolować historyczne stany śledzenia, dzięki czemu gesty rąk i interakcje w VR wyglądają niesamowicie płynnie.
Implementowanie cofania czasu w multiplayer: Koszmar ręcznej pracy
Jeśli zdecydujesz się napisać cały ten system ręcznie, czeka Cię ogromny overhead związany z infrastrukturą. Musisz skonfigurować orkiestratorów pokojów (room orchestrators), hostować własne autorytatywne instancje serwerów w środowiskach Linux i skonfigurować surowe gniazda UDP za pomocą ENetMultiplayerPeer. Aby uniknąć rozłączania graczy w sieciach domowych, musisz zbudować własne serwery STUN/TURN do omijania firewalli Carrier-Grade NAT (CGNAT).
Co więcej, utrzymanie trwałej bazy danych stanu globalnego, która potrafi przetwarzać tysiące wpisów o wysokiej częstotliwości na sekundę, wymaga specjalistycznych konfiguracji klastrowych. Spędzisz od 4 do 6 tygodni dedykowanego czasu inżynieryjnego na samo pisanie infrastruktury backend, zamiast projektować grę.
Budowanie tego samemu wymaga konfiguracji load balancerów, shardingu baz danych i zarządzania certyfikatami SSL — to lekko 4-6 tygodni pracy. Z horizOn te usługi backendowe są dostarczane jako wstępnie skonfigurowane, co pozwala Ci wydać grę zamiast walczyć z infrastrukturą.
Uproszczenie Godot XR Multiplayer z horizOn
Zamiast marnować cykle deweloperskie na skomplikowane, niskopoziomowe sieciowanie i aprowizację serwerów, możesz zintegrować horizOn bezpośrednio ze swoim projektem. Silnik czasu rzeczywistego platformy (real-time room engine) zapewnia zoptymalizowane potoki WebRTC i niskolatencyjne UDP, zaprojektowane specjalnie do serializacji danych VR o wysokiej częstotliwości.
Dzięki wykorzystaniu prekonfigurowanych struktur pokojów od horizOn, Twoja gra może automatycznie łączyć użytkowników w pary w oparciu o regionalne wskaźniki opóźnień, utrzymując spójność synchronizacji przestrzennej. Ponieważ całe rutowanie backend, tokeny bezpieczeństwa i matchmaking graczy są zarządzane przez proste SDK, możesz wdrożyć skalowalne, wieloregionowe doświadczenie VR w zaledwie kilka minut.
4 dobre praktyki netcode dla Godot XR Multiplayer
Stosowanie odpowiednich konwencji netcode gwarantuje, że Twoja gra będzie działać bez zarzutu niezależnie od jakości sieci poszczególnych klientów. Wprowadź te cztery techniki do swojego następnego projektu przestrzennego:
- Odseparuj network tick rate od physics frame rate: Nigdy nie blokuj częstotliwości wysyłania pakietów sieciowych (tick rate) bezpośrednio z częstotliwością odświeżania HMD klienta. Uruchamiaj logikę gry i synchronizację transformacji przy stabilnych 30Hz lub 45Hz, a do lokalnej rekonstrukcji natywnych klatek 90Hz/120Hz używaj liniowej i sferycznej interpolacji liniowej (SLERP) po stronie klienta.
- Priorytetyzuj aktualizacje śledzenia HMD ponad kontrolerami: Śledzenie głowy jest głównym czynnikiem wywołującym VR simulator sickness. Przydziel wyższy priorytet sieciowy i większe pasmo pakietów dla transformacji kamery użytkownika, stosując jednocześnie silniejszą kompresję delta i niższe limity priorytetów dla transformacji dłoni.
- Wymuś progi odległości i orientacji (Distance and Orientation Thresholds): Nie wysyłaj pakietów, jeśli zmiany przestrzenne gracza są znikome. Skonfiguruj jawne strefy ochronne kompresji delta (np. 5 mm przesunięcia pozycji lub 0,02 radiana rotacji), aby wyeliminować puste aktualizacje i oszczędzać przepustowość.
- Zastosuj client-side authority przy fizycznym chwytaniu: Kiedy gracz chwyta interaktywny obiekt, natychmiast przełącz network authority węzła na klienta wykonującego chwyt. Zapobiega to powstawaniu widocznego opóźnienia między kontrolerem a trzymanym przedmiotem z powodu lokalnego laga, pozwalając serwerowi na asynchroniczny audyt ostatecznego położenia obiektu.
Następne kroki: Wydaj swoje doświadczenie XR multiplayer
Tworzenie środowisk multiplayer o wysokiej częstotliwości w VR wymaga starannego zbalansowania stabilności wizualnej i ścisłego zarządzania przepustowością. Poprzez wdrożenie kompresji delta, lokalnego buforowania historii oraz client-side authority, możesz budować immersyjne światy, które zapewnią graczom stabilne i płynne połączenie.
Chcesz skalować swój multiplayer backend? Wypróbuj horizOn za darmo lub zapoznaj się z dokumentacją API.
Źródło: Godot XR Community Game Jam V