事例紹介 - SONAR、HTML5 Game Development

ショーン・ミドルディッチ
Sean Middleditch 氏

はじめに

昨年の夏、私は商用 WebGL ゲーム SONAR のテクニカル リードとして働いていました。このプロジェクトは 3 か月ほどかかり、JavaScript で完全にゼロから完了しました。SONAR の開発中、私たちはテストされていない新しい HTML5 の世界で多くの問題に対する革新的なソリューションを見つける必要がありました。特に、一見単純な問題である「プレーヤーがゲームを開始したときに 70 MB 以上のゲームデータをダウンロードしてキャッシュするにはどうすればよいか」という課題を解決する必要がありました。

他のプラットフォームには、この問題に対するソリューションがあらかじめ用意されています。ほとんどのゲーム機や PC ゲームは、ローカルの CD/DVD やハードドライブからリソースを読み込みます。Flash ではすべてのリソースをゲームを含む SWF ファイルの一部としてパッケージ化できますが、Java では JAR ファイルを使用して同じことができます。Steam や App Store などのデジタル配信プラットフォームは、プレーヤーがゲームを開始する前にすべてのリソースをダウンロードしてインストールするようにします。

HTML5 にはこのようなメカニズムはありませんが、独自のゲーム リソース ダウンロード システムを構築するために必要なツールはすべて揃っています。独自のシステムを構築するメリットは、必要なすべての制御と柔軟性が得られ、ニーズに完全に一致するシステムを構築できる点です。

取得

リソースのキャッシュを使用する前は、チェーン化されたシンプルなリソース ローダーを使用していました。このシステムでは、相対パスで個々のリソースをリクエストできるため、より多くのリソースをリクエストできるようになりました。読み込み画面には、読み込む必要のあるデータの量を測定するシンプルな進行状況メーターが表示され、リソース ローダーのキューが空になった後にのみ次の画面に移行しました。

このシステムの設計により、パッケージ化されたリソースと、ローカルの HTTP サーバー経由で提供されるルーズな(パッケージ化されていない)リソースを簡単に切り替えられるようになりました。これは、ゲームコードとデータの両方で迅速なイテレーションを実現するうえで実に役立ちました。

次のコードは、チェーン化されたリソース ローダーの基本的な設計を示しています。読みやすくするために、エラー処理と、より高度な XHR/画像読み込みコードが削除されています。

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();
};

このインターフェースの使い方は非常にシンプルですが、柔軟性も非常に優れています。初期ゲームコードは、初期ゲームレベルとゲーム オブジェクトを記述するデータファイルをリクエストできます。たとえば、単純な JSON ファイルなどです。これらのファイルに使用されるコールバックは、そのデータを検査し、依存関係に対する追加のリクエスト(チェーンされたリクエスト)を行うことができます。たとえば、ゲーム オブジェクト定義ファイルにモデルやマテリアルをリストアップし、マテリアルに対するコールバックでテクスチャ画像をリクエストすることもあります。

メインの ResourceLoader インスタンスに接続された oncomplete コールバックは、すべてのリソースが読み込まれた後にのみ呼び出されます。ゲームの読み込み画面は、そのコールバックが呼び出されるのを待ってから次の画面に移行できます。

もちろん、このインターフェースではさまざまなことができます。読者の練習として、調査する価値のある追加の機能として、進行状況/割合のサポートの追加、画像読み込みの追加(Image タイプを使用)、JSON ファイルの自動解析の追加、そしてもちろんエラー処理があります。

この記事で最も重要な機能は baseurl フィールドです。これにより、リクエストするファイルのソースを簡単に切り替えることができます。コアエンジンを設定すれば、URL 内の ?uselocal タイプのクエリ パラメータを使用して、ゲームのメイン HTML ドキュメントを配信したのと同じローカル ウェブサーバー(python -m SimpleHTTPServer など)によって提供される URL からリソースをリクエストできるようにします。パラメータが設定されていない場合はキャッシュ システムを使用します。

パッケージ リソース

リソースのチェーン読み込みの問題の 1 つは、すべてのデータの完全なバイト数を取得する方法がないことです。そのため、ダウンロードに関してシンプルで信頼性の高い進行状況ダイアログを作成する方法がなくなります。すべてのコンテンツをダウンロードしてキャッシュするため、大規模なゲームの場合はかなりの時間がかかるため、プレーヤーにわかりやすい進行状況ダイアログを表示することが非常に重要です。

この問題に対する最も簡単な方法は、すべてのリソース ファイルを 1 つのバンドルにパッケージ化することです。このバンドルは 1 回の XHR 呼び出しでダウンロードされます。これにより、適切な進行状況バーを表示するために必要な進行状況のイベントを取得できます。

カスタム バンドル ファイル形式の作成はそれほど難しくなく、いくつかの問題が解決しますが、バンドル形式を作成するためのツールを作成する必要があります。もう 1 つの解決策は、ツールがすでに存在する既存のアーカイブ形式を使用して、ブラウザで実行するデコーダを記述することです。HTTP ではすでに gzip や deflate アルゴリズムを使用してデータを圧縮できるため、圧縮アーカイブ形式は必要ありません。このような理由から、Google は TAR ファイル形式を採用するに至りました。

TAR は比較的シンプルな形式です。各レコード(ファイル)のヘッダーには 512 バイトがあり、その後に 512 バイトにパディングされたファイル コンテンツが続きます。ヘッダーには、ファイルの種類と名前など、関連するフィールドや興味深いフィールドはほとんどありません。これらはヘッダー内の固定位置に格納されます。

TAR 形式のヘッダー フィールドは、ヘッダー ブロック内の固定された場所に保存され、サイズが固定されます。たとえば、ファイルの最終変更タイムスタンプは、ヘッダーの先頭から 136 バイトの位置に保存され、長さは 12 バイトです。数値フィールドはすべて 8 進数にエンコードされ、ASCII 形式で保存されます。次に、フィールドを解析するために、配列バッファからフィールドを抽出します。数値フィールドについては、目的の 8 進数を示す 2 番目のパラメータを必ず渡して parseInt() を呼び出します。

最も重要なフィールドの一つはタイプ フィールドです。これは 1 桁の 8 進数で、レコードに含まれるファイルの種類を示します。ここで注目すべきレコードタイプは、通常のファイル('0')とディレクトリ('5')の 2 つだけです。任意の TAR ファイルを扱う場合は、シンボリック リンク('2')と、場合によってはハードリンク('1')も考慮する必要があります。

各ヘッダーの直後に、ヘッダーで記述されたファイルの内容が続きます(ディレクトリなど、独自の内容がないファイル形式は除く)。ファイルのコンテンツの後にパディングを追加して、すべてのヘッダーが 512 バイト境界で始まるようにします。したがって、TAR ファイル内のファイルレコードの全長を計算するには、まずファイルのヘッダーを読み取る必要があります。次に、ヘッダーの長さ(512 バイト)と、ヘッダーから抽出したファイル・コンテンツの長さを加算します。最後に、オフセットを 512 バイトに揃えるのに必要なパディング バイトを追加します。これは、ファイル長を 512 で除算し、数値の上限を取り、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
  };
};

既存の TAR リーダーを調べたところ、他の依存関係がないものや、既存のコードベースに簡単に収まりやすいものはいくつか見つかりました。そのため、私は自分で書くことにしました。また、時間をかけて読み込みを可能な限り最適化し、デコーダがアーカイブ内のバイナリデータと文字列データの両方を簡単に処理できるようにしました。

最初に解決しなければならなかった問題の 1 つは、実際に XHR リクエストからデータを読み込む方法でした。私はもともと「バイナリ文字列」アプローチから始めました。残念ながら、バイナリ文字列から ArrayBuffer のようなより使いやすいバイナリ形式への変換は容易ではなく、そのような変換も特に短期間で完了します。Image オブジェクトへの変換も同様に手間がかかります。

TAR ファイルを XHR リクエストから直接 ArrayBuffer として読み込み、チャンクを ArrayBuffer から文字列に変換するための小さな便利な関数を追加することにしました。現在、私のコードで処理できるのは基本的な ANSI/8 ビット文字のみですが、より便利な変換 API がブラウザで利用可能になり次第、この問題を修正できます。

このコードは、ArrayBuffer をスキャンしてレコード ヘッダーをパースするだけです。レコード ヘッダーには、関連するすべての TAR ヘッダー フィールド(および関連性のないいくつかのフィールド)と、ArrayBuffer 内のファイルデータの場所とサイズが含まれています。コードでは、必要に応じてデータを ArrayBuffer ビューとして抽出し、返されたレコード ヘッダーのリストに格納することもできます。

このコードは、わかりやすいオープンソース ライセンスの下で https://github.com/subsonicllc/TarReader.js から無料で入手できます。

FileSystem API

実際にファイルの内容を保存し、後でアクセスするために、FileSystem API を使用しました。この API はかなり新しいものですが、HTML5 Rocks FileSystem の優れた記事など、すでに有用なドキュメントがいくつか用意されています。

FileSystem API には注意点があります。一つは、イベント ドリブンなインターフェースです。これにより、API が非ブロック化となり、UI には役立ちますが、使いづらいという問題もあります。この問題は、WebWorker から FileSystem API を使用することで軽減できますが、その場合、ダウンロードおよび展開するシステム全体を WebWorker に分割する必要があります。これが最良のアプローチかもしれませんが、時間的な制約から(私はまだ WorkWorkers に慣れていないため)採用したアプローチではありませんでした。そのため、API の非同期のイベント ドリブンな性質に対処する必要がありました。

私たちのニーズは主に、ファイルをディレクトリ構造に書き込むことに重点を置いています。そのためには、ファイルごとに一連の手順を行う必要があります。まず、ファイルパスを取得してリストに変換する必要があります。これを行うには、パス文字列をパス区切り文字(URL と同様に常にスラッシュ)で分割します。次に、結果のリストの各要素を反復処理して最後の要素を保存し、(必要に応じて)ローカル ファイルシステムにディレクトリを再帰的に作成します。次に、ファイルを作成してから、FileWriter を作成し、最後にファイルの内容を書き出します。

考慮すべき 2 つ目の重要な点は、FileSystem API の PERSISTENT ストレージのファイルサイズの上限です。永続ストレージが必要でした。ユーザーがゲームをプレイ中であっても、強制排除されたファイルを読み込もうとする直前を含め、一時ストレージはいつでもクリアできるためです。

Chrome ウェブストアを対象とするアプリの場合、アプリのマニフェスト ファイルで unlimitedStorage 権限を使用しても、ストレージの制限はありません。ただし、通常のウェブアプリでは、試験運用版の割り当てリクエスト インターフェースを使用して引き続きスペースをリクエストできます。

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