ブログに戻る

フォロー&ご登録

Compute : 名作ビデオゲーム『DOOM』を移植する

Justin Liew

Senior Software Engineer

id Software 社の『DOOM』は、その移植性の高いコードベースとクリーンな抽象化のため、ゲーム史上最も多く移植されたゲームの1つとされています。Fastly のサーバーレスコンピューティング環境に構築された Compute の機能を試すべく、プラットフォームに『DOOM』を移植する実験を行いました。 

『DOOM』が Compute 上でインタラクティブに動作することがわかれば、プラットフォームの新境地を開拓することができると考えました。具体的なデモを作成することで、Compute がもたらすエキサイティングな可能性を紹介することが今回の目的でした。では、移植の手順についてご説明します。

『DOOM』のこれまでの歴史

『DOOM』は1993年に id Software 社が開発し、同年12月に発売したゲームです。高品質な 2D ゲームの開発を専門としていた id Software は、1992年の『Wolfenstein』、そして翌年の『DOOM』の公開で 3D ゲームへと画期的な飛躍を遂げ、PC ハードウェアの急速な進化の波に乗り、ゲーム業界の限界を押し広げました。

『DOOM』は1997年にオープンソース化され、README ファイルには「お好きな OS に移植してください」と書かれていました。以来『DOOM』は多くのファンによって、何百もの多種多様なプラットフォームに移植されてきました。『DOOM』のファンであると同時に Fastly 社員でもある私は、ぜひこのゲームを使って Compute の可能性を試してみたいと思いました。今回は、この名作ビデオゲームを Compute に移植した方法をご紹介します。

このプロジェクトを行うにあたって、Fabien Sanglard 著『Game Engine Black Book』という本が貴重な資料で、とても参考になりました。彼は『Wolfenstein』に関する著書も出版していますが、両方ともゲーム開発の歴史における重要な出来事を深く研究された内容で、非常に参考になる面白い本です。

移植

『DOOM』の移植は、次の2ステップで行いました。

  1. プラットフォーム非依存のコード (つまり、特定のアーキテクチャやプラットフォームのシステムコール、SDK に依存しないコード) をコンパイルして実行する。これが一般的な「ゲームプレイ」の大部分にあたります。

  2. 必要に応じ、プラットフォーム固有の API コールをターゲットプラットフォーム用に置き換える。これは、主にレンダリングやオーディオなどの入出力を扱うコードです。

C バインディングの公式な公開インターフェースはないので、自宅で試す場合は fastly-sys クレートから C API を逆引きする必要があります。

共通コード

レンダリングやオーディオなしで『DOOM』を Compute で動作させること自体は、比較的簡単でした。コードベースにはすべての関数名にプリフィックスがついており、実装に特化した関数には「I_」が使われているため、コードベースを調べ、コンパイルからこれらを削除することは難しくありませんでした。それが終わったら、wasi-sdk を使って Wasm のバイナリをターゲットにしました。WebAssembly はネイティブコードを手間をかけずにコンパイルするように設計されているため、この変更は非常に簡単でした。

ゲームを WebAssembly バイナリとして動作させるために修正が必要だった背景には、『DOOM』が32ビットコンピューティングの時代に開発されたことがあります。ポインタが4バイトであると仮定してコードが書かれている箇所がいくつかありますが、これは当時は完全に合理的な選択でした。『DOOM』のデータは、リリース時に開発チームが作成しまとめた、すべてのアセットを含むファイルから読み込まれます。このデータは直接メモリに読み込まれ、ゲーム内の C 構造体にキャストされます。これら構造体にポインタが含まれている場合、64ビット環境でデータをロードすると、構造体に正しくオーバーレイされず、予期しない動作が発生します。これらの問題を突き止めることはごく簡単でしたが、最初は明らかなクラッシュが何度か発生しました。

ゲームループの変更

共通のコードを Compute 上で実行するには、『DOOM』が採用していた従来のゲームループをリファクタリングする必要がありました。一般的なゲームは、初期化後にキーボードやマウス、コントローラなどのローカルなデバイスからの入力を受けて映像や音声を出力するという、入力 → シミュレーション → 出力を任意の頻度で繰り返し行う無限ループで動作します。しかし、Compute ではインスタンスが起動して作業を行った後、呼び出し元に戻ることが目的となっているため、このようなプロセスは最終的にプラットフォームによって削除されてしまいます。そこでループを完全に削除し、インスタンスがゲームの1フレームのみ実行するように変更しました。 

結果的に、以下のようにループで実行されるようになりました。

次のセクションでは、それぞれのステップについてさらに詳しく説明していきます。

出力

ビデオゲームでは、プレイヤーに表示される最終的な画像を保存するメモリをフレームバッファと呼びます。最近のゲームでは、フレームバッファは専用の GPU ハードウェアで構築されていることが多く、最終的な画像は GPU 上でいくつものパイプラインステップを実行した結果として得られることが多くなっています。しかし1993年当時、レンダリングはソフトウェアで行われており、『DOOM』の最終的なバッファは基本的な C 言語の配列でプログラマーが利用できるようになっていました。このシンプルで分かりやすい設計のおかげで、開発者は比較的容易に『DOOM』を新しいプラットフォームに移植することができたわけです。

Compute の場合、フレームバッファをプレイヤーのブラウザに返して表示させたいと考えました。そのためには、C API を使ってフレームバッファをレスポンスボディに書き込み、それをダウンストリームに送るだけの簡単な手順でした。

// gets a pointer to the framebuffer
byte* framebuffer = GetFramebuffer(&framebuffer_size);
BodyWrite(bodyhandle, framebuffer, framebuffer_size,...);
SendDownStream(handle, bodyhandle, 0);

ブラウザで動作しているクライアントが Compute からの http レスポンスを受信すると、フレームバッファが解析され、ブラウザでレンダリングされます。

ステート

この新しいモデルでゲームループを再現するには、後続のフレームを Compute から取得する際にゲームのどの地点にいるかを新しいインスタンスに伝えるために、どこかにステートを保存する必要がありました。これには『DOOM』のセーブロード機能を利用することができました。この機能は、プレイヤーがゲームのステートをディスクに保存し、後ほど中断した所からゲームプレイを続けることができるように、もともと存在していたものです。

ステートの保存には、フレームバッファと同じ仕組みを使用しました。ゲームフレームの終わりにセーブシステムを呼び出してゲームのステートを表すバッファを取得し、http レスポンスを呼び出し元に返す際に、フレームバッファにピギーバックさせました。

// gets a pointer to the framebuffer
byte* resp = GetFramebuffer(&framebuffer_size);
// gets the gamestate, appends it to the framebuffer
resp+fb_size = GetGameState(&state_size);

BodyWrite(bodyhandle, framebuffer, framebuffer_size + state_size,...);
SendDownStream(handle, bodyhandle, 0);

この変更に伴い、フレームバッファとステートを分離し、ステートはローカルに保存、フレームバッファはブラウザに表示するようにクライアントを変更しました。次回 Compute にリクエストを発行する際には、リクエストボディで渡されたステートを Compute のインスタンスが読み取り、以下のようにゲームに渡すことができます。

BodyRead(bodyhandle, buffer,...);
LoadGameFromBuffer(buffer);

この時点でゲームフレームを実行すると、まるでゲームのステートを保存した直後のように実行されます。

入力

次に必要なのは、実際にゲームをプレイするためのユーザー入力です。『DOOM』の入力システムは、入力イベントという概念で抽象化されています。例えば「プレイヤーが W キーを押した」とか「プレイヤーがマウスを X 方向に動かした」などです。標準的な Javascript のイベントリスナーを使って、『DOOM』が対応できる入力イベントをブラウザ上で簡単に生成することが可能です。

document.addEventListener(‘keydown’, (event) => {
	// save event.keyCode in a form we can send later
});

これらの入力イベントは、Compute への http リクエストの際に、ステートと共に送信されます。するとインスタンスは、フレームを実行する前に、入力イベントをゲームエンジンに渡すことができる形式に解析します。

最適化

このデモの最初のバージョンは、1往復あたり約200ミリ秒で動作しました。これは、インタラクティブなゲームとしては許容範囲外です。一般的なゲームでは33ミリ秒 (30 FPS) または16ミリ秒 (60 FPS) で動作します。レイテンシは更新頻度に大きな影響を及ぼすため、最初のバージョンより4倍早い50ミリ秒を目標にすることにしました。

今回ゲームを最適化する際に注目したポイントは、連続したゲームループの実行から1フレーム単位の実行へ変更することでした。ゲームシステムの多くは、各ティックが前フレームの差分であるという概念に基づいて設計されています。セーブゲームには取り込まれないものの、判断を下すためにフレームごとに使用されるステートが、ゲームによって保持されます。システムの機能性とパフォーマンスのため、これらのシステムではある程度の調整を行う必要がありました。またシステムの多くは、最初のフレームの状態 (つまり多くの変数やステートが初期化されている状態) ではないかのように動作している場合に、最も効果的に機能しました。  

また、ゲームでは起動時に多くの事前計算が行われるようになっていました。そのほとんどが、三角法を使ったビュー空間とワールド空間の計算でした。事前計算テーブルはゲームの画面解像度の情報が必要だったため、ランタイム時に計算が行われていた訳です。今回はレンダリング解像度を固定していたため、テーブルをコンパイル済みのバイナリに埋め込むことで、フレームごとの起動時の計算を回避することができました。

最終的には、1ティックあたり50 - 75ミリ秒での動作が可能になりました。本来の『DOOM』に近づけるためにはまだ改善が必要ですが、Compute でこのようなプロジェクトを反復して行うことが可能であることを証明することができました。

今回のポイント

今回は私にとって初の Compute の試みだったこともあり、手探りの状態でデバッグや反復作業に臨みました。Compute では断続的に、そして積極的に改善が進められています。私がこの作業に取り組んだ3週間の間でも、デプロイの信頼性やデバッグのしやすさなど、様々な面での改善が見られました。特に便利だったのは『DOOM』から出力されるプリントをほぼリアルタイムで見ることができる Log Tailing 機能でした。まだレンダリングがうまく動作していない時点での不透明な C プログラムでの反復作業において、この機能がデバッグ作業で非常に役立ちました。全体として Compute へのデプロイは、従来のビデオゲーム機などでの作業に似ていたと思います。

正直、Compute は頻繁なゲームアップデートを必要とするリアルタイムゲームの実行に理想的なソリューションだとは言えません。今回の方法で『DOOM』のようなゲームを実行することで得られるメリットは特にありませんでした。ですが、今回のプロジェクトの目的はプラットフォームの限界に挑むことでした。Compute の可能性の限界を試し、興味深いデモを作成することで、プラットフォームに関心を持ってもらい、インスピレーションを与えることが今回の目標でした。ビデオゲームを Comput に移植した利用事例は他にも存在すると思いますが、今後もより多くの企業に興味を持っていただけるよう、このプラットフォームが秘める可能性を紹介していきたいと思っています。

興味がある方は、ぜひ Developer Hub でデモをお試しください