事例紹介 - Inside World Wide Maze

World Wide Maze は、スマートフォンを使って、ウェブサイトから作成された 3D 迷路を転がるボールを操作してゴールを目指すゲームです。

World Wide Maze

このゲームでは、HTML5 の機能が幅広く使用されています。たとえば、DeviceOrientation イベントはスマートフォンから傾斜データを取得し、WebSocket 経由で PC に送信します。PC では、WebGLWeb Workers によって構築された 3D 空間をプレイヤーが移動します。

この記事では、これらの機能の使用方法、開発プロセス全体、最適化のポイントについて詳しく説明します。

DeviceOrientation

DeviceOrientation イベント()は、スマートフォンから傾斜データを取得するために使用します。addEventListenerDeviceOrientation イベントで使用すると、DeviceOrientationEvent オブジェクトを含むコールバックが引数として一定の間隔で呼び出されます。間隔自体は、使用するデバイスによって異なります。たとえば、iOS と Chrome、iOS と Safari ではコールバックは約 1/20 秒ごとに呼び出されますが、Android 4 と Chrome では約 1/10 秒ごとに呼び出されます。

window.addEventListener('deviceorientation', function (e) {
  // do something here..
});

DeviceOrientationEvent オブジェクトには、XYZ の各軸の傾斜データが度単位(ラジアン単位ではない)で格納されます(HTML5Rocks で詳細を確認する)。ただし、戻り値は、使用しているデバイスとブラウザの組み合わせによっても異なります。実際の戻り値の範囲は次の表のとおりです。

デバイスの向き。

上部で青色でハイライト表示されている値は、W3C 仕様で定義されている値です。緑色でハイライト表示されている値はこれらの仕様に合致していますが、赤色でハイライト表示されている値は合致していません。驚いたことに、仕様に一致する値が返されたのは Android と Firefox の組み合わせのみでした。ただし、実装に関しては、頻繁に発生する値に対応することをおすすめします。そのため、World Wide Maze では iOS の戻り値を標準として使用し、それに応じて Android デバイスを調整しています。

if android and event.gamma > 180 then event.gamma -= 360

ただし、Nexus 10 は引き続きサポートされていません。Nexus 10 は他の Android デバイスと同じ範囲の値を返しますが、ベータ値とガンマ値が逆になるバグがあります。この問題については別途対応いたします。(デフォルトで横向きになっている可能性があります)。

この例からもわかるように、物理デバイスに関連する API に仕様が設定されている場合でも、返される値がその仕様に一致するとは限りません。そのため、すべてのデバイスでテストすることが重要です。また、予期しない値が入力される可能性があり、回避策を作成する必要があります。World Wide Maze では、初めてプレイするユーザーに対してチュートリアルのステップ 1 としてデバイスの調整を求めますが、想定外の傾斜値が検出されると、ゼロ位置に正しく調整されません。そのため、内部的な時間制限があり、その時間内に調整できない場合は、キーボード コントロールに切り替えるようプレーヤーにプロンプトを表示します。

WebSocket

World Wide Maze では、スマートフォンと PC は WebSocket 経由で接続されます。正確には、スマートフォンからサーバー、サーバーからパソコンというように、リレー サーバー経由で接続されます。これは、WebSocket にブラウザを直接接続する機能がないためです。(WebRTC データチャネルを使用すると、ピアツーピア接続が可能になり、リレー サーバーの必要がなくなります。ただし、実装時点では、この方法は Chrome Canary と Firefox Nightly でのみ使用可能でした)。

私は、Socket.IO(v0.9.11)というライブラリを使用して実装しました。このライブラリには、接続タイムアウトや切断が発生した場合の再接続機能が含まれています。私は NodeJS と一緒に使用しました。この NodeJS + Socket.IO の組み合わせは、いくつかの WebSocket 実装テストでサーバーサイドのパフォーマンスが最も優れていることが示されました。

番号によるペア設定

  1. PC がサーバーに接続します。
  2. サーバーは PC にランダムに生成された番号を割り当て、その番号と PC の組み合わせを記憶します。
  3. モバイル デバイスで番号を指定してサーバーに接続します。
  4. 指定した番号が接続されている PC の番号と同じであれば、モバイル デバイスはその PC とペア設定されています。
  5. 指定された PC がない場合、エラーが発生します。
  6. モバイル デバイスからデータが届くと、ペア設定されている PC に送信されます。その逆も同様です。

代わりに、モバイル デバイスから最初の接続を行うこともできます。その場合は、デバイスを反対に装着します。

タブの同期

Chrome 固有のタブ同期機能を使用すると、ペア設定プロセスがさらに簡単になります。これにより、パソコンで開いているページをモバイル デバイスで簡単に開くことができます(その逆も同様です)。PC は、サーバーから発行された接続番号を取得し、history.replaceState を使用してページの URL に追加します。

history.replaceState(null, null, '/maze/' + connectionNumber)

タブ同期が有効になっている場合、URL は数秒後に同期され、モバイル デバイスで同じページを開くことができます。モバイル デバイスは開いているページの URL を確認し、番号が追加されている場合はすぐに接続を開始します。これにより、番号を手動で入力したり、カメラで QR コードをスキャンしたりする必要がなくなります。

レイテンシ

リレー サーバーは米国に設置されているため、日本からアクセスすると、スマートフォンの傾斜データが PC に届くまで約 200 ミリ秒の遅延が発生します。応答時間は、開発時に使用したローカル環境と比べて明らかに遅かったのですが、ローパス フィルタ(私は EMA を使用しました)を挿入することで、目立たないレベルまで改善されました。(実際には、表示目的でも低周波フィルタが必要でした。傾斜センサーからの戻り値にはノイズがかなり含まれているため、そのまま画面に適用すると、画面が大きく揺れていました)。ジャンプでは機能せず、明らかに遅延していましたが、解決策はありませんでした。

最初からレイテンシの問題が予想されたため、クライアントが利用可能な最も近いリレーサーバーに接続できるように、世界中にリレーサーバーを設置することを検討しました(これによりレイテンシを最小限に抑えます)。結局、Google Compute Engine(GCE)を使用しましたが、当時は米国にしか存在しなかったため、この方法は使えませんでした。

Nagle アルゴリズムの問題

Nagle アルゴリズムは通常、TCP レベルでバッファリングすることで効率的な通信を行うためにオペレーティング システムに組み込まれていますが、このアルゴリズムが有効になっている間はデータをリアルタイムで送信できないことがわかりました。(特に TCP 遅延確認と組み合わせた場合)。ACK が遅延していなくても、サーバーが海外にあるなどの要因で ACK が一定程度遅延すると、同じ問題が発生します)。

Nagle レイテンシの問題は、Nagle を無効にする TCP_NODELAY オプションを含む Android 版 Chrome の WebSocket では発生しませんでしたが、このオプションが有効になっていない iOS 版 Chrome で使用される WebKit WebSocket では発生しました。(同じ WebKit を使用している Safari でもこの問題が発生していました。この問題は Google を通じて Apple に報告され、WebKit の開発版で解決されたようです

この問題が発生すると、100 ミリ秒ごとに送信される傾斜データがチャンクにまとめられ、500 ミリ秒ごとに PC に届くようになります。このような状況ではゲームは機能できないため、サーバー側で短い間隔(50 ミリ秒程度)でデータを送信することで、このレイテンシを回避します。ACK が短い間隔で受信されると、Nagle アルゴリズムがデータを送信してもよいと判断するようです。

Nagle アルゴリズム 1

上記のグラフは、受信した実際のデータの間隔を示しています。パケット間の時間間隔を示します。緑は出力間隔、赤は入力間隔を表します。最小値は 54 ミリ秒、最大値は 158 ミリ秒、中央値は 100 ミリ秒に近い値です。ここでは、日本のリレー サーバーを使用した iPhone を使用しました。出力と入力の両方が 100 ミリ秒程度で、動作はスムーズです。

Nagle アルゴリズム 2

一方、次のグラフは、米国でサーバーを使用した場合の結果を示しています。緑色の出力間隔は 100 ミリ秒で安定していますが、入力間隔は最小 0 ミリ秒から最大 500 ミリ秒まで変動しています。これは、PC がデータをチャンクで受信していることを示しています。

ALT_TEXT_HERE

最後に、このグラフは、サーバーがプレースホルダ データを送信することでレイテンシを回避した結果を示しています。日本のサーバーを使用する場合ほどパフォーマンスは高くありませんが、入力間隔は 100 ミリ秒前後で比較的安定していることがわかります。

バグ

Android 4(ICS)のデフォルト ブラウザには WebSocket API が搭載されているにもかかわらず、接続できないため、Socket.IO connect_failed イベントが発生します。内部でタイムアウトし、サーバーサイドでも接続を確認できません。(WebSocket のみでテストしていないため、Socket.IO の問題である可能性があります)。

リレーサーバーのスケーリング

リレー サーバーの役割はそれほど複雑ではないため、同じ PC とモバイル デバイスが常に同じサーバーに接続されている限り、スケールアップしてサーバーの数を増やすことは難しくありません。

物理学

ゲーム内のボールの動き(下り坂を転がる、地面にぶつかる、壁にぶつかる、アイテムを収集するなど)はすべて、3D 物理シミュレータで行われます。Ammo.js(広く使用されている Bullet 物理エンジンを Emscripten を使用して JavaScript に移植したもの)と Physijs を使用して、これを「ウェブ ワーカー」として使用しました。

ウェブワーカー

Web Workers は、JavaScript を別のスレッドで実行するための API です。Web Worker として起動された JavaScript は、最初に呼び出したスレッドとは別のスレッドとして実行されるため、ページのレスポンスを維持しながら負荷の高いタスクを実行できます。Physijs は Web Worker を効率的に使用して、通常は負荷の高い 3D 物理エンジンをスムーズに実行します。World Wide Maze では、物理エンジンと WebGL 画像のレンダリングがまったく異なるフレームレートで処理されるため、WebGL レンダリングの負荷が重い低スペック マシンでフレームレートが低下しても、物理エンジン自体はほぼ 60 fps を維持し、ゲームの操作を妨げません。

FPS

この画像は、Lenovo G570 での結果のフレームレートを示しています。上部のボックスは WebGL のフレームレート(画像レンダリング)を示し、下部のボックスは物理エンジンのフレームレートを示します。GPU は統合型の Intel HD Graphics 3000 チップであるため、画像レンダリングのフレームレートが想定される 60 fps に達しませんでした。ただし、物理エンジンは想定どおりのフレームレートを達成しているため、ゲームプレイはハイスペック マシンでのパフォーマンスとそれほど変わりません。

アクティブな Web ワーカーを含むスレッドにはコンソール オブジェクトがないため、デバッグログを生成するには、postMessage を介してメインスレッドにデータを送信する必要があります。console4Worker を使用すると、ワーカーにコンソール オブジェクトと同等のものが作成され、デバッグ プロセスが大幅に簡素化されます。

Service Worker

最新バージョンの Chrome では、Web Worker の起動時にブレークポイントを設定できます。これはデバッグにも役立ちます。これは、デベロッパー ツールの [Workers] パネルで確認できます。

パフォーマンス

ポリゴン数の多いステージでは、ポリゴンが 10 万個を超えることもあります。しかし、すべて Physijs.ConcaveMesh(Bullet では btBvhTriangleMeshShape)として生成された場合でも、パフォーマンスに特に影響はありませんでした。

当初、衝突検出を必要とするオブジェクトの数が増えるにつれてフレームレートが低下しましたが、Physijs で不要な処理を排除することでパフォーマンスが向上しました。この改善は、元の Physijs のフォークに対して行われました。

ゴースト オブジェクト

衝突検出は行われるが、衝突による影響がないため他のオブジェクトに影響を与えないオブジェクトは、Bullet では「ゴースト オブジェクト」と呼ばれます。Physijs はゴースト オブジェクトを公式にはサポートしていませんが、Physijs.Mesh を生成した後にフラグをいじることで、作成できます。World Wide Maze では、アイテムとゴールポイントの衝突検出にゴースト オブジェクトを使用します。

hit = new Physijs.SphereMesh(geometry, material, 0)
hit._physijs.collision_flags = 1 | 4
scene.add(hit)

collision_flags の場合、1 は CF_STATIC_OBJECT、4 は CF_NO_CONTACT_RESPONSE です。詳しくは、Bullet のフォーラムStack OverflowBullet のドキュメントを検索してください。Physijs は Ammo.js のラッパーであり、Ammo.js は基本的に Bullet と同じであるため、Bullet でできることのほとんどは Physijs でもできます。

Firefox 18 の問題

Firefox のバージョン 17 から 18 への更新により、Web Worker のデータ交換方法が変更され、その結果 Physijs が機能しなくなった。この問題は GitHub で報告され、数日後に解決されました。このオープンソースの効率性に感心しましたが、このインシデントは、World Wide Maze が複数の異なるオープンソース フレームワークで構成されていることを思い出させました。この記事は、何らかのフィードバックを提供することを目的としています。

asm.js

これは World Wide Maze に直接関係するものではありませんが、Ammo.js はすでに Mozilla が最近発表した asm.js をサポートしています(asm.js は基本的に Emscripten によって生成された JavaScript を高速化するために作成されたものであり、Emscripten の作成者は Ammo.js の作成者でもあるため、これは驚くべきことではありません)。Chrome が asm.js もサポートしている場合、物理エンジンのコンピューティング負荷は大幅に軽減されます。Firefox Nightly でテストしたところ、速度が大幅に向上しました。速度が必要なセクションは C/C++ で作成し、Emscripten を使用して JavaScript に移植するのがよいでしょうか?

WebGL

WebGL の実装には、最も活発に開発されているライブラリである three.js(r53)を使用しました。リビジョン 57 は開発の後半ですでにリリースされていましたが、API に大きな変更が加えられたため、リリースには元のリビジョンを使用しました。

グロー効果

ボールのコアとアイテムに追加されたグロー効果は、いわゆる「Kawase Method MGF」のシンプルなバージョンを使用して実装されています。ただし、川瀬法では明るい部分すべてがブローミングされますが、ワールドワイド メイズでは、輝く必要がある領域に個別のレンダリング ターゲットを作成します。これは、ステージのテクスチャにウェブサイトのスクリーンショットを使用する必要があるためです。明るい部分をすべて抽出すると、たとえば背景が白色の場合、ウェブサイト全体が光り輝くことになります。すべてを HDR で処理することも検討しましたが、実装が非常に複雑になるため、今回は見送ることにしました。

グロー

左上は最初のパスです。グロー領域が別々にレンダリングされ、ぼかしが適用されています。右下は 2 回目のパスです。画像サイズを 50% 縮小してからぼかしを適用しています。右上は 3 回目のパスです。画像は再度 50% 縮小され、ぼかされています。3 枚の画像を重ね合わせて、左下に示されている最終的な合成画像を作成しました。ぼかしには three.js に含まれている VerticalBlurShaderHorizontalBlurShader を使用しているため、さらに最適化できる余地があります。

反射ボール

ボールの反射は、three.js のサンプルに基づいています。すべての方向はボールの位置からレンダリングされ、環境マップとして使用されます。環境マップはボールが動くたびに更新する必要がありますが、60 fps で更新すると負荷が大きくなるため、代わりに 3 フレームごとに更新されます。結果はフレームごとに更新する場合ほどスムーズではありませんが、指摘されない限り、違いはほとんどわかりません。

シェーダー、シェーダー、シェーダー

WebGL では、すべてのレンダリングにシェーダー(頂点シェーダー、フラグメント シェーダー)が必要です。three.js に含まれているシェーダーでも幅広いエフェクトを実現できますが、より複雑なシェーディングや最適化を行うには、独自のシェーダーを記述する必要があります。World Wide Maze では物理エンジンによって CPU が常に使用されているため、(JavaScript による)CPU 処理の方が簡単な場合でも、シェーディング言語(GLSL)で可能な限り記述することで、代わりに GPU を利用できるようにしました。海の波のエフェクトはシェーダーに依存しています。ゴールポイントの花火や、ボールが表示されたときに使用されるメッシュ エフェクトも同様です。

シェーダーボール

上記は、ボールが表示されたときに使用されるメッシュ エフェクトのテスト結果です。左側はゲーム内で使用されるモデルで、320 個のポリゴンで構成されています。中央のポリゴンは約 5,000 個、右側のポリゴンは約 300,000 個です。シェーダーによる処理では、これほど多くのポリゴンでも 30 fps の安定したフレームレートを維持できます。

シェーダー メッシュ

ステージ全体に散らばっている小さなアイテムはすべて 1 つのメッシュに統合されており、個々の動きはシェーダーが各ポリゴンの先端を動かすことに依存しています。これは、大量のオブジェクトが存在する場合にパフォーマンスが低下するかどうかを確認するテストの結果です。ここに配置されているオブジェクトは約 5,000 個で、約 20,000 個のポリゴンで構成されています。パフォーマンスはまったく低下しませんでした。

poly2tri

ステージは、サーバーから受信したアウトライン情報に基づいて形成され、JavaScript によってポリゴン化されます。このプロセスの重要な部分である三角測量は、three.js では適切に実装されておらず、通常は失敗します。そこで、poly2tri という別の三角形分割ライブラリを自分で統合することにしました。調べてみると、three.js では過去に同じことを試していたことが判明しました。そのため、その一部をコメント化することで、動作するようになりました。その結果、エラーが大幅に減少し、プレイ可能なステージが大幅に増えました。時折エラーが続くことがあります。なんらかの理由で poly2tri がアラートを発行してエラーを処理するため、代わりに例外をスローするように変更しました。

poly2tri

上記は、青色の輪郭が三角形に分割され、赤色のポリゴンが生成される様子を示しています。

異方性フィルタリング

標準の等方性 MIP マッピングでは、水平軸と垂直軸の両方で画像が縮小されるため、斜めからポリゴンを見ると、ワールドワイド メイズ ステージの遠端のテクスチャが水平方向に引き伸ばされた低解像度のテクスチャのように見えます。こちらの Wikipedia ページの右上にある画像が、その良い例です。実際には、より多くの水平解像度が必要になります。WebGL(OpenGL)は、アネソトロピック フィルタリングと呼ばれる方法を使用してこの問題を解決します。three.js では、THREE.Texture.anisotropy に 1 より大きい値を設定すると、異方性フィルタリングが有効になります。ただし、この機能は拡張機能であるため、すべての GPU でサポートされているとは限りません。

最適化

このWebGL のベスト プラクティスの記事でも説明されているように、WebGL(OpenGL)のパフォーマンスを改善する最も重要な方法は、描画呼び出しを最小限に抑えることです。World Wide Maze の初期開発では、ゲーム内のすべての島、橋、ガードレールは個別のオブジェクトでした。その結果、2,000 回を超えるドロー呼び出しが発生し、複雑なステージが扱いにくくなることがありました。ただし、同じタイプのオブジェクトをすべて 1 つのメッシュにまとめると、ドローコールが 50 程度に減り、パフォーマンスが大幅に向上しました。

さらなる最適化のために、Chrome のトレース機能を使用しました。Chrome のデベロッパー ツールに含まれるプロファイラでは、メソッドの全体的な処理時間をある程度把握できますが、トレースでは各部分の所要時間を 1/1000 秒単位で正確に把握できます。トレースを使用する方法については、こちらの記事をご覧ください。

最適化

上記は、ボールの反射の環境マップを作成した際のトレース結果です。Three.js の関連性が高いと思われる場所に console.timeconsole.timeEnd を挿入すると、次のようなグラフが得られます。時間は左から右に流れ、各レイヤはコールスタックのようなものです。console.time を console.time 内にネストすると、さらに測定できます。上のグラフが最適化前、下のグラフが最適化後です。上部のグラフに示すように、プリ オプティマイゼーション中に、レンダリング 0 ~ 5 のそれぞれで updateMatrix(単語は省略されています)が呼び出されています。オブジェクトの位置や向きが変更された場合にのみ必要なプロセスであるため、このプロセスが 1 回だけ呼び出されるように変更しました。

トレース プロセス自体はリソースを消費するため、console.time を過度に挿入すると、実際のパフォーマンスと大幅にずれてしまい、最適化の対象を特定するのが難しくなります。

パフォーマンス調整ツール

インターネットの性質上、ゲームは仕様が大きく異なるシステムでプレイされる可能性があります。2 月上旬にリリースされた Find Your Way to Oz では、IFLAutomaticPerformanceAdjust というクラスを使用して、フレームレートの変動に応じてエフェクトを縮小し、スムーズな再生を実現しています。World Wide Maze は同じ IFLAutomaticPerformanceAdjust クラスをベースに構築されており、ゲームプレイを可能な限りスムーズにするために、次の順序でエフェクトをスケールバックします。

  1. フレームレートが 45 fps を下回ると、環境マップの更新が停止します。
  2. それでも 40 fps を下回る場合は、レンダリング解像度が 70%(サーフェス比の 50%)に低下します。
  3. それでも 40 fps を下回る場合は、FXAA(アンチエイリアス)が削除されます。
  4. それでも 30 fps を下回る場合は、グロー効果が除去されます。

メモリリーク

three.js では、オブジェクトをきれいに削除するのは面倒です。しかし、そのままにしておくとメモリリークが発生することは明らかであるため、以下の方法を考案しました。@rendererTHREE.WebGLRenderer を参照します。(three.js の最新リビジョンでは、割り当て解除の方法が若干異なるため、そのままでは機能しない可能性があります)。

destructObjects: (object) =>
  switch true
    when object instanceof THREE.Object3D
      @destructObjects(child) for child in object.children
      object.parent?.remove(object)
      object.deallocate()
      object.geometry?.deallocate()
      @renderer.deallocateObject(object)
      object.destruct?(this)

    when object instanceof THREE.Material
      object.deallocate()
      @renderer.deallocateMaterial(object)

    when object instanceof THREE.Texture
      object.deallocate()
      @renderer.deallocateTexture(object)

    when object instanceof THREE.EffectComposer
      @destructObjects(object.copyPass.material)
      object.passes.forEach (pass) =>
        @destructObjects(pass.material) if pass.material
        @renderer.deallocateRenderTarget(pass.renderTarget) if pass.renderTarget
        @renderer.deallocateRenderTarget(pass.renderTarget1) if pass.renderTarget1
        @renderer.deallocateRenderTarget(pass.renderTarget2) if pass.renderTarget2

HTML

個人的には、WebGL アプリの最大のメリットは、HTML でページ レイアウトを設計できることだと思います。Flash や openFrameworks(OpenGL)でスコアやテキスト表示などの 2D インターフェースを構築するのは面倒です。Flash には IDE が少なくともありますが、openFrameworks は慣れていないと難しいです(Cocos2D などを使用すると簡単になるかもしれません)。一方、HTML では、ウェブサイトを構築する場合と同様に、CSS を使用してフロントエンドのデザインをすべて正確に制御できます。粒子がロゴに凝縮されるような複雑なエフェクトは不可能ですが、CSS 変換の機能内で可能な 3D エフェクトもあります。World Wide Maze の「GOAL」と「TIME IS UP」のテキスト エフェクトは、CSS 遷移(Transit で実装)のスケールを使用してアニメーション化されています。(背景のグラデーションは WebGL を使用しています)。

ゲーム内の各ページ(タイトル、結果、ランキングなど)には独自の HTML ファイルがあり、これらがテンプレートとして読み込まれると、適切なタイミングで適切な値とともに $(document.body).append() が呼び出されます。1 つの問題は、追加前にマウス イベントとキーボード イベントを設定できなかったため、追加前に el.click (e) -> console.log(e) を試しても機能しなかったことです。

国際化(i18n)

HTML で作業すると、英語版の作成にも便利でした。国際化のニーズに合わせて、ウェブ i18n ライブラリである i18next を使用することを選択しました。このライブラリは、変更なしでそのまま使用できました。

ゲーム内のテキストの編集と翻訳は Google ドキュメントのスプレッドシートで行っていました。i18next には JSON ファイルが必要であるため、スプレッドシートを TSV にエクスポートし、カスタム コンバータで変換しました。リリース直前に多くの更新を行ったため、Google ドキュメント スプレッドシートからエクスポート プロセスを自動化すれば、作業が大幅に楽になっていたと思います。

ページは HTML で作成されているため、Chrome の自動翻訳機能も正常に機能します。ただし、言語が正しく検出されず、まったく別の言語と誤って認識されることがあります(ベトナム語)に対応していないため、現在この機能は無効になっています。(メタタグを使用して無効にできます)。

RequireJS

JavaScript モジュール システムとして RequireJS を選択しました。ゲームの 10,000 行のソースコードは、約 60 個のクラス(= coffee ファイル)に分割され、個々の js ファイルにコンパイルされます。RequireJS は、依存関係に基づいてこれらの個々のファイルを適切な順序で読み込みます。

define ->
  class Hoge
    hogeMethod: ->

上記で定義したクラス(hoge.coffee)は次のように使用できます。

define ['hoge'], (Hoge) ->
  class Moge
    constructor: ->
      @hoge = new Hoge()
      @hoge.hogeMethod()

動作させるには、hoge.js が moge.js の前に読み込まれる必要があります。また、「hoge」は「define」の最初の引数として指定されているため、hoge.js が常に最初に読み込まれます(hoge.js の読み込みが完了するとコールバックされます)。このメカニズムは AMD と呼ばれ、AMD をサポートしている限り、サードパーティ ライブラリを同じ種類のコールバックに使用できます。そうでない場合でも(three.js など)、依存関係が事前に指定されている限り、同様に動作します。

これは AS3 のインポートと似ているため、それほど奇妙ではありません。依存するファイルが増えた場合は、こちらの解決策を検討してください。

r.js

RequireJS には、r.js というオプティマイザーが含まれています。これにより、メインの js とすべての依存 js ファイルを 1 つにバンドルし、UglifyJS(または Closure Compiler)を使用して圧縮します。これにより、ブラウザが読み込む必要があるファイルの数とデータの合計量が減ります。World Wide Maze の JavaScript ファイルの合計サイズは約 2 MB ですが、r.js の最適化により約 1 MB に削減できます。gzip を使用してゲームを配信できる場合は、さらに 250 KB まで圧縮できます。(GAE には、1 MB 以上の gzip ファイルの送信を許可しない問題があるため、現在、ゲームは 1 MB のプレーンテキストとして非圧縮で配布されています)。

Stage Builder

ステージデータは、米国の GCE サーバーで完全に実行され、次のように生成されます。

  1. ステージに変換するウェブサイトの URL が WebSocket 経由で送信されます。
  2. PhantomJS がスクリーンショットを撮影し、div タグと img タグの位置を取得して JSON 形式で出力します。
  3. ステップ 2 のスクリーンショットおよび HTML 要素の位置情報に基づいて、カスタム C++(OpenCV、Boost)プログラムが不要な領域を削除し、島を生成してブリッジで島を接続し、ガードレールとアイテムの位置を計算してゴールポイントを設定します。結果は JSON 形式で出力され、ブラウザに返されます。

PhantomJS

PhantomJS は、画面を必要としないブラウザです。ウィンドウを開かずにウェブページを読み込むことができるため、自動テストやサーバーサイドでのスクリーンショットのキャプチャに使用できます。ブラウザ エンジンは Chrome や Safari と同じ WebKit であるため、レイアウトと JavaScript の実行結果も標準ブラウザとほぼ同じです。

PhantomJS では、JavaScript または CoffeeScript を使用して、実行するプロセスを記述します。こちらのサンプルに示すように、スクリーンショットのキャプチャは非常に簡単です。Linux サーバー(CentOS)で作業していたため、日本語を表示するフォント(M+ FONTS)をインストールする必要がありました。それでも、フォント レンダリングは Windows や Mac OS とは異なる方法で処理されるため、同じフォントでも他のマシンでは違って見えることがあります(ただし、違いは最小限です)。

img タグと div タグの位置の取得は、基本的に標準ページと同じ方法で処理されます。jQuery も問題なく使用できます。

stage_builder

最初は、ステージの生成に DOM ベースのアプローチ(Firefox 3D インスペクタに似たもの)を検討し、PhantomJS で DOM 分析のようなことを試みました。最終的には、画像処理のアプローチに落ち着きました。そのために、OpenCV と Boost を使用する C++ プログラム「stage_builder」を作成しました。次の処理を行います。

  1. スクリーンショットおよび JSON ファイルを読み込みます。
  2. 画像とテキストを「アイランド」に変換します。
  3. 島をつなぐ橋を作成します。
  4. 不要な橋を排除して迷路を作成します。
  5. 大きなアイテムを配置します。
  6. 小さなアイテムを置く。
  7. ガードレールを配置します。
  8. 位置情報データを JSON 形式で出力します。

各ステップについて、以下で詳しく説明します。

スクリーンショットおよび JSON ファイルの読み込み

スクリーンショットの読み込みには通常の cv::imread が使用されます。JSON ファイル用にいくつかのライブラリをテストしましたが、picojson が最も簡単に扱えるように思えました。

画像とテキストを「アイランド」に変換する

ステージビルド

上記は、aid-dcc.com のニュース セクションのスクリーンショットです(クリックすると実際のサイズが表示されます)。画像要素とテキスト要素は、アイランドに変換する必要があります。これらのセクションを分離するには、白い背景色(スクリーンショットで最も多く使用されている色)を削除する必要があります。設定が完了すると、次のように表示されます。

ステージビルド

白色の部分は、島の候補です。

テキストが細すぎてシャープすぎるため、cv::dilatecv::GaussianBlurcv::threshold を使用して太くします。画像コンテンツも欠落しているため、PhantomJS から出力された img タグデータに基づいて、それらの領域を白色で塗りつぶします。生成された画像は次のようになります。

ステージビルド

テキストが適切な塊を形成し、各画像が適切な島になりました。

島をつなぐ橋を構築する

島の準備が整うと、橋で接続されます。各島は、左、右、上、下の隣接する島を探し、最も近い島の最も近いポイントに橋を接続します。次のような結果になります。

ステージビルド

不要な橋を排除して迷路を作成する

すべての橋を残すとステージを移動しやすくなってしまうため、迷路を作成するには一部の橋を削除する必要があります。1 つの島(左上の島など)が開始点として選択され、その島に接続する橋のうち 1 つを除くすべての橋(ランダムに選択)が削除されます。残りの橋でつながっている次の島についても同じ操作を行います。道が行き止まりになったり、以前に訪れた島に戻ったりしたら、新しい島にアクセスできるポイントまで戻ります。すべての島がこの方法で処理されると、迷路が完成します。

ステージビルド

サイズの大きいアイテムを配置する

各アイランドのサイズに応じて、アイランドの端から最も離れたポイントから選択して、1 つ以上の大きなアイテムが配置されます。以下に、わかりにくい部分を赤で示します。

ステージビルド

これらの候補点から、左上の点が開始点(赤い円)、右下の点がゴール(緑の円)として設定され、残りの点から最大 6 個が大きなアイテムの配置に選択されます(紫の円)。

小さなアイテムを配置する

ステージビルド

島の端から一定の距離に線を配置し、その線に沿って適切な数の小さなアイテムを配置します。上記の画像(aid-dcc.com のものではありません)は、島の端から一定の間隔でオフセットして配置された、投影されたプレースメント ラインをグレーで示しています。赤い点は、小物が置かれている場所を示しています。この画像は開発中盤のバージョンのものであるため、アイテムは直線状に配置されていますが、最終バージョンでは、アイテムがグレーの線の両側に少し不規則に散らばっています。

ガードレールの配置

ガードレールは基本的に車線分離帯の外側の境界に沿って設置されますが、橋で切断して通行できるようにする必要があります。この目的には、Boost のGeometry ライブラリが役立ちました。このライブラリにより、島の境界データが橋の両側の線と交差する場所を特定するなどのジオメトリ計算を簡素化できました。

ステージビルド

島の輪郭を示す緑色の線はガードレールです。この画像ではわかりにくいかもしれませんが、橋の部分に緑色の線はありません。これは、デバッグに使用される最終的なイメージで、JSON に出力する必要があるすべてのオブジェクトが含まれています。ライトブルーの点は小さなアイテムで、灰色の点は再起動ポイントとして提案されています。ボールが海に落ちると、ゲームは最も近い再開ポイントから再開されます。再開ポイントは、小さなアイテムとほぼ同じ方法で、島の端から一定の距離で一定の間隔で配置されています。

位置情報の JSON 形式の出力

出力にも picojson を使用しました。データは標準出力に書き込まれ、呼び出し元(Node.js)が受け取ります。

Linux で実行する C++ プログラムを Mac で作成する

このゲームは Mac で開発され、Linux にデプロイされましたが、OpenCV と Boost はどちらのオペレーティング システムにも存在するため、コンパイル環境が確立された後は、開発自体は難しくありませんでした。Xcode のコマンドライン ツールを使用して Mac でビルドをデバッグし、automake/autoconf を使用して構成ファイルを作成して、Linux でビルドをコンパイルできるようにしました。その後、Linux で「configure && make」を使用して実行可能ファイルを作成するだけでした。コンパイラ バージョンの違いにより、Linux 固有のバグがいくつか発生しましたが、gdb を使用して比較的簡単に解決できました。

まとめ

このようなゲームは Flash や Unity で作成できます。これにより、多くのメリットが得られます。ただし、このバージョンではプラグインは不要で、HTML5 と CSS3 のレイアウト機能は非常に強力であることが実証されています。タスクごとに適切なツールを用意することは重要です。個人的には、HTML5 で完全に作られたゲームとしては、非常に完成度が高いと感じました。まだ多くの点で改善の余地はありますが、今後の進化を楽しみにしています。