スプライトレンダラーでレイキャスティング
Wolfenstein 風の迷路を 2D の短冊として描くことは、スプライトレンダラーにとって最も厳しいテストの1つだとわかりました。2dbench では、Chrome 上の RTX 5060 搭載ノート PC 1 台で、spryte は 10,000 体の敵を 60 fps で保ち、PixiJS は 2,000 体でした。
レイキャスターは Wolfenstein 3D を支えていた技術です。平らなグリッドのマップがあり、画面の列ごとにレイを 1 本飛ばし、壁は距離に応じた高さの縦の短冊として描きます。3D のジオメトリはありません。どのフレームも、テクスチャの 1 列ずつを表示する細い矩形の山でできています。
だからこそ 2D スプライトレンダラーとの相性は抜群で、レンダラーの作りを知る方法としても意外なほど優秀です。私たちはオープンなレンダラーベンチマークである 2dbench に raycaster シーンを追加しました。この記事では、その仕組みと計測結果を紹介します。
シーン
シーンは 24×24 の固定の迷路です。硬い岩の中に円形の回廊と中央の部屋、そして 8 本の放射状の通路があります。カメラは輪を歩き、進行方向の少し内側を向いています。敵(ドットの目が2つある squish のぷにぷにした塊)は空いたマスに散らばっていて、少しだけうろうろします。
すべてのテクスチャは一枚のアトラスキャンバスにあり、どこでも同一になるようコードで描かれています。64×64 のタイルが 9 枚で、壁のスタイル 4 種、敵の色 4 種、そして床と天井に使う白いタイルです。
ほかの 2dbench のシーンと同じく、役割分担は厳格です。共通のシミュレーションがゲームの処理をすべて行い(レイを飛ばし、陰影を付け、敵を投影して深度クリップする)、その結果をただの型付き配列に書き込みます。アダプターはその配列を描くだけです。そのため、どのライブラリも仕組みとしてまったく同じ処理をすることになり、ベンチマークが計測するのは誰かのレイキャスティングのコードではなく描画になります。
480 本のレイを飛ばす
キャンバスは 960×540 です。壁のスライスはそれぞれ 2 ピクセル幅なので、シーンは 1 フレームに 480 本のレイを飛ばします。各レイは DDA でグリッドを進みます(x と y のうち近いほうのグリッド線へ進み、壁のマスに当たるまで繰り返します)。
for (let guard = 0; guard < 64 && cell === 0; guard++) {
if (sideX < sideY) {
sideX += ddx;
mx += stepX;
side = 0;
} else {
sideY += ddy;
my += stepY;
side = 1;
}
cell = this.map[my * 24 + mx] ?? 1;
}当たった結果から3つのことがわかります。距離、当たった壁のスタイル、そしてレイが壁のどの位置に当たったか。最後の値で、テクスチャの列 texX(0〜63)が決まります。スライスは元の画像では幅 1 ピクセル、高さ 64 ピクセルで、画面上では幅 2 ピクセル、高さ HEIGHT / dist に引き伸ばされます。
距離は Z バッファにも入ります。Z バッファは列ごとに1つずつ値を持ち、後で敵を描く際に使います。
色合いで陰影を付ける
昔のレイキャスターは、遠くの壁を暗くし、さらに角がはっきり見えるよう、片方の向きの壁を暗くしていました。ここではそれを1つの係数で表します。
const shade = Math.min(1, 2.2 / (1 + dist * 0.3)) * (side === 1 ? 0.8 : 1);この陰影がスライスの色合いになります。追加のテクスチャも、シェーダーの小技も要りません。使う価値のあるスプライトレンダラーなら当然持っている、スプライトごとの色合いだけです。
床と天井も同じ考え方です。それぞれ 12 本の帯で、白いタイルの小さな一部分から描き、地平線に近づくほど暗く色付けします。敵が一体も現れないうちから、これで 24 個のクアッドと 480 個の壁スライスです。
ビルボードの敵を、列ごとにクリップする
敵はビルボードです。つまり常にカメラのほうを向く平らなスプライトです。レイキャスターで敵を描く古典的な方法が、ここでもそのまま使われています。
- 各敵を動かし、カメラまでの距離の二乗を計ります。
- 遠い順に並べ替えます。こうすると近い敵が遠い敵より手前に描かれます。
- 各敵をカメラ空間に投影します。カメラの後ろにあるもの(または近すぎるもの)は飛ばします。
- 画面上の位置と大きさを求め、その列を順にたどります。2 ピクセルの列ごとに、敵が Z バッファの壁より遠ければその列は飛ばし、そうでなければ敵タイルの幅 1 テクセルのスライスを描きます。
for (let col = first; col <= last; col++) {
if (ty >= (this.zBuffer[col] as number)) continue;
const texX = Math.floor(((col * STRIP + 1 - left) / size) * TILE);
if (texX < 0 || texX >= TILE) continue;
this.capacity(n + 1);
this.emit(n++, col * STRIP, top, STRIP, size, tile * TILE + texX, 0, 1, TILE, shade);
}こうすることで、GPU に深度バッファがなくても、敵を角の向こうに半分隠すことができます。見えるかどうかは、何かを描く前に、JavaScript の中で列ごとに決まります。
これはつまり、敵一体がスプライト1つではないということでもあります。近くの敵はたくさんの列にまたがるので、たくさんのスライスになります。ベンチマークの負荷のつまみは迷路の中の敵の数で、それが生み出すクアッドの数は毎フレーム変わります。
なぜストレステストになるのか
ほとんどのスプライトベンチマークは、同じテクスチャを繰り返し描きます。典型例が bunnymark で、画像は一枚、位置がたくさん。レンダラーはそれをとても速くこなせますし、そのための専用の一括パスを持つものもあります。
レイキャスターはその正反対です。すべてのクアッドが細いスライスで、ほぼすべてがアトラスの別々の領域から来ます。別の壁の列、別の敵の列。それが 1 フレームに数千個あり、それぞれ大きさと色合いが違います。
これは特殊な話ではありません。スプライトシートのアニメーション(キャラクターごとにシートの別のフレーム)やタイルマップ(タイルごとにアトラスの別のマス)とまったく同じ形の処理です。レイキャスターはそれを極端なところまで押し進めているだけです。ソース矩形が変わるたびにコストを払うレンダラーなら、このシーンがそれを暴き出します。
各ライブラリは画像の一部をどう描くのか
2dbench のスライス系シーンでは、各クアッドをテクスチャのピクセル単位のソース矩形(sx, sy, sw, sh)、描画サイズ(w, h)、左上の位置で表します。各アダプターはそれを、そのライブラリらしい呼び出しに変換します。
spryte は Frame で画像の一部を描きます。画像と矩形を持つオブジェクトです。draw.sprite はそれをその場で読むだけで保持しないので、アダプターは1つの Frame オブジェクトを使い回し、スライスごとに矩形だけを変えます。スライスごとのメモリ確保はありません。
const frame = { image: textures[0] as HTMLCanvasElement, x: 0, y: 0, w: 1, h: 1 };
const slice = { origin: [0, 0] as const, scaleX: 1, scaleY: 1, tint: 0xffffffff };
// ...スライスごとに:
frame.x = s.sx[i];
frame.y = s.sy[i];
frame.w = s.sw[i];
frame.h = s.sh[i];
slice.scaleX = s.w[i] / frame.w;
slice.scaleY = s.h[i] / frame.h;
slice.tint = s.color[i];
draw.sprite(frame, s.x[i], s.y[i], slice);spryte はイミディエイトモードのレンダラーなので、ソース矩形はそのクアッドのインスタンスデータに入るただの 4 つの数値です。
PixiJS はリテインドモードのシーングラフです。画像の一部を描く方法は、共有ソース上に frame 矩形を持つ Texture です。アダプターは異なるソース矩形ごとに Texture を1つキャッシュするので、同じものを二度作ることはありません。また、ステージ上に Sprite のプールを保持し、毎フレーム先頭から count 個のスプライトのテクスチャ・位置・サイズ・色合い・アルファを設定し、残りを非表示にしてから application.render() を呼びます。アプリは PixiJS 8 の WebGL レンダラーで動き、roundPixels はオン、アンチエイリアスなし、ticker はオフです。
これがそのライブラリらしいセットアップで、アダプターは setup 文字列にそう明記しており、その内容はすべてのレポートに入ります。PixiJS アダプターのソースには、もっとそれらしい、あるいはもっと速いセットアップがあれば PR を送ってほしいと、PixiJS のメンテナーへの呼びかけも書いてあります。本心から聞いてみたいと思っています。
一括パスと Canvas 2D が参加しない理由
2dbench には一括アダプターが2つあります。spryte の draw.sprites と PixiJS の ParticleContainer です。どちらも bunnymark では汎用パスよりはるかに速いのですが、どちらも同じ理由で raycaster には参加しません。1回の呼び出しの中のすべてのコピーで1つのテクスチャ領域を共有するのに対し、このシーンではクアッドごとに別の領域が必要だからです。無理やり参加させれば、より公正なベンチマークではなく、別のベンチマークになってしまいます。
Canvas 2D の drawImage は部分矩形を描けますが、描画ごとに色合いを付けられず、陰影はそれに依存しています。なので、こちらも今回はお休みです。
残るのは2つの汎用パス、spryte の draw.sprite と PixiJS の Sprite で、これは実際のゲームが直面する比較そのものです。
結果
シーンは 1 台のマシンで 3 ラウンド実行しました。AMD Ryzen 7 260 と NVIDIA GeForce RTX 5060 Laptop GPU を搭載したノート PC で、Chrome 155、Windows、AC 電源です。数ごとに 20 フレームのウォームアップと 100 フレームの計測をします。各フレームはシミュレーションから GPU 完了まで(1 ピクセルのリードバック)で計測するので、vsync でコストが隠れることはありません。ラウンドごとに数ごとの中央値を取り、さらにラウンド間の中央値を取ります。
計測の前に、両方のアダプターが PixiJS の基準に対するピクセル一致ゲートを通過しました。2,000 体と 50,000 体の敵で、カバレッジ 100%、スプライト内部で異なるピクセル 0% です。同じ絵を描いています。
| アダプター | バージョン | 60 fps での最大敵数 | 10k | 50k | 200k |
|---|---|---|---|---|---|
spryte draw.sprite | 0.1.0 | 10,000 | 11.3 ms | 58.2 ms | 377.1 ms |
PixiJS Sprite | 8.22.0 | 2,000 | 46.4 ms | 238.7 ms | 946.6 ms |
敵 10,000 体では、spryte は 1 フレーム 11.3 ms、PixiJS は 46.4 ms で、約 4.1 倍の差です。spryte は 10,000 体まで、PixiJS は 2,000 体まで 60 fps を保ちます。
結果には GPU 同期前のメインスレッド時間も記録されています。敵 10,000 体では spryte が 8.7 ms、PixiJS が 40.2 ms です。差のほとんどは GPU ではなく、CPU 上の JavaScript にあります。
ここからわかること
PixiJS が何か間違ったことをしているわけではありません。このコストは、そのモデルのコストです。
シーングラフでは、各スライスが1つの Sprite オブジェクトです。毎フレーム、それぞれのテクスチャが差し替えられ、プロパティが設定され、その後レンダラーがステージをたどってトランスフォームを更新し、各スプライトをバッチに集めます。リテインドモードは、フレーム間でほとんどのものが変わらないときに真価を発揮します。レイキャスターでは、変わらないものはほとんどありません。
spryte はイミディエイトモードです。スライスは1回の呼び出しで、ソース矩形の 4 つの数値、位置、スケール、色合いが GPU バッファに直接書き込まれます。更新したりたどったりするスライスごとのオブジェクトはなく、ソース矩形を変えるコストは変えないときと同じです。
レンダラー設計全般への教訓は、「画像の一部を描く」ことは安くあるべきだ、ということです。ほとんどの 2D ゲームはスプライトシートとタイルアトラスで作られていて、どちらでも隣り合うクアッドはたいてい別の領域から来ます。ソース矩形を長生きするオブジェクトのプロパティではなく、呼び出しごとの値にしているレンダラーなら、それをタダで扱えます。spryte のホットパスがどうしてこうなったのかは、spryte が速くなるまでに詳しく書きました。
注意事項
これは 1 台のマシン、ノート PC に載った高速なデスクトップ級 GPU での結果です。表の中での比率として比較してください。スマートフォンではピクセルの塗りつぶしが支配的になることが多く、差は違って見えるかもしれません。raycaster はまだ私たちのスマートフォン群では実行していません。
また、このシーンは数ある設計の1つにすぎません。実際のレイキャスターのゲームなら、壁をカスタムシェーダーで描いたり、敵を GPU の深度テスト付きで丸ごと一枚のスプライトとして描いたりするかもしれません。2dbench は、それが計測対象だからこそ、意図的に 2D スプライト API でできることだけに限定しています。
別のマシンをお持ちだったり、もっと良い PixiJS のセットアップをご存知だったりしたら、ぜひお寄せください。2dbench は結果とプルリクエストを受け付けています。このシーンは node run.mjs raycaster で実行できます。