Einführung
Letzten Sommer war ich als technischer Leiter an einem kommerziellen WebGL-Spiel namens SONAR beteiligt. Das Projekt dauerte etwa drei Monate und wurde komplett in JavaScript von Grund auf neu erstellt. Bei der Entwicklung von SONAR mussten wir innovative Lösungen für eine Reihe von Problemen in den neuen und ungetesteten HTML5-Gewässern finden. Insbesondere benötigten wir eine Lösung für ein scheinbar einfaches Problem: Wie laden wir mehr als 70 MB Spieldaten herunter und speichern sie im Cache, wenn der Spieler das Spiel startet?
Andere Plattformen haben vorgefertigte Lösungen für dieses Problem. Die meisten Konsolen- und PC-Spiele laden Ressourcen von einer lokalen CD/DVD oder einer Festplatte. In Flash können alle Ressourcen als Teil der SWF-Datei verpackt werden, die das Spiel enthält. In Java ist das mit JAR-Dateien möglich. Digitale Vertriebsplattformen wie Steam oder der App Store sorgen dafür, dass alle Ressourcen heruntergeladen und installiert werden, bevor der Spieler das Spiel starten kann.
HTML5 bietet uns diese Mechanismen nicht, aber alle Tools, die wir zum Erstellen unseres eigenen Downloadsystems für Spielressourcen benötigen. Der Vorteil eines eigenen Systems besteht darin, dass wir die benötigte Kontrolle und Flexibilität erhalten und ein System entwickeln können, das genau unseren Anforderungen entspricht.
Abruf
Bevor es das Ressourcen-Caching gab, hatten wir einen einfachen verketteten Ressourcen-Lademechanismus. Mit diesem System konnten wir einzelne Ressourcen über den relativen Pfad anfordern, was wiederum weitere Ressourcen anfordern konnte. Unser Ladebildschirm zeigte einen einfachen Fortschrittsbalken, der angab, wie viele Daten noch geladen werden mussten. Der nächste Bildschirm wurde erst angezeigt, wenn die Warteschlange des Ressourcen-Ladeprogramms leer war.
Das Design dieses Systems ermöglichte es uns, ganz einfach zwischen paketierten und losen (nicht paketierten) Ressourcen zu wechseln, die über einen lokalen HTTP-Server bereitgestellt wurden. Das war sehr wichtig, damit wir sowohl den Spielcode als auch die Daten schnell iterieren konnten.
Der folgende Code veranschaulicht das grundlegende Design unseres verketteten Ressourcen-Ladeprogramms. Die Fehlerbehandlung und der erweiterte XHR-/Bildladecode wurden 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 Benutzeroberfläche ist ziemlich einfach, aber auch recht flexibel. Der ursprüngliche Spielcode kann einige Datendateien anfordern, die das ursprüngliche Spiellevel und die Spielobjekte beschreiben. Das können beispielsweise einfache JSON-Dateien sein. Der für diese Dateien verwendete Rückruf prüft dann diese Daten und kann zusätzliche Anfragen (verkettete Anfragen) für Abhängigkeiten stellen. Die Definitiondatei für Game-Objekte kann Modelle und Materialien enthalten. Der Rückruf für Materialien kann dann Texturbilder anfordern.
Der oncomplete
-Callback, der an die Hauptinstanz von ResourceLoader
angehängt ist, wird erst aufgerufen, nachdem alle Ressourcen geladen wurden. Der Ladebildschirm des Spiels kann einfach warten, bis dieser Rückruf aufgerufen wird, bevor er zum nächsten Bildschirm wechselt.
Natürlich ist mit dieser Oberfläche noch viel mehr möglich. Als Übung für den Leser sind einige weitere Funktionen empfehlenswert, die Sie ausprobieren können: Unterstützung für Fortschritt/Prozentsatz, Bildladen (mit dem Bildtyp), automatisches Parsen von JSON-Dateien und natürlich Fehlerbehandlung.
Die wichtigste Funktion für diesen Artikel ist das Feld „baseurl“, mit dem wir die Quelle der angeforderten Dateien ganz einfach wechseln können. Es ist ganz einfach, die Kern-Engine so einzurichten, dass ein ?uselocal
-Suchparameter in der URL erlaubt ist, um Ressourcen von einer URL anzufordern, die vom selben lokalen Webserver (z. B. python -m SimpleHTTPServer
) bereitgestellt wird, der das Haupt-HTML-Dokument für das Spiel bereitgestellt hat. Andernfalls wird das Cache-System verwendet, wenn der Parameter nicht festgelegt ist.
Verpackungsressourcen
Ein Problem beim verketteten Laden von Ressourcen besteht darin, dass es keine Möglichkeit gibt, die Gesamtzahl der Bytes aller Daten zu ermitteln. Das hat zur Folge, dass es keine Möglichkeit gibt, ein einfaches, zuverlässiges Fortschrittsdialogfeld für Downloads zu erstellen. Da wir alle Inhalte herunterladen und im Cache speichern, was bei größeren Spielen ziemlich lange dauern kann, ist es wichtig, dem Spieler einen guten Fortschrittsbildschirm zu bieten.
Die einfachste Lösung für dieses Problem (die auch einige andere Vorteile bietet) besteht darin, alle Ressourcendateien in einem einzigen Bundle zu verpacken, das wir mit einem einzigen XHR-Aufruf herunterladen. So erhalten wir die Fortschrittsereignisse, die wir für eine schöne Fortschrittsanzeige benötigen.
Das Erstellen eines benutzerdefinierten Bundle-Dateiformats ist nicht besonders schwierig und würde sogar einige Probleme lösen. Es würde jedoch ein Tool zum Erstellen des Bundle-Formats erfordern. 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 werden kann. Wir benötigen kein komprimiertes Archivformat, da HTTP Daten bereits mit GZIP- oder Deflate-Algorithmen komprimieren 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 von 512 Byte, gefolgt vom Dateiinhalt, der auf 512 Byte aufgefüllt wird. Der Header enthält nur wenige für unsere Zwecke relevante oder interessante Felder, vor allem den Dateityp und den Namen, die an festen Positionen im Header gespeichert sind.
Kopfzeilenfelder im TAR-Format werden an festen Speicherorten mit fester Größe im Kopfblock gespeichert. Der Zeitstempel der letzten Änderung der Datei wird beispielsweise 136 Byte vom Beginn des Headers entfernt gespeichert und ist 12 Byte lang. Alle numerischen Felder sind als Oktalzahlen codiert und werden im ASCII-Format gespeichert. Zum Parsen der Felder extrahieren wir die Felder aus unserem Array-Buffer. Für numerische Felder rufen wir parseInt()
auf und geben dabei den gewünschten Oktal-Base-Parameter an.
Eines der wichtigsten Felder ist das Typfeld. Dies ist eine einzelne Oktalzahl, die angibt, welche Art von Datei der Eintrag enthält. Für unsere Zwecke sind nur die beiden Datensatztypen „normale Dateien“ ('0'
) und „Verzeichnisse“ ('5'
) interessant. Bei beliebigen TAR-Dateien könnten auch symbolische Links ('2'
) und möglicherweise harte Links ('1'
) von Bedeutung sein.
Auf jeden Header folgt unmittelbar der Inhalt der Datei, die durch den Header beschrieben wird. Eine Ausnahme bilden Dateitypen ohne eigenen Inhalt, z. B. Verzeichnisse. Auf den Dateiinhalt folgt dann Padding, damit jeder Header an einer 512-Byte-Grenze beginnt. Um die Gesamtlänge eines Dateieintrags in einer TAR-Datei zu berechnen, müssen wir also zuerst die Kopfzeile der Datei lesen. Wir addieren dann die Länge des Headers (512 Byte) zur Länge des aus dem Header extrahierten Dateiinhalts. Schließlich fügen wir Padding-Byte hinzu, die erforderlich sind, damit der Offset auf 512 Byte ausgerichtet ist. Dazu teilen wir die Dateilänge durch 512, nehmen die Obergrenze der Zahl und multiplizieren sie dann mit 512.
// 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 nach vorhandenen TAR-Lesern gesucht und einige gefunden, aber keine, die keine anderen Abhängigkeiten hatten oder sich problemlos in unsere vorhandene Codebasis einfügen ließen. Aus diesem Grund habe ich mich entschieden, meine eigene 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 verarbeitet.
Eines der ersten Probleme, die ich lösen musste, war, wie die Daten aus einer XHR-Anfrage geladen werden. Ursprünglich habe ich mit einem „Binärstring“-Ansatz begonnen. Leider ist die Umwandlung von Binärstrings in leichter verwendbare Binärformen wie ArrayBuffer
nicht einfach und auch nicht besonders schnell. Die Umwandlung in Image
-Objekte ist ebenso mühsam.
Ich habe mich entschieden, die TAR-Dateien als ArrayBuffer
direkt aus der XHR-Anfrage zu laden und eine kleine praktische Funktion zum Konvertieren von Chunks aus dem ArrayBuffer
in einen String hinzuzufügen. Derzeit werden in meinem Code nur einfache ANSI-/8-Bit-Zeichen verarbeitet. Das kann jedoch behoben werden, sobald eine praktischere Conversion API in Browsern verfügbar ist.
Der Code scannt einfach die ArrayBuffer
und analysiert die Datensatzüberschriften, einschließlich aller relevanten TAR-Headerfelder (und einiger weniger weniger relevanter) sowie den Speicherort und die Größe der Dateidaten in der ArrayBuffer
. Optional können die Daten auch als ArrayBuffer
-Ansicht extrahiert und in der Liste der zurückgegebenen Datensatzüberschriften gespeichert werden.
Der Code ist unter einer nutzerfreundlichen, permissiven Open-Source-Lizenz unter https://github.com/subsonicllc/TarReader.js kostenlos verfügbar.
FileSystem API
Zum Speichern des Dateiinhalts und zum späteren Zugriff darauf haben wir die FileSystem API verwendet. Die API ist noch recht neu, aber es gibt bereits einige gute Dokumentationen, darunter den hervorragenden HTML5 Rocks-Artikel zum Dateisystem.
Die FileSystem API ist nicht ohne Einschränkungen. Zum einen ist es eine ereignisgesteuerte Schnittstelle. Dadurch ist die API nicht blockierend, was zwar für die Benutzeroberfläche ideal ist, aber auch die Nutzung erschwert. Die Verwendung der FileSystem API in einem WebWorker kann dieses Problem lindern, erfordert aber, das gesamte Download- und Entpackungssystem in einen WebWorker aufzuteilen. Das könnte sogar der beste Ansatz sein, aber aufgrund von Zeitmangel (ich war mit WorkWorkers noch nicht vertraut) habe ich mich nicht dafür entschieden. Stattdessen musste ich mit der asynchronen, ereignisgesteuerten Natur der API umgehen.
Unsere Anforderungen konzentrieren sich hauptsächlich auf das Schreiben von Dateien in eine Verzeichnisstruktur. Dazu sind für jede Datei mehrere Schritte erforderlich. Zuerst müssen wir den Dateipfad in eine Liste umwandeln. Das geht ganz einfach, indem Sie den Pfadstring am Trennzeichen für Pfade (wie bei URLs immer der Schrägstrich) aufteilen. Anschließend müssen wir jedes Element in der resultierenden Liste durchgehen, mit Ausnahme des letzten, und bei Bedarf rekursiv ein Verzeichnis im lokalen Dateisystem erstellen. Dann können wir die Datei erstellen, eine FileWriter
erstellen und schließlich den Dateiinhalt ausgeben.
Ein weiterer wichtiger Punkt ist die Dateigrößenbeschränkung des PERSISTENT
-Speichers der FileSystem API. Wir wollten einen nichtflüchtigen Speicher, da der temporäre Speicher jederzeit gelöscht werden kann, auch wenn der Nutzer gerade unser Spiel spielt und kurz davor ist, 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 Anwendung verwendet wird. Für reguläre Web-Apps kann jedoch weiterhin Speicherplatz über die Benutzeroberfläche für experimentelle Kontingentanfragen angefordert werden.
function allocateStorage(space_in_bytes, success, error) {
webkitStorageInfo.requestQuota(
webkitStorageInfo.PERSISTENT,
space_in_bytes,
function() {
webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);
},
error
);
}