iteratec Blog

Multiuser für XR-Anwendungen: Infrastruktur und Software (Teil 2)

Geschrieben von Philip Ewert | 09.08.2024 12:07:53

Im ersten Beitrag haben wir uns die Basics von Multiuser-Anwendungen und die häufigsten Missverständnisse angesehen. In Teil 2 erklären wir die notwendige Infrastruktur und SDKs und was sich im Detail im Source-Code ändern muss, damit eine bestehende Anwendung um eine Multiuser-Funktion erweitert werden kann.

 

Der Hintergrund dieser Artikel-Reihe sind zwar Industrie-Anwendungsfälle wie XR-Anwendungen rund um das Thema Schulungs- und Trainings-Software, doch Antworten auf die Fragen lassen sich vor allem in der Videospiel-Entwicklung finden, welche sich schon seit Jahrzehnten mit dem Thema Multiplayer auseinandersetzt. Aus diesem Grund werden in den Beiträgen Begriffe wie Multiplayer, Game-State, Gameplay oder auch Matchmaking verwendet, um die Vorgänge zu erklären. Das soll die Anwendungsfälle nicht auf Videospiele beschränken, sondern nur vermitteln, mit welcher Terminologie sich Lösungen im Internet finden lassen.

 

 

Infrastruktur

Da wir in diesem Fall nur über Netzwerk-Multiplayer sprechen, können wir davon ausgehen, dass es immer zumindest einen Server und zwei Clients gibt, um eine Anwendung im Multiplayer erleben zu können.

Zunächst gehen wir darauf ein, aus welchen Komponenten sich eine Topologie zusammensetzen kann. Diese definiert, was ein Server ist und was ein Client. Wie wir sehen werden, gibt es auch Fälle, in denen die Aussage nicht klar getroffen werden kann. 

  • Transport Layer. Beschreibt die Protokolle und Werkzeuge, mit welchen die Daten versendet und synchronisiert werden.

  • Client Runtime Application. Die Runtime, welche von Clients verwendet wird.

  • Server Runtime Application. Die Runtime, welche vom Server verwendet wird.

  • Backend Services. Standalone Services für Matchmaking, Leaderboards, Friend-Lists, usw.


Player-Hosted P2P


 

 

In einem Peer-to-Peer Netzwerk gibt es keinen Server im klassischen Sinne. Stattdessen agiert einer der Clients gleichzeitig als Server – ein sogenannter Host – mit dem sich andere Clients verbinden.

  • Kostengünstig. Ohne die Notwendigkeit für einen klassischen Server, welcher dediziert gehostet werden müsste, sinken die Kosten für Multiplayer auf ein Minimum.

  • Relay Server. Für die Verbindung via Internet, statt nur dem lokalen Netzwerk, ist ein Relay Server notwendig, welcher die Verbindung zwischen beiden Peers initiiert und verwaltet. 

  • Host Autorität. In Anwendungen und Spielen mit einem kompetitiven Charakter besitzt der Host einen signifikanten Vorteil gegenüber den verbundenen Clients, da er mit keiner Latency rechnen muss – Client und Server laufen auf demselben Gerät. Auch kann nicht verhindert werden, dass der Host den Game-State zu seinen Gunsten manipuliert. Das stellt bei Anwendungen mit einer Priorität auf Datenintegrität ein schwerwiegendes Problem dar.

  • Host-Migration. Verlässt der Host das Spiel, bedeutet das auch einen Verbindungsabbruch für alle anderen Clients. Eine Gegenmaßnahme ist Host-Migration, welche einen anderen Client zum Host ernennt und den gesamten State inklusive der Autoritäten ihm überträgt. Dies kann je nach Anwendungsfall ein komplexer und fehleranfälliger Prozess sein, welcher in jedem Fall die User Experience unterbricht.

  • Host Performance. Das Gerät, welches als Host agiert, hat eine erhöhte Last, da es neben der Server-Runtime auch die Client-Runtime verarbeiten muss. Bei Geräten mit begrenzter Performance kann das zu Problemen führen.


Direct P2P

In einem direkten Peer-to-Peer Netzwerk verbinden sich die Clients direkt miteinander und agieren jeweils auch als Server. Eine Player-Hosted Lösung kommt mir ihren Vor- und Nachteilen. Generell ist jedoch zu sagen, dass direktes P2P aus folgenden Gründen eher zu vernachlässigen ist:

  • Komplexe Synchronisation. Wenn jeder Client auch gleichzeitig ein Server ist, entsteht die Frage, wer die Autorität über die zu synchronisierenden Objekte besitzt. In diesem Fall entsteht die Notwendigkeit für einen besonderen Konsensmechanismus, welcher aus den vielen Angaben einen gültigen Game-State ermittelt und akzeptiert. Solche Mechanismen sind allerdings komplex und fehleranfällig und sollten möglichst vermieden werden.

  • Schwerfällige Skalierung. In einem Direct P2P Netzwerk sind alle Clients mit allen anderen Clients verbunden. Mit jedem neuen Client steigt die Anzahl der Verbindungen signifikant.

Wenn n der Anzahl der Clients entspricht, hat eine Anwendung mit 4 Clients 6 Verbindungen, eine Anwendung mit 8 Clients schon 28 Verbindungen, bei 12 Clients sind es 66 Verbindungen.

Direct P2P wurde in diesem Beitrag nur der Vollständigkeit halber erwähnt. Aufgrund der eingangs erwähnten Probleme unterstützen etablierte Multiplayer-SDKs diese Lösung bewusst nicht.

 

Dedicated Server

Die wohl verbreiteste Lösung für eine robuste Multiplayer-Lösung ist der Dedicated Server. Dabei wird die Server-Runtime auf einem dedizierten Server gehostet, worauf sich einzelne Clients verbinden.

  • Im Gegensatz zu P2P-Netzwerken besitzt keiner der Clients einen Vorteil, wenn eine größere Latenz auftritt. Alle Clients bekommen gleichzeitig State-Changes, unter der Annahme, dass alle eine ähnlich starke Internet-Verbindung haben.

  • Ohne einen Host, hat kein Client die Möglichkeit auf alle Server-Authoritative Objekte zuzugreifen, wodurch Cheating wesentlich erschwert wird.

  • Mit jeder neuen Verbindung kommt nur eine weitere Verbindung zwischen Server und Client zustande. Für Clients ist es hinfällig, wie viele andere Clients verbunden sind. Einzig der Server muss nun bei einem State-Change an einen weiteren Client die Daten senden.

  • Ohne die Notwendigkeit für einen Host, haben Entwickler:innen die volle Kontrolle über die Qualität der Multiplayer-Erfahrung. Sie selbst können bestimmen, wie viel Rechenleistung die Server-Hardware haben soll. Besonders bei Zielgruppen mit eher leistungsschwacher Hardware, kann ein dedizierter Server für Entlastung auf User-Seite sorgen.


Software-Development Kits

State-Management ist komplex und besonders bei Echtzeit-Anwendungen wie XR-Anwendungen und Videospielen ein wichtiges Thema. Für die vielfältigen und meist sehr einzigartigen Anforderungen bedarf es umfassende Werkzeuge, die Entwickler:innen die Arbeit an einem Multiplayer erleichtern. Diese Werkzeuge, bzw. die Code-Base für Multiplayer-Funktionalitäten, wird häufig als Netcode bezeichnet.

 

Integration

In diesem Abschnitt geht es um die Grundfunktionen, welche man von SDKs erwarten sollte. Werden diese nicht geboten, sollte man sich dem Aufwand bewusst werden, diese Funktionen selbst zu implementieren.

  • Game Engine. Wenn für die Entwicklung eine Game Engine verwendet wird, bieten beispielsweise Unity und Unreal eigene SDKs für die Entwicklung von Multiplayer-Features. Eine Game Engine kann aus der Software-Perspektive als ein eigenes, riesiges Framework mit GUI betrachtet werden, welches eine eigene Design-Philosophie verfolgt, wie Anwendungen entwickelt werden sollten. Ein hauseigenes SDK ist dann nichts mehr als ein Plugin zu diesen Frameworks und ist dann besonders attraktiv, wenn sich an der Design-Philosophie der Engine orientiert wird.

  • Third-Party. Darüber hinaus gibt es auch abstraktere SDKs, welche agnostisch zu Game Engines oder Frameworks stehen. Diese bieten höhere Flexibilität aber setzen mehr Entwicklungszeit voraus, um Schnittstellen zwischen SDK und Game Engine zu schaffen. Verwendet man beispielsweise in Unity eine Rigidbody-Komponente, für welche in dem offiziellen Unity-SDK eine vorgefertigte Netzwerk-Lösung existiert, so müssen Entwickler:innen an dieser Stelle eigenen Netcode schreiben.

    Egal welchen Weg man wählt, alle SDKs geben Werkzeuge an die Hand die verschiedenen Hosting- / Topologie-Varianten zu unterstützen und den State über das Netzwerk an die Clients zu kommunizieren. Im nächsten Abschnitt gehen wir auf diese Werkzeuge ein.


Transport Layer

Multiplayer-SDKs bieten eine große Bandbreite an Werkzeugen für die Entwicklung und nehmen Entwickler:innen dabei in vielen Aspekten Arbeit ab – angefangen beim Transport Layer. Ein Transport Layer stellt die Verbindung zwischen den Anwendungen und der Hosts in einem Netzwerk her und sorgt für einen zuverlässigen Austausch.

Der Transport Layer eines SDKs kann folgende Funktionen enthalten:

  • Connection-oriented Kommunikation garantiert eine robuste Verbindung mittels eines Handshake-Protocols.

  • Die Daten-Integrität wird bei fehlerhaften oder verlorenen Paketen durch Retransmission wieder hergestellt.

  • Bei verlorenen Paketen, hoher Netzwerk-Latenz und / oder Ausfall der Hardware kann dafür gesorgt werden, dass die Paket-Reihenfolge wiederhergestellt werden.

  • Der Traffic wird reguliert, um die Netzwerk-Performance nicht unnötig zu belasten.


Tooling

Genutzt wird der Transport Layer von einer Vielzahl an Modulen und Funktionen, welche den Game State an andere Clients oder Server versenden. Es gibt hierbei eine Reihe an Möglichkeiten, um den State zu kommunizieren, welche sich auf die verschiedenen Arten der Synchronisierung zurückführen lassen. Diese wurden bereits in einem früheren Abschnitt behandelt.

Folgend die zwei wichtigsten Werkzeuge, welche jedes Multiplayer-SDK implementieren sollte:

 

Netzwerk-Variablen

Kontinuierliche Synchronisierung, wie beispielsweise der Ball bei der Simulation eines Fußballspiels, müssen so schnell und verlässlich wie möglich mit allen Clients synchronisiert werden. Darum bieten SDKs in der Regel einen Variablentyp, welcher es ermöglicht, Variablen über das Netzwerk synchronisieren zu lassen. Diese werden dann je nach Implementierung bei jeder Veränderung und / oder in einer bestimmten Frequenz synchronisiert. Hier gilt auch wieder festzulegen, wer die Autorität über die Variable hat.


In Unitys hauseigener SDK namens Netcode for Gameobjects würde solch eine Variable wie folgt verwendet werden: 

Die Klasse, welche die Netzwerk-Variable verwenden möchte, muss von NetworkBehaviour erben. Die NetworkBehaviour-Klasse erbt von der allgemein bekannten MonoBehaviour-Klasse und erweitert diese um die Netzwerk-Funktionalitäten mit Methoden wie bool IsOwner() oder int GetNetworkObjectId() und auch Callback-Funktionen wie OnNetworkSpawn(), um auf Netzwerk-Events zu reagieren.


Anschließend lässt sich die Klasse NetworkVariable verwenden, um eine Netzwerk-Variable zu deklarieren.

private NetworkVariable<int> m_SomeValue = new NetworkVariable<int>();


Der Wert der Variable wird allerdings mithilfe eines eigenen Attributs verändern.

m_SomeValue.Value = k_InitialValue;


Um über Veränderungen an der Variable in Kenntnis gesetzt zu werden, nutzt man die dafür vorgesehene Callback-Funktion.

m_SomeValue.OnValueChanged += OnSomeValueChanged;


Versucht ein Client die Variable zu verändern, besitzt aber nicht Autorität darüber, schlägt dieser Funktions-Aufruf fehl. Sieh dir die Dokumentation an, um mehr Informationen über die NetworkVariable-Klasse zu erhalten.

 

Remote Procedure Calls (RPCs)

Bei eventbasierter Synchronisierung sind RPCs außerordentlich nützlich. Diese geben die Möglichkeit bei Server und / oder Clients Funktionen auszuführen. Anhand von RPCs wird auch deutlich, warum es wichtig ist, dass Server- und Client-Runtime dieselbe Code-Basis verwenden. Denn damit ein RPC die richtige Instanz in der Anwendung findet, um dort einen Funktionsaufruf durchzuführen, muss diese Instanz denselben logischen Pfad besitzen, wie die Anwendung, welche den RPC abgeschickt hat.

Im Falle von Netcode for Gameobjects erstellt man einen RPC wie folgt:

Angenommen wir haben ein Partikel-System, welches gleichzeitig auf allen Clients gestartet werden soll. In diesem Fall existiert die ClientRpc-Annotation.

[ClientRpc]

public void PlayParticlesClientRpc() {

                  ParticleSystem.Play();

}

Diese Funktion könnte allerdings nur vom Server bzw. vom Host ausgeführt werden. Da Netcode for Gameobjects es nicht erlaubt, ClientRpc von Clients aufzurufen. Falls wir aber von einem Client aus, den Effekt auslösen wollen ist der Umweg über einen ServerRpc notwendig, welcher dann den ClientRpc ausführt.

[ServerRpc]

public void PlayParticlesServerRpc() {

                  PlayParticlesClientRpc();

}

In diesem Fall würde ein Client den ServerRpc ausführen. Der Server erhält den RPC und führt anschließend auf allen Clients aus und löst damit den ursprünglichen RPC ab. Das mag zunächst umständlich erscheinen, doch neben dem Vorteil, dass so die Autorität über den Funktion-Aufruf beim Server liegt, hat es auch zur Folge, dass die Funktion auf allen Clients gleichzeitig ausgeführt wird. Bei hoher und / oder unterschiedlicher Latenz der Clients kann es dennoch zu unterschiedlichen Erlebnissen kommen.

 

Spezifische Lösungen

Neben den eben erwähnten Lösungen gibt es noch etliche für Game Engines zugeschnittene Lösungen. Nicht jede Game Engine hat ein eigenes Multiplayer-SDK, doch meist gibt es zumindest Schnittstellen.

Auch liefern SDKs unterschiedliche Lösungen für sehr spezifische Probleme, die aber nicht in jedem Projekt auftreten müssen. Ein sehr bekanntes Beispiel ist Dead Reckoning.

Dead Reckoning ist eine Technik im Multiplayer-Game-Development, die verwendet wird, um die Position und Bewegung von Objekten vorherzusagen und zu interpolieren. Außerdem wird so eine reibungslose und verzögerungsfreie Darstellung gewährleistet. Da Latenz häufig auftritt, sendet der Server nicht ständig die genaue Position aller Objekte an alle Clients. Stattdessen berechnen die Clients die Positionen basierend auf den zuletzt empfangenen Daten und den erwarteten Bewegungen. Wenn neue Daten eintreffen, passen sie die Vorhersagen an, um Diskrepanzen zu minimieren und eine möglichst flüssige Darstellung zu gewährleisten. Dies verbessert das Benutzer:innen-Erlebnis, indem es Lag reduziert und die Synchronisation zwischen den Clients verbessert.

 

Jede Anwendung ist einzigartig

In diesem Beitrag haben wir uns im Detail mit Werkzeugen beschäftigt, die Entwickler:innen zur Verfügung stehen, wenn sie eine Multiuser-Anwendung entwickeln möchten. Doch war das lediglich ein kleiner Einblick, um ein grundsätzliches Verständnis aufzubauen. Neben den erwähnten Möglichkeiten gibt es viele weitere, die in ihren Anwendungsgebieten so speziell sind, dass es wenig Sinn hat, sie an dieser Stelle zu erwähnen. Wichtig ist nur zu verstehen, dass diese Werkzeuge nicht alle verwendet werden müssen und sollten. Jede Anwendung ist einzigartig und sollte dementsprechend behandelt werden. Die Auswahl der richtigen Werkzeuge setzt ein tiefes Verständnis für die Struktur der Anwendung voraus und sollte immer mit Bedacht gewählt werden, um spätere Umbauten zu vermeiden.

Im letzten Teil der Reihe gehen wir näher auf die realen Optionen ein, welche Entwickler:innen haben, um mit der Entwicklung eines Multiuser-Features zu starten. Zusätzlich wird ein Fragenkatalog vorgestellt, um leichter Entscheidungen bezüglich Infrastruktur und Software treffen zu können.

 

 

 

Philip Ewert - ist Fullstack-Webentwickler bei iteratec und fördert zudem die Themen 3D-Entwicklung und Grafikprogrammierung im Web.

 

 

 

 

 

Haben Sie Fragen oder benötigen Unterstützung?

Mehr zu den Möglichkeiten von XR-Anwendungen für Ihr Unternehmen finden Sie auf unserer Webseite. Sprechen Sie uns auch gerne an.