Fallstudie: SONAR, HTML5-Spieleentwicklung

Middleditch
Sean Middleditch

Einleitung

Letzten Sommer habe ich als Technical Lead an einem kommerziellen WebGL-Spiel namens SONAR gearbeitet. Das Projekt dauerte etwa drei Monate und wurde komplett neu in JavaScript erstellt. Während der Entwicklung von SONAR mussten wir innovative Lösungen für eine Reihe von Problemen in neuen und noch nicht erprobten HTML5-Umgebungen finden. Insbesondere brauchten wir eine Lösung für ein scheinbar einfaches Problem: Wie laden wir mehr als 70 MB an Spieldaten herunter und speichern sie im Cache, wenn der Spieler das Spiel startet?

Andere Plattformen haben vordefinierte Lösungen für dieses Problem. Die meisten Konsolen und PC-Spiele laden Ressourcen von einer lokalen CD/DVD oder von einer Festplatte. Flash kann alle Ressourcen als Teil der SwF-Datei bündeln, die das Spiel enthält, und Java kann dies auch mit JAR-Dateien tun. Digitale Vertriebsplattformen wie Steam oder der App Store sorgen dafür, dass alle Ressourcen heruntergeladen und installiert werden, bevor der Spieler das Spiel überhaupt starten kann.

HTML5 bietet uns diese Mechanismen nicht, bietet uns aber alle Tools, die wir benötigen, um unser eigenes Downloadsystem für Spieleressourcen zu entwickeln. Der Vorteil eines eigenen Systems ist, dass wir die Kontrolle und Flexibilität erhalten, die wir brauchen, und ein System aufbauen können, das genau unseren Anforderungen entspricht.

Abruf

Bevor wir überhaupt Ressourcen-Caching hatten, hatten wir ein einfaches verkettetes Ressourcenladeprogramm. Dieses System ermöglichte es uns, einzelne Ressourcen anhand des relativen Pfads anzufordern, wodurch wiederum mehr Ressourcen angefordert werden konnten. Unser Ladebildschirm zeigte eine einfache Fortschrittsanzeige, die abschätzte, wie viel mehr Daten geladen werden mussten, und wechselte erst zum nächsten Bildschirm, nachdem die Warteschlange des Ressourcenladeprogramms leer war.

Das Design dieses Systems ermöglichte es uns, einfach zwischen gepackten Ressourcen und lockeren (nicht verpackten) Ressourcen zu wechseln, die über einen lokalen HTTP-Server bereitgestellt werden, was entscheidend dazu beitrug, dass wir sowohl Spielcode als auch Daten schnell iterieren konnten.

Der folgende Code veranschaulicht das grundlegende Design unseres Ladeprogramms für verkettete Ressourcen. Fehlerbehandlung und der erweiterte Code zum Laden von XHR-/Bildern wurde entfernt, um die Lesbarkeit zu verbessern.

function ResourceLoader() {
  this.pending = 0;
  this.baseurl = './';
  this.oncomplete = function() {};
}

ResourceLoader.prototype.request = function(path, callback) {
  var xhr = new XmlHttpRequest();
  xhr.open('GET', this.baseurl + path);
  var self = this;

  xhr.onreadystatechange = function() {
    if (xhr.readyState == 4 && xhr.status == 200) {
      callback(path, xhr.response, self);

      if (--self.pending == 0) {
        self.oncomplete();
      }
    }
  };

  xhr.send();
};

Die Verwendung dieser Oberfläche ist ziemlich einfach, aber auch recht flexibel. Der anfängliche Spielcode kann einige Datendateien anfordern, die das anfängliche Spiellevel und die Spielobjekte beschreiben. Dabei kann es sich zum Beispiel um einfache JSON-Dateien handeln. Der für diese Dateien verwendete Callback prüft dann die Daten und kann zusätzliche Anfragen (verkettete Anfragen) für Abhängigkeiten senden. In der Definitionsdatei für Spielobjekte können Modelle und Materialien aufgeführt sein. Der Callback für Materialien fordert dann möglicherweise Texturbilder an.

Der oncomplete-Callback, der an die ResourceLoader-Hauptinstanz angehängt ist, wird erst aufgerufen, nachdem alle Ressourcen geladen wurden. Der Ladebildschirm des Spiels kann einfach warten, bis dieser Callback aufgerufen wird, bevor der nächste Bildschirm angezeigt wird.

Mit dieser Oberfläche ist natürlich noch viel mehr möglich. Als Übungen für den Leser sind einige zusätzliche Funktionen, die sich näher ansehen sollten, die Unterstützung des Fortschritts/Prozentsatzes, Hinzufügen von Bildern zum Laden von Bildern (mithilfe des Image-Typs), Hinzufügen des automatischen Parsen von JSON-Dateien und natürlich die Fehlerbehandlung.

Die wichtigste Funktion für diesen Artikel ist das Feld „baseurl“, mit dem wir die Quelle der angeforderten Dateien einfach ändern können. Es ist einfach, die Kern-Engine so einzurichten, dass ein ?uselocal-Abfrageparameter in der URL Ressourcen von einer URL anfordern kann, die vom selben lokalen Webserver bereitgestellt wird (z. B. python -m SimpleHTTPServer), der das HTML-Hauptdokument für das Spiel bereitgestellt hat. Wenn der Parameter nicht festgelegt ist, wird das Cache-System verwendet.

Ressourcen für die Paketerstellung

Ein Problem beim verketteten Laden von Ressourcen besteht darin, dass es keine Möglichkeit gibt, eine vollständige Bytezahl aller Daten zu erhalten. Dies hat zur Folge, dass es keine Möglichkeit gibt, ein einfaches, zuverlässiges Dialogfeld für den Fortschritt von Downloads anzuzeigen. Da wir den gesamten Inhalt herunterladen und im Cache speichern werden und das bei größeren Spielen ziemlich lange dauern kann, ist es sehr wichtig, dem Spieler ein schönes Fortschrittsdialogfeld zu geben.

Die einfachste Lösung für dieses Problem (das uns auch einige weitere Vorteile bietet) besteht darin, alle Ressourcendateien in einem einzigen Bundle zu bündeln, das wir mit einem einzigen XHR-Aufruf herunterladen. So erhalten wir die benötigten Fortschrittsereignisse und eine schöne Fortschrittsanzeige.

Ein benutzerdefiniertes Paketdateiformat zu erstellen, ist nicht allzu schwierig und würde sogar einige Probleme lösen. Für das Bundle-Format wäre jedoch ein Tool erforderlich. Eine alternative Lösung besteht darin, ein vorhandenes Archivformat zu verwenden, für das bereits Tools vorhanden sind, und dann einen Decoder zu schreiben, der im Browser ausgeführt wird. Wir benötigen kein komprimiertes Archivformat, da HTTP die Daten bereits mit gzip komprimieren oder Deflate-Algorithmen anwenden kann. Aus diesen Gründen haben wir uns für das TAR-Dateiformat entschieden.

TAR ist ein relativ einfaches Format. Jeder Datensatz (Datei) hat einen Header mit 512 Byte, gefolgt vom Dateiinhalt auf 512 Byte. Der Header enthält nur wenige relevante oder interessante Felder für unsere Zwecke, hauptsächlich den Dateityp und den Namen. Diese werden an festen Positionen im Header gespeichert.

Headerfelder im TAR-Format werden an festen Positionen mit festen Größen im Header-Block gespeichert. Beispielsweise wird der Zeitstempel der letzten Änderung der Datei mit 136 Byte ab dem Anfang des Headers gespeichert und ist 12 Byte lang. Alle numerischen Felder werden als Oktalzahlen codiert und im ASCII-Format gespeichert. Um die Felder zu parsen, extrahieren wir die Felder aus unserem Array-Zwischenspeicher. Bei numerischen Feldern rufen wir parseInt() auf. Dabei muss der zweite Parameter übergeben werden, um die gewünschte Oktalbasis anzugeben.

Eines der wichtigsten Felder ist das Feld „type“. Dies ist eine einstellige Oktalzahl, die angibt, welchen Dateityp der Datensatz enthält. Die einzigen zwei interessanten Datensatztypen für unseren Zweck sind reguläre Dateien ('0') und Verzeichnisse ('5'). Wenn wir es mit beliebigen TAR-Dateien zu tun haben, sind uns möglicherweise auch symbolische Links ('2') und möglicherweise harte Links ('1') wichtig.

Jedem Header folgt unmittelbar der Inhalt der durch den Header beschriebenen Datei, mit Ausnahme von Dateitypen, die keinen eigenen Inhalt haben, wie z. B. Verzeichnisse. Auf den Dateiinhalt folgt dann ein Auffüllen, um sicherzustellen, dass jeder Header mit einer 512-Byte-Grenze beginnt. Um die Gesamtlänge eines Dateidatensatzes in einer TAR-Datei zu berechnen, müssen wir daher zunächst den Header für die Datei lesen. Anschließend fügen wir die Länge des Headers (512 Byte) mit der Länge des aus dem Header extrahierten Dateiinhalts hinzu. Schließlich fügen wir alle erforderlichen Padding-Byte hinzu, um den Offset auf 512 Byte auszurichten. Dies ist ganz einfach, indem Sie die Dateilänge durch 512 teilen, den Höchstwert der Zahl multiplizieren und dann mit 512 multiplizieren.

// Read a string out of an array buffer with a maximum string length of 'len'.
// state is an object containing two fields: the array buffer in 'buffer' and
// the current input index in 'index'.
function readString (state, len) {
  var str = '';

  // We read out the characters one by one from the array buffer view.
  // this actually is a lot faster than it looks, at least on Chrome.
  for (var i = state.index, e = state.index + len; i != e; ++i) {
    var c = state.buffer[i];

    if (c == 0) { // at NUL byte, there's no more string
      break;
    }

    str += String.fromCharCode(c);
  }

  state.index += len;

  return str;
}

// Read the next file header out of a tar file stored in an array buffer.
// state is an object containing two fields: the array buffer in 'buffer' and
// the current input index in 'index'.
function readTarHeader (state) {
  // The offset of the file this header describes is always 512 bytes from
  // the start of the header
  var offset = state.index + 512;

  // The header is made up of several fields at fixed offsets within the
  // 512 byte block allocated for the header.  fields have a fixed length.
  // all numeric fields are stored as octal numbers encoded as ASCII
  // strings.
  var name = readString(state, 100);
  var mode = parseInt(readString(state, 8), 8);
  var uid = parseInt(readString(state, 8), 8);
  var gid = parseInt(readString(state, 8), 8);
  var size = parseInt(readString(state, 12), 8);
  var modified = parseInt(readString(state, 12), 8);
  var crc = parseInt(readString(state, 8), 8);
  var type = parseInt(readString(state, 1), 8);
  var link = readString(state, 100);

  // The header is followed by the file contents, then followed
  // by padding to ensure that the next header is on a 512-byte
  // boundary.  advanced the input state index to the next
  // header.
  state.index = offset + Math.ceil(size / 512) * 512;

  // Return the descriptor with the relevant fields we care about
  return {
    name : name,
    size : size,
    type : type,
    offset : offset
  };
};

Ich habe mich nach vorhandenen TAR-Lesegeräten umgesehen und ein paar gefunden, aber keines, die keine anderen Abhängigkeiten hatte oder problemlos in unsere vorhandene Codebasis passen würde. Aus diesem Grund habe ich mich dafür entschieden, selbst einen zu schreiben. Außerdem habe ich mir die Zeit genommen, das Laden so gut wie möglich zu optimieren und dafür zu sorgen, dass der Decoder sowohl Binär- als auch Stringdaten im Archiv problemlos verarbeiten kann.

Eines der ersten Probleme, die ich lösen musste, war, wie die Daten tatsächlich aus einer XHR-Anfrage geladen werden können. Ich habe ursprünglich mit einem „binary string“-Ansatz begonnen. Leider ist die Umwandlung von binären Strings in nutzerfreundlichere Binärformen wie ArrayBuffer weder einfach noch sind solche Konvertierungen besonders schnell. Das Konvertieren in Image-Objekte ist genauso mühsam.

Ich habe mich dafür entschieden, die TAR-Dateien direkt aus der XHR-Anfrage als ArrayBuffer zu laden und eine kleine praktische Funktion zum Konvertieren von Blöcken aus ArrayBuffer in einen String hinzuzufügen. Derzeit verarbeitet mein Code nur einfache ANSI-/8-Bit-Zeichen. Dieses Problem lässt sich aber beheben, sobald eine praktischere Conversion-API in Browsern verfügbar ist.

Der Code scannt einfach die ArrayBuffer und parst Eintragsheader, die alle relevanten TAR-Headerfelder (und einige weniger relevante Felder) sowie den Speicherort und die Größe der Dateidaten innerhalb der ArrayBuffer enthalten. Mit dem Code können die Daten auch optional als ArrayBuffer-Ansicht extrahiert und in der Liste der zurückgegebenen Eintragsheader gespeichert werden.

Der Code ist unter einer freundlichen, moderaten Open-Source-Lizenz unter https://github.com/subsonicllc/TarReader.js kostenlos verfügbar.

FileSystem API

Zum Speichern der Dateiinhalte und zum späteren Zugriff darauf haben wir die FileSystem API verwendet. Die API ist relativ neu, verfügt jedoch bereits über eine umfassende Dokumentation, darunter auch den ausgezeichneten HTML5 Rocks FileSystem-Artikel.

Die FileSystem API muss bestimmte Einschränkungen beachten. Zum einen ist es eine ereignisgesteuerte Schnittstelle. Dies macht die API nicht blockierend, was sich hervorragend für die Benutzeroberfläche eignet, aber auch die Verwendung erschwert. Die Verwendung der FileSystem API von einem WebWorker kann dieses Problem lösen, aber dazu müsste das gesamte Download- und Entpackungssystem in einen WebWorker aufgeteilt werden. Das mag sogar der beste Ansatz sein, aber aus Zeitgründen habe ich mich nicht für diesen Ansatz entschieden (ich kannte WorkWorkers noch nicht). Deshalb musste ich mich mit der asynchronen, ereignisgesteuerten API befassen.

Unser Hauptaugenmerk liegt dabei auf dem Schreiben von Dateien in einer Verzeichnisstruktur. Dies erfordert eine Reihe von Schritten für jede Datei. Zuerst müssen wir den Dateipfad in eine Liste umwandeln. Dazu teilen wir den Pfadstring am Pfadtrennzeichen (bei URLs immer den Schrägstrich) auf. Dann müssen wir über jedes Element in der resultierenden Liste iterieren und für das letzte speichern und gegebenenfalls rekursiv ein Verzeichnis im lokalen Dateisystem erstellen. Anschließend können wir die Datei erstellen, dann ein FileWriter erstellen und schließlich den Dateiinhalt ausschreiben.

Außerdem sollten Sie die Größenbeschränkung für den PERSISTENT-Speicher der FileSystem API berücksichtigen. Wir wollten einen nichtflüchtigen Speicher, da der temporäre Speicher jederzeit gelöscht werden kann, auch wenn der Nutzer gerade unser Spiel spielt, bevor er versucht, die entfernte Datei zu laden.

Für Apps, die auf den Chrome Web Store ausgerichtet sind, gibt es keine Speicherlimits, wenn die Berechtigung unlimitedStorage in der Manifestdatei der App verwendet wird. Normale Webanwendungen können jedoch über die experimentelle Oberfläche für Kontingentanfragen Speicherplatz anfordern.

function allocateStorage(space_in_bytes, success, error) {
  webkitStorageInfo.requestQuota(
    webkitStorageInfo.PERSISTENT,
    space_in_bytes,
    function() {
      webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);      
    },
    error
  );
}