Introduction
L'été dernier, j'ai travaillé en tant que responsable technique sur un jeu WebGL commercial appelé SONAR. La réalisation du projet a pris environ trois mois et a été entièrement réalisée à partir de zéro en JavaScript. Lors du développement de SONAR, nous avons dû trouver des solutions innovantes à un certain nombre de problèmes dans le nouveau HTML5, qui n'avait pas encore été testé. Plus précisément, nous devions trouver une solution à un problème apparemment simple: comment télécharger et mettre en cache plus de 70 Mo de données de jeu lorsque le joueur lance le jeu ?
D'autres plates-formes proposent des solutions prêtes à l'emploi pour ce problème. La plupart des jeux sur console et PC chargent des ressources à partir d'un CD/DVD local ou d'un disque dur. Flash peut empaqueter toutes les ressources dans le fichier SWF contenant le jeu, et Java peut faire de même avec les fichiers JAR. Les plates-formes de distribution numérique telles que Steam ou l'App Store s'assurent que toutes les ressources sont téléchargées et installées avant même que le joueur ne puisse lancer le jeu.
HTML5 ne nous fournit pas ces mécanismes, mais il nous fournit tous les outils dont nous avons besoin pour créer notre propre système de téléchargement de ressources de jeu. L'avantage de créer notre propre système est que nous obtenons tout le contrôle et la flexibilité dont nous avons besoin, et que nous pouvons créer un système qui correspond exactement à nos besoins.
Récupération
Avant la mise en cache des ressources, nous utilisions un simple chargeur de ressources en chaîne. Ce système nous a permis de demander des ressources individuelles par chemin d'accès relatif, ce qui pouvait à son tour demander plus de ressources. Notre écran de chargement présentait un simple indicateur de progression qui mesurait la quantité de données à charger et ne passait à l'écran suivant qu'une fois la file d'attente du chargeur de ressources vide.
La conception de ce système nous a permis de passer facilement entre des ressources empaquetées et des ressources non empaquetées (non empaquetées) diffusées via un serveur HTTP local, ce qui nous a permis d'itérer rapidement sur le code et les données du jeu.
Le code suivant illustre la conception de base de notre chargeur de ressources en chaîne, avec la gestion des erreurs et le code de chargement XHR/image plus avancé supprimés pour faciliter la lecture.
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();
};
L'utilisation de cette interface est assez simple, mais aussi très flexible. Le code de jeu initial peut demander des fichiers de données qui décrivent le niveau de jeu initial et les objets de jeu. Il peut s'agir, par exemple, de fichiers JSON simples. Le rappel utilisé pour ces fichiers inspecte ensuite ces données et peut effectuer des requêtes supplémentaires (requêtes en chaîne) pour les dépendances. Le fichier de définition des objets de jeu peut lister des modèles et des matériaux, et le rappel des matériaux peut ensuite demander des images de texture.
Le rappel oncomplete
associé à l'instance ResourceLoader
principale ne sera appelé qu'après le chargement de toutes les ressources. L'écran de chargement du jeu peut simplement attendre que ce rappel soit appelé avant de passer à l'écran suivant.
Bien sûr, cette interface permet de faire bien plus. Pour les exercices du lecteur, voici quelques fonctionnalités supplémentaires à explorer : l'ajout de la prise en charge de la progression/du pourcentage, l'ajout du chargement d'images (à l'aide du type Image), l'ajout de l'analyse automatique des fichiers JSON et, bien sûr, la gestion des erreurs.
La fonctionnalité la plus importante pour cet article est le champ "baseurl", qui nous permet de modifier facilement la source des fichiers que nous demandons. Il est facile de configurer le moteur principal pour autoriser un paramètre de requête de type ?uselocal
dans l'URL à demander des ressources à partir d'une URL diffusée par le même serveur Web local (comme python -m SimpleHTTPServer
) qui a diffusé le document HTML principal du jeu, tout en utilisant le système de cache si le paramètre n'est pas défini.
Ressources d'emballage
Le chargement en chaîne des ressources présente un problème : il est impossible d'obtenir un nombre d'octets complet pour toutes les données. Par conséquent, il est impossible de créer une boîte de dialogue de progression simple et fiable pour les téléchargements. Étant donné que nous allons télécharger tout le contenu et le mettre en cache, ce qui peut prendre un temps assez long pour les jeux plus volumineux, il est important de proposer au joueur une boîte de dialogue de progression agréable.
La solution la plus simple à ce problème (qui présente également d'autres avantages) consiste à empaqueter tous les fichiers de ressources dans un seul bundle, que nous téléchargerons avec un seul appel XHR, ce qui nous fournit les événements de progression dont nous avons besoin pour afficher une barre de progression.
Créer un format de fichier de bundle personnalisé n'est pas très difficile et permettrait même de résoudre quelques problèmes, mais cela nécessiterait de créer un outil pour créer le format de bundle. Une autre solution consiste à utiliser un format d'archive existant pour lequel des outils existent déjà, puis à écrire un décodeur à exécuter dans le navigateur. Nous n'avons pas besoin d'un format d'archive compressé, car HTTP peut déjà compresser les données à l'aide des algorithmes gzip ou deflate. C'est pourquoi nous avons choisi le format de fichier TAR.
Le format TAR est relativement simple. Chaque enregistrement (fichier) comporte un en-tête de 512 octets, suivi du contenu du fichier complété à 512 octets. L'en-tête ne comporte que quelques champs pertinents ou intéressants pour nos besoins, principalement le type et le nom de fichier, qui sont stockés à des positions fixes dans l'en-tête.
Les champs d'en-tête au format TAR sont stockés à des emplacements fixes et de taille fixe dans le bloc d'en-tête. Par exemple, la date et l'heure de dernière modification du fichier sont stockées à 136 octets du début de l'en-tête et font 12 octets. Tous les champs numériques sont encodés sous forme de nombres octals stockés au format ASCII. Pour analyser les champs, nous les extrayons de notre tampon de tableau. Pour les champs numériques, nous appelons parseInt()
en veillant à transmettre le deuxième paramètre pour indiquer la base octale souhaitée.
L'un des champs les plus importants est le champ de type. Il s'agit d'un nombre octal à un seul chiffre qui indique le type de fichier contenu dans l'enregistrement. Les deux seuls types d'enregistrements intéressants pour nos besoins sont les fichiers standards ('0'
) et les répertoires ('5'
). Si nous avions affaire à des fichiers TAR arbitraires, nous pourrions également nous intéresser aux liens symboliques ('2'
) et éventuellement aux liens physiques ('1'
).
Chaque en-tête est immédiatement suivi du contenu du fichier décrit par l'en-tête (à l'exception des types de fichiers qui n'ont pas de contenu propre, comme les répertoires). Le contenu du fichier est ensuite suivi d'un remplissage pour s'assurer que chaque en-tête commence sur une limite de 512 octets. Par conséquent, pour calculer la longueur totale d'un enregistrement de fichier dans un fichier TAR, nous devons d'abord lire l'en-tête du fichier. Nous ajoutons ensuite la longueur de l'en-tête (512 octets) à la longueur du contenu du fichier extrait de l'en-tête. Enfin, nous ajoutons les octets de remplissage nécessaires pour que le décalage soit aligné sur 512 octets. Pour ce faire, il suffit de diviser la longueur du fichier par 512, de prendre le plafond du nombre, puis de le multiplier par 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
};
};
J'ai cherché des lecteurs TAR existants et j'en ai trouvé quelques-uns, mais aucun qui ne présentait d'autres dépendances ni qui ne s'intégrait facilement à notre codebase existant. C'est pourquoi j'ai choisi d'écrire le mien. J'ai également pris le temps d'optimiser le chargement au mieux et de m'assurer que le décodeur gère facilement les données binaires et les chaînes dans l'archive.
L'un des premiers problèmes que j'ai dû résoudre était de savoir comment charger les données à partir d'une requête XHR. J'ai commencé par une approche de "chaîne binaire". Malheureusement, la conversion de chaînes binaires en formes binaires plus facilement utilisables, comme une ArrayBuffer
, n'est pas simple, et ces conversions ne sont pas particulièrement rapides. La conversion en objets Image
est tout aussi pénible.
J'ai choisi de charger les fichiers TAR en tant que ArrayBuffer
directement à partir de la requête XHR et d'ajouter une petite fonction pratique pour convertir les segments de l'ArrayBuffer
en chaîne. Actuellement, mon code ne gère que les caractères ANSI/8 bits de base, mais cela peut être corrigé une fois qu'une API de conversion plus pratique sera disponible dans les navigateurs.
Le code analyse simplement le ArrayBuffer
en analysant les en-têtes d'enregistrement, qui incluent tous les champs d'en-tête TAR pertinents (et quelques-uns moins pertinents), ainsi que l'emplacement et la taille des données de fichier dans le ArrayBuffer
. Le code peut également extraire les données sous forme de vue ArrayBuffer
et les stocker dans la liste des en-têtes d'enregistrement renvoyés.
Le code est disponible sans frais sous une licence Open Source permissive et conviviale à l'adresse https://github.com/subsonicllc/TarReader.js.
API FileSystem
Pour stocker le contenu des fichiers et y accéder ultérieurement, nous avons utilisé l'API FileSystem. L'API est toute récente, mais elle est déjà bien documentée, y compris dans l'excellent article HTML5 Rocks FileSystem.
L'API FileSystem n'est pas sans limites. D'une part, il s'agit d'une interface basée sur les événements. Cela rend l'API non bloquante, ce qui est idéal pour l'UI, mais aussi difficile à utiliser. L'utilisation de l'API FileSystem à partir d'un WebWorker peut atténuer ce problème, mais cela nécessiterait de diviser l'ensemble du système de téléchargement et de décompression en un WebWorker. Il s'agit peut-être même de la meilleure approche, mais ce n'est pas celle que j'ai choisie en raison de contraintes de temps (je ne connaissais pas encore WorkWorkers). J'ai donc dû gérer la nature asynchrone de l'API basée sur les événements.
Nos besoins se concentrent principalement sur l'écriture de fichiers dans une structure de répertoires. Pour cela, vous devez suivre une série d'étapes pour chaque fichier. Tout d'abord, nous devons convertir le chemin d'accès au fichier en liste. Pour ce faire, il suffit de diviser la chaîne de chemin d'accès au niveau du caractère séparateur de chemin d'accès (qui est toujours la barre oblique, comme dans les URL). Nous devons ensuite itérer sur chaque élément de la liste obtenue, à l'exception du dernier, en créant récursivement un répertoire (le cas échéant) dans le système de fichiers local. Nous pouvons ensuite créer le fichier, puis un FileWriter
, et enfin écrire le contenu du fichier.
Un autre élément important à prendre en compte est la limite de taille de fichier du stockage PERSISTENT
de l'API FileSystem. Nous voulions un stockage persistant, car le stockage temporaire peut être effacé à tout moment, y compris lorsque l'utilisateur est en train de jouer à notre jeu juste avant qu'il ne tente de charger le fichier éjecté.
Pour les applications ciblant le Chrome Web Store, il n'y a pas de limite de stockage lorsque vous utilisez l'autorisation unlimitedStorage
dans le fichier manifeste de l'application. Toutefois, les applications Web standards peuvent toujours demander de l'espace à l'aide de l'interface expérimentale de demande de quota.
function allocateStorage(space_in_bytes, success, error) {
webkitStorageInfo.requestQuota(
webkitStorageInfo.PERSISTENT,
space_in_bytes,
function() {
webkitRequestFileSystem(PERSISTENT, space_in_bytes, success, error);
},
error
);
}