Compute : 名作ビデオゲーム『DOOM』を移植する
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ステップで行いました。
プラットフォーム非依存のコード (つまり、特定のアーキテクチャやプラットフォームのシステムコール、SDK に依存しないコード) をコンパイルして実行する。これが一般的な「ゲームプレイ」の大部分にあたります。
必要に応じ、プラットフォーム固有の 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 framebufferbyte* framebuffer = GetFramebuffer(&framebuffer_size);BodyWrite(bodyhandle, framebuffer, framebuffer_size,...);SendDownStream(handle, bodyhandle, 0);
ブラウザで動作しているクライアントが Compute からの http レスポンスを受信すると、フレームバッファが解析され、ブラウザでレンダリングされます。
ステート
この新しいモデルでゲームループを再現するには、後続のフレームを Compute から取得する際にゲームのどの地点にいるかを新しいインスタンスに伝えるために、どこかにステートを保存する必要がありました。これには『DOOM』のセーブロード機能を利用することができました。この機能は、プレイヤーがゲームのステートをディスクに保存し、後ほど中断した所からゲームプレイを続けることができるように、もともと存在していたものです。
ステートの保存には、フレームバッファと同じ仕組みを使用しました。ゲームフレームの終わりにセーブシステムを呼び出してゲームのステートを表すバッファを取得し、http レスポンスを呼び出し元に返す際に、フレームバッファにピギーバックさせました。
// gets a pointer to the framebufferbyte* resp = GetFramebuffer(&framebuffer_size);// gets the gamestate, appends it to the framebufferresp+fb_size = GetGameState(&state_size);
BodyWrite(bodyhandle, framebuffer, framebuffer_size + state_size,...);SendDownStream(handle, bodyhandle, 0);