事例紹介 - Inside World Wide Maze

ワールド ワイド 迷路は、ウェブサイトで作成された 3D 迷路をスマートフォンを使ってボールを操作し、ゴールポイントに到達しようとするゲームです。

ワールド ワイド 迷路

HTML5 の機能を豊富に使用するゲームです。たとえば、DeviceOrientation イベントによってスマートフォンから傾きデータが取得され、それが WebSocket 経由で PC に送信されます。プレーヤーはそこで WebGLWeb Worker によって構築された 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 で初めてデバイスを調整するようプレーヤーに促しますが、予期しない傾斜値を受け取ると、ゼロ位置に正しく調整されません。そのため、この API には内部で制限時間があり、制限時間内に調整できない場合はキーボード コントロールに切り替えるようプレーヤーに促します。

WebSocket

World Wide Maze では、スマートフォンと PC が WebSocket で接続されます。より正確には、スマートフォン間、つまりサーバー間、PC 間がリレーサーバーを介して接続されています。これは、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 で開いているページをモバイル デバイスで簡単に開くことができます(その逆も可能です)。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 の遅延の問題は、Chrome for Android の WebSocket(Nagle を無効にする TCP_NODELAY オプションを含む)では発生しませんでしたが、Chrome for iOS で使用される WebKit WebSocket で発生しました。WebKit WebSocket はこのオプションを有効にしていません。(同じ WebKit を使用している Safari でもこの問題が発生していました。この問題は Google 経由で Apple に報告されており、WebKit の開発版で解決済みのようです

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

Nagle アルゴリズム 1

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

Nagle アルゴリズム 2

対照的に、このグラフは米国内のサーバーを使用した結果を示しています。緑色の出力間隔は 100ms で安定していますが、入力間隔は 0ms の下限と 500ms の間で変動しており、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 と併せて「ウェブ ワーカー」として使用しました。

ウェブワーカー

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

FPS

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

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

Service Worker

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

パフォーマンス

ポリゴン数の多いステージはポリゴン数が 100,000 を超えることがありますが、全体を Physijs.ConcaveMesh(箇条書きの btBvhTriangleMeshShape)として生成してもパフォーマンスは特に悪化しませんでした。

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

ゴースト オブジェクト

衝突検出機能を備えていても、衝突しても他のオブジェクトには影響を及ぼさないオブジェクトを、箇条書きでは「ゴースト オブジェクト」と呼びます。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 には大きな変更が加えられているため、リリースでは元のリビジョンをそのまま残しました。

グロー効果

ボールのコアやアイテムに加えられるグロー効果は、いわゆる「カワセ メソッド MGF」の簡易版を使用して実装されます。ただし、Kawase メソッドではすべての明るい領域をブルームしていますが、World Wide Maze では、発光が必要な領域に対して別のレンダリング ターゲットを作成します。これは、ステージのテクスチャにはウェブサイトのスクリーンショットを使用する必要があり、たとえば背景が白の場合に、すべての明るい領域を抽出するだけでウェブサイト全体が光るからです。すべて HDR で処理することも検討しましたが、実装が非常に複雑になるため、今回はやめることにしました。

グロー

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

反射ボール

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

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

WebGL では、すべてのレンダリングにシェーダー(頂点シェーダー、フラグメント シェーダー)が必要です。try.js に含まれるシェーダーでは、すでにさまざまなエフェクトが可能ですが、より複雑なシェーディングや最適化を行うには、独自のシェーダーを作成することをおすすめします。World Wide Maze は物理エンジンによって CPU をビジー状態に保つため、(JavaScript による)CPU 処理が簡単だったとしても、なるべくシェーディング言語(GLSL)で記述して GPU を活用しようとしました。海の波効果はシェーダーに依存しています。もちろん、ゴール地点の花火やボールが現れたときに使用されるメッシュ効果も同様です。

シェーダー ボール

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

シェーダー メッシュ

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

poly2tri

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

poly2tri

上の図は、青いアウトラインが三角形に分割され、赤いポリゴンがどのように生成されるかを示しています。

異方性フィルタリング

標準的な等方性 MIP マッピングでは、水平軸と垂直軸の両方で画像が縮小されるため、ポリゴンを斜めから表示すると、World Wide Maze ステージの奥のテクスチャが水平方向に伸び、低解像度のテクスチャのように見えます。こちらの Wikipedia ページの右上の画像は、この良い例を示しています。実際には、より高い水平解像度が必要ですが、WebGL(OpenGL)では異方性フィルタリングと呼ばれる手法を使用して解決します。3.js では、THREE.Texture.anisotropy に 1 より大きい値を設定すると、異方性フィルタリングが有効になります。ただし、この機能は拡張機能であり、一部の GPU でサポートされているわけではありません。

最適化

こちらの WebGL のベスト プラクティスの記事でも言及されているように、WebGL(OpenGL)のパフォーマンスを向上させる最も重要な方法は、描画呼び出しを最小限に抑えることです。ワールド ワイド 迷路が開発当初は、ゲーム内の島、橋、ガードレールはすべて別々のオブジェクトでした。そのため、2,000 回以上のドローコールが発生し、複雑なステージが扱いにくくなっていました。しかし、同じ種類のオブジェクトをすべて 1 つのメッシュにまとめると、描画呼び出しが 50 程度に減り、パフォーマンスが大幅に向上しました。

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

最適化

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

当然、トレース プロセス自体がリソースを消費するため、console.time を過度に挿入すると実際のパフォーマンスから大きく外れ、最適化する領域を特定することが困難になります。

パフォーマンスの調整

インターネットの性質上、このゲームは、さまざまな仕様のシステムでプレイされる可能性があります。2 月初旬にリリースされた「Find Your Way to Oz」では、IFLAutomaticPerformanceAdjust というクラスを使用して、フレームレートの変動に応じてエフェクトを縮小し、スムーズに再生できるようにしています。ワールド ワイド 迷路は、同じ IFLAutomaticPerformanceAdjust クラスに基づいて構築され、ゲームプレイが可能な限りスムーズになるように、次の効果を縮小します。

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

メモリリーク

オブジェクトをきれいに排除するには、3.js で手間がかかります。しかし、これらを放置すると明らかにメモリリークにつながるため、次のように方法を考案しました。@rendererTHREE.WebGLRenderer を指します。(3.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 Transforms の機能内では 3D 効果を作成可能です。World Wide Maze の「GOAL」と「TIME IS UP」のテキスト エフェクトは、CSS Transition(Transit で実装)でスケールを使用してアニメーション化されます。(もちろん、背景のグラデーションには WebGL が使用されています)。

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

国際化(i18n)

HTML で作業することは、英語版の作成にも便利でした。国際化のニーズに対応するためにウェブの i18n ライブラリである i18next を使用することに決めましたが、変更せずにそのまま使用できました。

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

ページは HTML で作成されているため、Chrome の自動翻訳機能も正常に動作します。しかし、場合によっては言語を正しく検出できず、まったく異なる言語(例:ベトナム語)では使用できないため、この機能は現在無効になっています。(メタタグを使用して無効にできます)。

RequireJS

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

define ->
  class Hoge
    hogeMethod: ->

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

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

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

これは AS3 のインポートと似ているため、違和感はないでしょう。最終的に依存するファイルが増えた場合は、こちらの解決策をお試しください。

r.js

RequireJS には r.js というオプティマイザーが含まれています。これにより、main 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 の書式なしテキストとして非圧縮状態で配信されています)。

ステージ ビルダー

ステージデータは、次のように生成され、米国の 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 を使用する「stage_builder」という C++ プログラムを作成しました。次の処理を行います。

  1. スクリーンショットと JSON ファイルを読み込みます。
  2. 画像とテキストを「島」に変換します。
  3. 島をつなぐ橋を架けます。
  4. 迷路を形成するために不要な橋を使わずに済みます。
  5. 大きなアイテムを配置します。
  6. 小さなものを配置します。
  7. ガードレールを設置する。
  8. ポジショニング データを JSON 形式で出力します。

各手順について以下で詳しく説明します。

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

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

画像とテキストを「島」に変換する

ビルドの段階

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

ビルドの段階

白い部分が潜在的な島です。

テキストが細かすぎるため、cv::dilatecv::GaussianBlurcv::threshold で太くします。画像コンテンツもないため、PantomJS から出力された img タグデータを基に、これらの領域を白で塗りつぶします。次のような画像が表示されます。

ビルドの段階

テキストが適切な塊になり、それぞれの画像が適切な島の形になります。

島をつなぐ橋を作る

島の準備が整ったら、島は橋で接続されます。各島は、左、右、上、下に隣接する島を探し、最も近い島の最も近い地点に橋を接続します。結果は次のようになります。

ビルドの段階

迷路を形成するために不要な橋をなくす

すべての橋を置いておくと、ステージをナビゲートしにくくなるため、橋を取り除いて迷路を形成する必要があります。1 つの島(たとえば、左上の島)が開始点として選択され、その島に接続している 1 つの橋(ランダムに選択)を除くすべてが削除されます。次に、もう一方の橋でつながっている次の島についても同じことが行われます。道が行き止まりにたどり着くか、以前に訪れた島に戻ると、新しい島にアクセスできる地点まで後戻りします。すべての島がこのように処理されれば、迷路が完成します。

ビルドの段階

大きなアイテムの配置

各島には、島の寸法に応じて 1 つ以上の大きなアイテム(島の端から遠い地点を選んで配置)が配置されます。あまり明確ではありませんが、これらのポイントを以下に赤で示します。

ビルドの段階

これらすべてのポイントの中から、左上のポイントを始点(赤い円)、右下のポイントを目標(緑色の円)、残り最大 6 つのポイントを大きなアイテムの配置用に選択します(紫色の円)。

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

ビルドの段階

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

ガードレールの配置

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

ビルドの段階

島を囲む緑色の線はガードレールです。この画像ではわかりにくいかもしれませんが、橋があるところに緑色の線はありません。これはデバッグに使用される最終的な画像で、JSON に出力する必要があるすべてのオブジェクトが含まれています。水色の点は小さな項目で、灰色の点は再開ポイントを示しています。ボールが海に落ちると、最も近いリスタート地点からゲームが再開されます。再始点点は、小物とほぼ同様で、島の端から設定した距離で一定の間隔で配置されます。

ポジショニング データを JSON 形式で出力する

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

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

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

おわりに

このようなゲームは Flash や Unity で作成できるため、多くのメリットが得られます。ただし、このバージョンはプラグインを必要としておらず、HTML5 + CSS3 のレイアウト機能が非常に強力であることが証明されました。タスクごとに適切なツールを使用することは非常に重要です。個人的には、完全に HTML5 で作られたゲームがどれほどうまくいったかには驚きました。まだ多くの分野では欠けていますが、今後どのように展開していくか楽しみです。