プログラミング
OpenStrikeのリリース:2004年の携帯ゲーム機で動くCounter-Strike風FPS
Shipping OpenStrike: A Counter-Strike-Shaped FPS on a 2004 Handheld (pocketjs.dev)
要約
この記事では、2004年のSony PSPで動作する、Counter-Strike風のシングルプレイヤーFPS「OpenStrike」のリリースを発表しています。Rustでゲームエンジンを、TypeScriptとSolidJSでゲームロジックとUIを構築し、Web技術のエルゴノミクスを活かしつつもWebスタックのオーバーヘッドなしで、PSP上で60FPSを維持する単一の実行ファイルとして出荷されています。このプロジェクトは、最新のWeb開発手法が古いハードウェア上でのリアルタイム3Dゲームにも応用可能であることを示しており、ビルドプロセスで計算負荷の高い処理を事前に実行することでパフォーマンスを最適化しています。
全文翻訳
本日、OpenStrikeをリリースします。これは、シングルプレイヤーで、Counter-Strike風のFPSであり、クラシックなGoldSrc時代のマップをプレイできます。ボット、トレーサー、リコイル、ラウンドの流れ、そしてJSXのHUDを備えています。ラウンドのルールはTypeScriptで記述されています。HUDはSolidアプリです。そして、この全てが2004年のSony PSP上でロックされた60 FPSを維持し、Memory Stickにドロップするだけで動作する単一のファイルとして出荷されます。出荷ビルドのネイティブ480×272出力。このフレームとこの記事の他の全てのスクリーンショットは、エミュレーター上で実行されるバイト単位で正確なテストが使用しているPSP実行ファイル自身のフレームバッファーです。
OpenStrikeはpocket-stack/open-strikeでオープンソース化されています。これはPocketランタイムファミリー(PocketJSの基盤となるアーキテクチャ)上に構築された最初のゲームであり、「Webスタックのエルゴノミクスを、Webスタックの仕組みなしで」UIを超えて、iPhone以前のハードウェア上でリアルタイム3Dゲームにまで拡張できるという主張を証明するために存在します。この記事は、ブラウザ向けにJavaScriptを出荷したことはあるが、ゲームコンソールに触れたことのない人に向けて書かれています。組み込みやグラフィックスの背景知識は想定していません。これは完全な物語です。マシンが実際に何であるか、TypeScriptがどのように内部で動作するか、1999年のマップ形式があなたのバンドラーとどのように関係するか、シェーダーなしでどのように描画するか、そしてミリ秒がどこへ消えたのか。
問題のマシン
PSP-1000は、333 MHzのシングルコアMIPS CPUを搭載しています。インオーダー実行、投機的実行なし、そして—これが私たちにとって最も重要ですが—JavaScriptのJITなしです。そのため、全てのクロージャは解釈実行され、全てのフレームで実行されます。RAMは32 MBで、そのうちユーザープログラムが見えるのは24 MBです。あなたが今読んでいるこのブラウザタブは、このマシン全体のメモリよりも大きなJavaScriptヒープを持っています。
GPU—ソニーはこれをグラフィックスエンジンと呼んでいます—はプログラマブルシェーダーよりも前のものです。頂点シェーダーやフラグメントシェーダーをアップロードすることはできません。アップロードする場所がありません。これは固定機能パイプラインです。三角形を変形させ、その各三角形の色を補間し、バイリニアフィルタリングでテクスチャをサンプリングし、深度テストを行い、ブレンド—レジスタで設定される固定メニューで、2 MBのビデオメモリから480×272の画面を出力します。そして、あなたが意味するようなオペレーティングシステムはありません。プロセス、仮想メモリ、動的リンカー、console.logの出力先はありません。ゲーム全体—Rustエンジン、JavaScriptインタープリター、JSバンドル、マップ—は単一の実行ファイル(PSPの.exeであるEBOOT.PBP)にリンクされ、実行されるとマシンが渡されます。クラッシュすると、マシンがクラッシュします。それがデプロイターゲットです。これが私たちがデプロイしたものです。
1つの製品、2つのマシン
OpenStrikeは、Web開発者がすぐに認識する線—ネイティブアプリの分割—に沿って分割され、移植されています。エンジンはRust、製品はJavaScriptです。Rust側(openstrike-coreとPocket3Dレンダラー)は、フレームを絶対に落としてはいけない全てのものを所有しています:プレイヤーの移動と衝突、ボットAI、弾丸、そして世界の描画。JavaScript側は、これを単なるゲームではなく「このゲーム」にしている全てのものを所有しています:rules.tsはラウンドの流れ、スコアリング、武器とボットのチューニングテーブルです。hud.tsxは、ヘルス、弾薬、クロスヘア、スコア—通常のSolidコンポーネントツリーでTailwindクラスでスタイリングされ、PocketJS上で実行されるHUD全体です。ベースゲームは、modが持たないような特権を一切与えません。rules.tsを変更すれば、あなたはmodを作成したことになります。
実行ファイルに埋め込まれたJSエンジンはQuickJSです—Fabrice Bellardによる完全なES2023エンジンで、数百キロバイトにコンパイルされます。QuickJSは、NodeにとってのV8のように、このPSPにとってのものです。ホストはそれにstrike APIサーフェスとui APIサーフェスを渡し、同じopenstrike.jsバンドルが全てのターゲットでそれらに対してブートします。全てのターゲット—複数形、そしてそれがリリースの真のヘッドラインです:openstrike.js + openstrike.pakという製品—TypeScript、単一のバンドル、両方のマシンでバイト単位で同一です。
rules.ts · ラウンドの流れ、スコアリング、武器+ボットテーブル
hud.tsx · HUD—Solidアプリ、Tailwindクラス
QuickJSゲスト rquickjs経由で埋め込み
strike + uiサーフェス
状態スナップショット ↓ · コマンド ↑
openstrike-core — シミュレーション
no_std Rust、共有 verbatim
pocket3d · wgpuレンダラー
Metal / Vulkan / DX12、任意の解像度あなたのラップトップ
cargo run -p openstrike
QuickJS、MIPS用インタープリターのみにコンパイル—このCPUではJITなし
strike + uiサーフェス 同じ語彙、フィールドごとにミラーリング
openstrike-core — シミュレーション 同じクレート、MIPS用に再コンパイル
pocket3d-gu · sceGuレンダラー
固定機能GE、480×272、2 MB VRAM
2004年のSony PSP
1つのEBOOT.PBP — エンジン、JS、マップ内蔵
事実(facts)は下へ流れる(状態スナップショット+イベント) · 意図(intent)は上へ流れる(コマンド) · ゲストはシミュレーションをブロックしない
ティックの順序は固定されており、内部化する価値があります。なぜなら、後続の全てがそれに依存するからです。毎秒60回:16.7 msごと(1つの垂直ブランク)
pad → SimInputボタン+アナログスティック
sim.tick(dt) 移動、ボット、弾丸 Rust
strike.__dispatch(state, events) QuickJSへの1回の呼び出し
├─ rules.ts 反応し、コマンドをキューイング
└─ hud.tsx 変更されたバインディングのみを再レンダリング
drain commands → core setPhase, configureWeapon… Rust
draw PVS → batches → GE
事実(facts)は、イベントのバッチ(ヒット、プレイヤー死亡、ラウンドリセット)に加えて、プレーンな状態スナップショット(HP、弾薬、フェーズ、生きているボットなど)として下へ流れます。意図は、キューイングされたコマンドとして上へ流れます。JavaScriptはフレームごとに正確に1回だけ参照され、シミュレーションをブロックすることは決してありません—PocketJSがUIのために既に強制していた、フレームあたりの同じワンクロス(one-crossing)規律が、今やゲームのルールを所有しています。
1999年のジオメトリのためのバンドラー
次に3D部分です、ゼロから。GoldSrc時代のマップ—Half-LifeとオリジナルのCounter-Strikeの背後にあるフォーマット—は三角形のスープではありません。それはBSPファイルです:レベルのポリゴンは、バイナリ空間分割木に事前に整理されて格納されています。これは1990年代のデータ構造で、再帰的に世界を平面でスライスし、全ての位置がリーフと呼ばれる凸状のセルに入るまで分割します。このフォーマットのキラー機能はPVS—Potentially Visible Setです。各リーフに対して、ファイルは、ドアを通り抜け、箱を越え、あらゆる角度から、その内部からでも見える可能性のある他の全てのリーフの、事前に計算された圧縮ビットセットを格納します。それを計算するには、レベルデザイナーのマシンが1999年に一度、数分かかりました。それを消費するには、永遠にマイクロ秒かかります。Webの言葉で言うと:PVSは、ロックファイルとしてのオクルージョンカリングです。高負荷な思考は全てオフラインで行われ、ランタイムは単に答えを参照します。Dust2の中庭に立っているとき、レンダラーはトンネルのジオメトリを全く考慮しません—「速く描画する」のではなく、それに触れることすらありません。
私たちはその1999年の精神を維持し、それをモダンな動きで拡張しました:コンソールをデプロイターゲットとして扱い、その前にコンパイラを置きます。PSPは、BSPファイルを解析したり、テクスチャをデコードしたり、ロード時にデータを再整形したりする余裕はありません—それは中間コピーのためのRAMと、ボットに使いたいCPUを消費します。そのため、OpenStrikeのビルドステップはラップトップ上でpocket3d-cookを実行し、マップと.wadテクスチャアーカイブを読み込み、.p3dを生成します:PSPのGPUが読み込みたいバイトが、正確なレイアウト、アラインメント、バイトオーダーで格納されているファイルです。
de_dust2.bsp BSP v30 — GoldSrc, 1999 + .wadテクスチャアーカイブ(ゲームの自身のコピーから—マップデータは再配布されません)
pocket3d-cook ビルド時間、ラップトップ上で
· 32ユニットグリッド上のダイスの面
· ライトマップ → 頂点カラー
· 位置をi16に量子化
· pow²、バイリニアにリサンプリング
· CLUT8 + スワイズル + ミップス
· PVS + 衝突形状をパック
GPUのDMAエンジンが読み取るそのままのバイトレイアウト
de_dust2.p3d — 3.8 MB
WVTX 58k頂点 × 20 B
WIDX u16インデックス
WBAT ドローバッチ
WTEX スワイズル化されたCLUT8 + ミップス
WVIS PVS
WCLP 衝突形状
WENT スポーン、太陽、境界
ロード時に解析ゼロ、コピーゼロ
EBOOTにリンク—GPUはこれらのバイトをインプレースで読み取ります。「レベルのロード」は1回のキャッシュライトバック呼び出しです。
「ベイク」とは、ランタイムコストをビルド時間に移動させることを指す言葉で、クッカーは積極的にベイクします:ライティングは頂点カラーにベイクされます。GoldSrcマップはライトマップ—表面ごとの小さなシャドウテクスチャ—をシップします。GEはそれらをマルチテクスチャ化できましたが、各テクスチャユニットは私たちが使いたくない帯域幅を消費します。そのため、クッカーは各ポリゴンをグリッドに分割し、ライトマップを頂点にサンプリングします。