Compute: Portierung des legendären Videospiels DOOM

Aufgrund seiner zugänglichen Code-Base und klaren Abstraktionen ist DOOM von id Software inzwischen eines der am häufigsten portierten Spiele überhaupt. Das Spiel erschien mir einfach als das perfekte Projekt für eine Portierung auf unsere Serverless-Compute-Umgebung Compute, um dort mit verschiedenen Anwendungsfällen unseres Produkts zu experimentieren. 

Zu zeigen, dass DOOM interaktiv auf Compute laufen kann, war für uns eine der Möglichkeiten, um die Performancegrenzen unseres Produkts zu erweitern und eine konkrete Demoversion zur Verfügung zu stellen – einen Meilenstein bei der Veranschaulichung der spannenden Möglichkeiten von Compute. Sehen wir uns einmal an, wie wir das gemacht haben.

Die Geschichte von DOOM im Überblick

DOOM wurde 1993 von id Software entwickelt und im Dezember desselben Jahres veröffentlicht. Bis dahin hatte id Software seine Umsätze mit der Entwicklung hochwertiger 2D-Spiele bestritten. 1992 mit Wolfenstein und ein Jahr später mit DOOM gelang dem Unternehmen aber ein Quantensprung hin zur Entwicklung von 3D-Spielen, wobei es die schnellen Entwicklungen im Bereich der PC-Hardware nutzte, um die Branche zu revolutionieren.

1997 wurde DOOM zum Open-Source-Programm. In seiner README-Datei standen die Worte: „Geeignet zum Portieren auf Ihr bevorzugtes Betriebssystem.“ Genau das haben viele seiner Fans getan: DOOM wurde auf Hunderte von bekannten sowie auf weniger bekannte Plattformen portiert. Ich hatte mir vorgenommen, das Potenzial von Compute sowohl als Fan von DOOM als auch in meiner Funktion Fastly Mitarbeiter zu testen. So kam es, dass ich das legendäre Videospiel zu Compute brachte.

An dieser Stelle eine kurze Anmerkung: Das „Game Engine Black Book” von Fabien Sanglard ist eine fantastische Informationsquelle, auf die während dieses Projekts häufig zurückgegriffen wurde. Bei diesem und einem anderen Buch desselben Autors über Wolfenstein handelt es sich um gut recherchierte, unterhaltsame und zugleich lehrreiche Ausflüge in Schlüsselmomente der Geschichte der Game-Entwicklung.

Portierung

Meine Strategie für die Portierung von DOOM bestand aus zwei Schritten:

  1. Kompilieren und Ausführen des Codes, der nicht von bestimmten Syscalls oder Software Development Kits [SDK] der Architektur oder Plattform abhängig ist. Das ist der Großteil dessen, was die meisten als „Gameplay“ bezeichnen würden.

  2. Ersetzen plattformspezifischer API-Calls, soweit dies für die Zielplattform erforderlich ist. Dabei handelt es sich um Code, der sich hauptsächlich mit Inputs und Outputs, einschließlich Rendering und Audio, befasst.

Es gibt keine offizielle öffentliche Schnittstelle für C-Bindings, daher müssen Sie, um es zu Hause zu versuchen, die C-APIs vom Fastly System ableiten.

Allgemeiner Code

DOOM ohne Rendering oder Audio auf Compute zum Laufen zu bringen, war ziemlich einfach. Die Code-Base hat Präfixe für jeden Funktionsnamen und verwendet praktischerweise „I_“ für alle implementierungsspezifischen Funktionen, sodass es ganz leicht war, die Code-Base zu durchsuchen und diese Präfixe beim Kompilieren zu entfernen. Anschließend verwendete ich wasi-sdk, um eine Binärdatei in Wasm zu erstellen. WebAssembly ist darauf ausgelegt, nativen Code ohne viel Aufhebens zu kompilieren. Daher war diese Änderung relativ einfach. 

Die Korrekturen, die ich vornehmen musste, um das Spiel als WebAssembly-Binärdatei zum Laufen zu bringen, hingen damit zusammen, dass DOOM in einer Zeit entwickelt wurde, zu der es nur 32-Bit-Computer gab. Es gibt einige Stellen, an denen der Code davon ausgeht, dass Pointer 4 Byte groß sind, was zu jener Zeit eine absolut vernünftige Entscheidung war. Bei DOOM werden die Daten aus einer Datei geladen, die alle Assets enthält, die vom Entwicklerteam erstellt und zum Zeitpunkt der Veröffentlichung gebündelt wurden. Diese Daten werden direkt in den Speicher geladen und in die spielinterne C-Struktur gecastet, in der sie dargestellt werden. Wenn in diesen Strukturen Pointer enthalten sind, würde das Laden der Daten in einer 64-Bit-Umgebung zu einem unzureichenden Daten-Overlay innerhalb der Struktur und somit zu unerwartetem Verhalten führen. Diese Strukturen waren recht einfach aufzuspüren und führten anfangs zu ziemlich offensichtlichen Abstürzen.

Änderungen am Game Loop

Um den allgemeinen Code auf Compute zum Laufen zu bringen, musste ich den ursprünglich von DOOM verwendeten Game Loop umstrukturieren. Ein typisches Spiel wird initialisiert und läuft dann in einer Endlosschleife, die in der gewünschten Frequenz immer wieder einen Input->Simulation->Output-Tick ausführt, Inputs von den lokalen Eingabegeräten wie Tastatur, Maus oder Controller entgegennimmt und Video und Audio ausgibt. Auf Compute wird ein solcher Prozess jedoch von der Plattform beendet – mit der Absicht, die Instanz zu starten, einige Aufgaben zu erledigen und dann zum Ursprung des Aufrufs zurückzukehren. Also habe ich den Loop komplett entfernt und die Instanz so verändert, dass nur ein einziger Frame des Spiels ausgeführt wird. 

In einer Schleife ausgeführt, sah das Gesamtergebnis in etwa so aus:

Screen Shot 2021-03-31 at 3.33.38 PM

In den folgenden Abschnitten werde ich auf jeden dieser Schritte näher eingehen.

Output

Bei Videospielen wird der Speicher mit dem endgültigen Bild, das dem Spieler angezeigt wird, als Framebuffer bezeichnet. Bei modernen Spielen basiert der Framebuffer häufig auf spezialisierter GPU-Hardware, da das endgültige Bild oft das Ergebnis der Ausführung einer Reihe von Pipeline-Schritten auf dem Grafikprozessor ist. Seit 1993 erfolgt das Rendering in der Software. Bei DOOM konnten Programmierer auf den endgültigen Puffer über ein einfaches C-Array zugreifen. Mit diesem Design gelang die Portierung von DOOM auf neue Plattformen relativ reibungslos, da es den Entwicklern einen einfachen, nachvollziehbaren Ausgangspunkt für ihre Arbeit bot.

Auf Compute wollte ich den Framebuffer zur Darstellung an den Browser des Spielers zurückgeben. Dank der C-API musste ich dafür einfach nur den Framebuffer in einen Response Body schreiben und diesen Body näher zum Endnutzer bringen:

// gets a pointer to the framebuffer
byte* framebuffer = GetFramebuffer(&framebuffer_size);
BodyWrite(bodyhandle, framebuffer, framebuffer_size,...);
SendDownStream(handle, bodyhandle, 0);

Wenn der Client des Browsers die HTTP-Antwort von Compute empfängt, parst er den Framebuffer und rendert ihn im Browser.

Statusinformationen

Die Replikation des Game-Loops in diesem neuen Modell erfordert, dass wir den Spielstand irgendwo abspeichern, damit wir der neuen Instanz mitteilen können, an welcher Stelle wir im Spiel waren, wenn wir einen Call für nachfolgende Frames über Compute ausführen. Dabei konnte ich die bereits im Spiel vorhandene Save-Load-Funktion nutzen, mit der der Spieler ursprünglich den Spielstand auf der Festplatte speichern und das Spiel später neu laden und dort weitermachen konnte, wo er aufgehört hatte. 

Ich habe für den Spielstand den gleichen Mechanismus wie für den Framebuffer verwendet: Am Ende des Game Frames habe ich das Speichersystem aufgerufen und einen Buffer für den Spielstand erhalten. Und als ich die HTTP-Antwort an den Caller zurückgab, habe ich ihn huckepack auf den Framebuffer gelegt.

// gets a pointer to the framebuffer
byte* resp = GetFramebuffer(&framebuffer_size);
// gets the gamestate, appends it to the framebuffer
resp+fb_size = GetGameState(&state_size);
BodyWrite(bodyhandle, framebuffer, framebuffer_size + state_size,...);
SendDownStream(handle, bodyhandle, 0);

Zusätzlich zu dieser Änderung wurde der Client modifiziert, um den Framebuffer vom Spielstand zu trennen und den Spielstand lokal zu speichern, während der Inhalt des Framebuffers im Browser angezeigt wird. Wenn er das nächste Mal eine Anfrage an Compute sendet, wird der Spielstand im Request Body übergeben und kann dort von der Compute Instanz gelesen und wie folgt an das Spiel übertragen werden:

BodyRead(bodyhandle, buffer,...);
LoadGameFromBuffer(buffer);

Wenn wir unseren Game Frame an diesem Punkt ausführen, sieht es so aus, als wäre das kurz nach dem Speichern des Spielstands erfolgt.

Input

Als nächstes brauchen wir Nutzereingaben, damit der Spieler das Spiel auch tatsächlich spielen kann! Das Eingabesystem von DOOM ist über das Konzept der Input Events abstrahiert. Beispiel: „Der Spieler hat die Taste ‚W‘ gedrückt.“ Oder: „Der Spieler hat die Maus x-mal bewegt.“ Wir können Input Events im Browser generieren, die mit Hilfe von Standard Event Listenern in Javascript ziemlich einfach so zugeordnet werden können, wie DOOM es erwartet:

document.addEventListener(‘keydown’, (event) => {
// save event.keyCode in a form we can send later
});

Bei der HTTP-Anfrage sende ich diese Input Events zusammen mit dem Spielstand an Compute. Die Instanz parst sie dann in ein Formular, das wir vor dem Ausführen des Frames an die Game Engine übergeben können.

Optimierungsmöglichkeiten

Die erste funktionierende Version dieser Demo lief mit einer Round-Trip-Geschwindigkeit von ca. 200 ms. Dies ist für ein interaktives Spiel natürlich inakzeptabel. Typische Spiele laufen mit 33 ms, was 30 fps entspricht, oder mit 16 ms, was 60 fps entspricht. Angesichts der Tatsache, dass Latenzen einen nicht unerheblichen Teil unserer Update-Frequenz ausmachen, beschloss ich, dass 50 ms ein erstrebenswertes Ziel ist, das einer 4-fachen Verbesserung gegenüber der Basisversion entspricht.

Einige der von mir implementierten Optimierungen konzentrierten sich auf den Wechsel von einem kontinuierlichen Game Loop zu einem Single Frame. Viele Spielsysteme basieren auf der Vorstellung, dass jeder Tick ein Delta des vorherigen Frames ist. Das Spiel speichert Zustände, die nicht im Spielstand erfasst werden, die aber in jedem Frame als Entscheidungsgrundlage verwendet werden. Einige dieser Systeme mussten aus Performancegründen überarbeitet werden, und auch um sicherzustellen, dass die Funktionen richtig laufen. Außerdem funktionieren viele dieser Systeme am besten, wenn sie so arbeiteten, als wären sie nicht der erste Frame, bei dem eine Menge Variablen und Zustände initiiert wurden. 

Das Spiel führte beim Start eine Reihe von Vorberechnungen durch, vor allem trigonometrische Berechnungen für die Konvertierung von View Space zu World Space. Für diese vorberechneten Tabellen muss man die Bildschirmauflösung des Spiels kennen, weshalb sie in der Laufzeitumgebung durchgeführt werden. Für meine Zwecke behielt ich die Rendering-Auflösung bei und musste die Tabellen einfach nur in die kompilierte Binärdatei einbetten und die Startup-Berechnung bei jedem Frame unterdrücken.

Es ist mir gelungen, das Spiel mit 50–75 ms pro Tick zum Laufen zu bringen. Man könnte noch einiges tun, um sich dem anzunähern, wofür DOOM ausgelegt ist, aber zumindest können wir so schon beweisen, dass wir ein Programm wie dieses auf Compute iterieren können.

Fazit

Dies war mein erster Streifzug durch Compute, und ich war mir nicht sicher, was in Bezug auf Fehlersuche und Iteration auf mich zukommen würde. Die Plattform wird kontinuierlich verbessert, und in den drei Wochen, in denen ich daran gearbeitet habe, habe ich gesehen, wie die Verbesserungen Gestalt annahmen, die Bereitstellung zuverlässiger wurde und die Möglichkeiten zum Debugging wuchsen. Als besonders nützlich empfand ich dabei das Log Tailing, bei dem ich die Prints, die von DOOM erzeugt wurden, in Echtzeit sehen konnte. Für die Iteration auf einem ziemlich undurchsichtigen C-Programm waren diese Prints für das Debugging absolut essenziell – vor allem, bevor ich das Rendering zum Laufen brachte. Insgesamt verlief die Bereitstellung auf Compute ähnlich wie bei einer herkömmlichen Videospielkonsole.

Um es nochmal ganz deutlich zu sagen: Dies ist nicht unbedingt die ideale Lösung für ein Echtzeitspiel, wo zeitnahe Updates gefragt sind. Es gibt keine wirklichen Vorteile, ein Spiel dieser Art auf diese Weise zu betreiben. Zweck dieses Experiments war es, die Grenzen der Plattform zu erweitern, um eine überzeugende Demo zu erstellen, mit der sich die Möglichkeiten ausloten und aufzeigen lassen, und um Inspiration und Begeisterung für die Plattform zu schaffen. Es gibt sicherlich Anwendungsfälle für Videospiele, die diese Plattform nutzen würden. Deshalb freuen wir uns darauf, auch weiterhin neue Möglichkeiten zu erforschen, um Compute zu einem Produkt zu machen, das auch andere Branchen überzeugt.

Wenn Sie die Demo selbst testen möchten, steht sie auf unserem Developer Hub für Sie bereit. Einfach ausprobieren.

Justin Liew
Senior Software Engineer
Veröffentlicht am

Lesedauer: 7 Min.

Sie möchten sich mit einem Experten austauschen?
Sprechen Sie mit einem Experten
Diesen Beitrag teilen
Justin Liew
Senior Software Engineer

Als Senior Software Engineer bei Fastly beschäftigt sich Justin mit der Kern-Caching-Software, die das Herzstück von Compute@Edge, unserer Serverless-Compute-Umgebung, bildet. Bevor er zu Fastly stieß, war er 18 Jahre in der Spielebranche tätig und arbeitete an Bestsellern wie „FIFA“, „Gears of War“ und „Don't Starve“. Seine Freizeit verbringt er gerne zusammen mit seiner Familie auf Entdeckungstour seiner wunderschönen Heimat British Columbia in Kanada.

Sie möchten loslegen?

Setzen Sie sich mit uns in Verbindung oder erstellen Sie einen Account.