スレッドを使う
ブラウザのゲームは、処理を移さない限り1つのスレッドで動きます。物理、大群、経路探索、パーティクル流体のような重いロジックは、Web Worker に任せられます。ワーカーがシミュレーションしている間も、ページは描画と入力の読み取りを全速で続けます。複数のワーカーは SharedArrayBuffer で1つのメモリを共有できるので、データをやり取りせずに処理を分けられます。
SharedArrayBuffer は、クロスオリジン分離されたページでしか使えません。そのためにはサーバーから2つのレスポンスヘッダーを送る必要があります。debuff は開発中にこのヘッダーを送り、ヘッダーファイルを読むホスト向けにファイルも書き出します。@debuffjp/cli 0.23.0 から使えます。
spryte のフローデモは、川の流体をこの方法で8つのワーカーで動かしています。
分離を有効にする
// debuff.config.ts
import { defineConfig } from "@debuffjp/cli";
export default defineConfig({
isolation: true,
});debuff devは、すべてのレスポンスで3つのヘッダーを送ります。Cross-Origin-Opener-Policy: same-origin、Cross-Origin-Embedder-Policy、Cross-Origin-Resource-Policy: same-originです。debuff buildは、これらをdist/_headersに書き出します。public/に自分の_headersがある場合は、そこに追記します。ただし、そのファイルがすでにCross-Origin-Embedder-Policyを設定しているなら変更しません。debuff doctorは、index.htmlがほかのサイトから読み込むスクリプト、フレーム、スタイル、画像について警告します。debuff ship itchは、itch.io のオプションにチェックを入れるよう知らせます(後述)。
ゲームの中では次のように確認します。
const threads = globalThis.crossOriginIsolated ? Math.min(8, navigator.hardwareConcurrency - 1) : 1;シングルスレッドで動く経路は必ず残してください。ページを分離しないブラウザやホストもあります。そこでも、余裕が少ないだけでゲームは動くべきです。
分離の代償
ページを分離すると、ほかのサイトのファイルは Cross-Origin-Resource-Policy か CORS のヘッダーで許可していない限り、ブラウザに拒否されます。CDN のフォントやスクリプト、アナリティクス、広告、埋め込み動画がよく影響を受けます。できるだけ自分でホストしてください。ページとやり取りする必要があるサインインのポップアップ(一部の OAuth)も動かなくなります。
isolation: true は embed: "require-corp" を使い、どのブラウザでも動きます。embed: "credentialless" は、ほかのサイトのファイルを拒否する代わりに Cookie なしで読み込むので、サードパーティのファイルには優しい方法です。ただし Safari はこれに対応していないため、Safari ではページが分離されません。
export default defineConfig({ isolation: { embed: "credentialless" } });ホストごとに必要なこと
| ホスト | すること |
|---|---|
| Wavedash | 何もしなくて大丈夫です。Wavedash はすべてのゲームを分離して動かします。ゲームが通信するすべてのサーバーもヘッダーを送る必要があります。 |
| Cloudflare Pages、Netlify | 何もしなくて大丈夫です。debuff build が書き出す dist/_headers を読みます。 |
| itch.io | Embed options の SharedArrayBuffer support にチェックを入れます。itch.io では実験的な機能です。ゲームが新しいドメインに移るのでプレイヤーのセーブはやり直しになり、Safari のプレイヤーにはポップアップウィンドウで表示されます。 |
| Vercel | プロジェクトの vercel.json に3つのヘッダーを追加します("headers": [{ "source": "/(.*)", "headers": [...] }])。 |
| GitHub Pages | 独自のヘッダーを送れません。coi-serviceworker がサービスワーカーからヘッダーを追加しますが、初回訪問時に1回リロードが入ります。 |
| 自分のサーバー | ゲーム以下のすべてのレスポンスで3つのヘッダーを送ります。 |
ワーカーでシミュレーションする
フローデモで使っている形を、4つに分けて説明します。
ワーカーは固定ステップで動かします。 debuff のゲームループと同じく、シミュレーションを1秒に60回の固定間隔で進めます。1ステップが割り当て時間を超えたら、追いつくために余分なステップを回すのではなく、遅れた時間を捨てます。余分なステップを回すと次のフレームがさらに遅くなり、どんどん遅れていきます。時間を捨てれば、シミュレーションはスローモーションになっても、ページは滑らかなままです。
ページは直近2つのステップの間を描きます。 各パーティクルについて、最新ステップの開始時と終了時の位置を持っておき、現在のフレームが次のステップにどれだけ進んだかに応じて、その間の位置に描きます。1ステップ遅れて描くので数ミリ秒の遅延が出ますが、シミュレーションの速さにかかわらず動きが滑らかになります。シミュレーションが毎ステップでパーティクルを並べ替える場合(たとえば高速化のためにグリッドのセル順に並べる場合)は、パーティクルごとに両方の位置を送ってください。次のステップでは、インデックス i が同じパーティクルを指さないからです。
受け渡しの方法を選びます。 分離していない場合は、各ステップの配列を postMessage で転送します(postMessage(data, [data.buffer]))。こうすればコピーは発生しません。分離している場合は、ワーカーが SharedArrayBuffer に書き込み、ページがそれを直接読めます。ダブルバッファにして、各ステップの後に Atomics のカウンターを切り替えれば、書き込み途中のステップをページが読むことはありません。
ワーカーの間隔は期限で管理します。 タイムアウト付きの Atomics.wait は、OS のタイマーと同じ精度しかありません。Windows では 14 ms の待機が約 30 ms になり、ステップの速さが半分になることがあります。時間に十分な余裕があるうちは短い待機を繰り返し、最後の数ミリ秒はスピンで待ちます。
deadline += 1000 / 60;
while (deadline - performance.now() > 3) Atomics.wait(pace, 0, 0, 1);
while (performance.now() < deadline) {}ページのメインスレッドでは Atomics.wait を呼ばないでください。ブラウザがそこでの呼び出しを許可していません。
1つのシミュレーションを複数のワーカーに分ける
複数のワーカーが同じパーティクルを足並みをそろえて進めることもできます。各ワーカーが一部を受け持ち、フェーズの間にバリア(共有カウンターに対する Atomics.add、Atomics.wait、Atomics.notify)で待ち合わせます。近くのパーティクルを押しのける処理のように、あるパーティクルがほかのパーティクルへ書き込むフェーズは、先に形を変える必要があります。各パーティクルは受け取った力を合計し、自分自身にだけ書き込むようにします。この形は行き過ぎることがあるので、力を弱め、1ステップあたり2〜3回に分けて落ち着かせます。完全な例はフローデモの川のソースにあります。30,000 個のパーティクルで、1ステップが2ワーカーで 64 ms、8ワーカーで 20 ms です。