本文へスキップ

スレッドを使う ​

ブラウザのゲームは、処理を移さない限り1つのスレッドで動きます。物理、大群、経路探索、パーティクル流体のような重いロジックは、Web Worker に任せられます。ワーカーがシミュレーションしている間も、ページは描画と入力の読み取りを全速で続けます。複数のワーカーは SharedArrayBuffer で1つのメモリを共有できるので、データをやり取りせずに処理を分けられます。

SharedArrayBuffer は、クロスオリジン分離されたページでしか使えません。そのためにはサーバーから2つのレスポンスヘッダーを送る必要があります。debuff は開発中にこのヘッダーを送り、ヘッダーファイルを読むホスト向けにファイルも書き出します。@debuffjp/cli 0.23.0 から使えます。

spryte のフローデモは、川の流体をこの方法で8つのワーカーで動かしています。

分離を有効にする ​

ts
// 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 のオプションにチェックを入れるよう知らせます(後述)。

ゲームの中では次のように確認します。

ts
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 ではページが分離されません。

ts
export default defineConfig({ isolation: { embed: "credentialless" } });

ホストごとに必要なこと ​

ホストすること
Wavedash何もしなくて大丈夫です。Wavedash はすべてのゲームを分離して動かします。ゲームが通信するすべてのサーバーもヘッダーを送る必要があります。
Cloudflare Pages、Netlify何もしなくて大丈夫です。debuff build が書き出す dist/_headers を読みます。
itch.ioEmbed 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 になり、ステップの速さが半分になることがあります。時間に十分な余裕があるうちは短い待機を繰り返し、最後の数ミリ秒はスピンで待ちます。

ts
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 です。