2D レンダラーを V8 のヒューリスティクスに収める
spryte 0.13 では、draw.sprite で 200k スプライトを送る JavaScript の時間が、2dbench の 9 つのスプライトシーンのうち 8 つで 4-27% 短くなりました。描画結果は変わっていません。改善の大半は Math.round を Math.floor(v + 0.5) に置き換えたことによるものです。残りは、ホットな関数を V8 のインライン化の上限より小さく保ったことと、オプションオブジェクトのシェイプを 1 つにそろえるヘルパーから来ています。3 つとも、Chrome と Node の JavaScript エンジンである V8 が、どのコードを最適化するかをどう決めているかに関わる話です。
2dbench、200k スプライト(text は 20k ラベル)、draw.sprite アダプター、交互に回した 5 ラウンドの中央値です(結果)。RTX 5060 ノート PC、Ryzen 7 260、Chrome 157、2026-11-11。「送信」は描画呼び出しの発行にかかった JavaScript の時間で、「フレーム」は GPU の完了まで待った時間です。
| シーン | 送信(前) | 送信(後) | 変化 | フレーム(前) | フレーム(後) |
|---|---|---|---|---|---|
| bunny | 11.7 ms | 8.5 ms | -27% | 15.1 ms | 14.9 ms |
| churn | 11.5 ms | 8.4 ms | -27% | 14.9 ms | 11.7 ms |
| lowres | 12.3 ms | 9.0 ms | -27% | 18.7 ms | 15.4 ms |
| upload | 13.8 ms | 10.7 ms | -22% | 18.7 ms | 14.4 ms |
| clip | 12.0 ms | 9.7 ms | -19% | 17.8 ms | 15.0 ms |
| text | 10.6 ms | 8.8 ms | -17% | 13.2 ms | 10.9 ms |
| tilemap | 11.9 ms | 10.5 ms | -12% | 15.2 ms | 13.8 ms |
| mixed | 13.0 ms | 11.8 ms | -9% | 16.8 ms | 15.0 ms |
| particles | 11.3 ms | 10.9 ms | -4% | 17.3 ms | 15.9 ms |
| raycaster | 173.4 ms | 193.0 ms | +11% | 202.8 ms | 220.1 ms |
2 つの行は注意して読む必要があります。bunny は送信時間が 4 分の 1 減ったのに、フレーム時間はほとんど動いていません。この数では、フレームを決めるのは GPU の側だからです。raycaster は中央値では遅くなりましたが、ラウンドごとの幅が大きく重なっています(前は 186-264 ms、後は 184-253 ms)。そのため、もっと長い計測で違う結果が出るまではノイズとして扱います。一括の draw.sprites は変更したコードを通らず、200k での送信時間は前後で 0.1 ms 以内の差でした。
1. Math.round は見た目より遅い
ピクセルアートのスプライトは整数ピクセルにスナップさせないと、動くときに縁がちらつきます。spryte はこれを Math.round で行っていて、スプライトごとに 2 回呼んでいました。
Math.round は、ちょうど半分の値を正の無限大の方向へ丸め、-0.5 から 0 の間の入力には -0 を返さなければなりません。それが 1 回あたりどれだけかかるのか、生成されたマシンコードを読んで確かめたわけではありません。置き換えた結果を計測しました。画面上の位置としては -0 と 0 は同じピクセルで、それ以外では Math.floor(v + 0.5) が同じ結果を返します。
export function pixel(v: number): number {
return Math.floor(v + 0.5);
}-0 以外で 2 つが食い違うのは、v + 0.5 が浮動小数点で丸められる場合です。つまり 0.49999999999999994 と、2^52 より大きい値です。どちらも画面上の位置にはなりません。すべてのアダプターのフレームを基準と比べる 2dbench のピクセル一致チェックも、そのまま通りました。
この置き換えを入れたときに単独で計ると、bunny の 200k スプライトで 1 フレームが 18.6 ms から 14.6 ms になりました。上の表とはマシンの負荷が違うセッションでの数字です。3 つの変更の中ではこれが一番大きなものでした。
2. インライン化の予算
V8 の最適化コンパイラーは、小さな関数を呼び出し元にコピーします(インライン化)。これで呼び出しそのものがなくなり、数値をレジスタに置いたまま扱えます。デフォルトでは、TurboFan はバイトコードが 460 バイトを超える関数をインライン化しません(--max-inlined-bytecode-size)。また、1 つの呼び出し元へのインライン化は合計 920 バイトで打ち切ります(--max-inlined-bytecode-size-cumulative)。呼び出し頻度も条件になります。これらは Node 24 の V8 のフラグのデフォルト値で、文書化された約束ではありません。
インライン化されない呼び出しはタグ付きの値を渡します。そのため、スプライトの x のような小さな整数ではない数値は、呼び出しをまたぐためにヒープ上の数値としてボックス化されます。生じるゴミを直接計ったわけではなく、計ったのはフレーム時間です。
Draw.sprite のバイトコードは 497 バイトで、上限をわずかに超えていました。その大半はまれなケースの処理でした(コマンドリストへの記録や、フォールバックの矩形描画パス)。それらを別のメソッドに移し、短いホットパスだけを残しました。
sprite(frame, x, y, o = NO_OPTIONS) {
const b = this.backend;
if (b.sprite && !this.rec) {
this.quads++;
b.sprite(/* ... */);
return;
}
this.spriteSlow(frame, x, y, o);
}これで 145 バイトです。単独で計ると、あるセッションで bunny が 15.3 ms から 14.7 ms になり、mixed シーンは変わりませんでした。1 つ目の変更と比べると小さな効果です。
困るのは、次の機能追加で誰も気づかないうちに上限を超えてしまうことです。そこでテストを用意しました。Node で --print-bytecode を付けてスプライトのパスを実行し、Draw.sprite が 200 バイトを、どちらかのバックエンド関数が 460 バイトを超えたら失敗します。
const out = execFileSync(process.execPath, [
"--print-bytecode", `--print-bytecode-filter=${name}`, fixture,
]);
// ... read "Bytecode length: N" for each function
expect(length).toBeLessThanOrEqual(max);古いコードでは "expected 497 to be less than or equal to 200" で失敗します。バイトコードの長さは目安にすぎません。V8 はバージョンごとに上限を変えることがあるので、テストには、上限を上げる前に計り直すよう書いてあります。
3. オプションオブジェクトとシェイプ
draw.sprite(frame, x, y, { rotation, tint }) はオプションオブジェクトからプロパティを読みます。V8 はインラインキャッシュでプロパティの読み取りを速くします。インラインキャッシュは、その行で見たオブジェクトのシェイプ(隠しクラス)を覚えておく仕組みです。シェイプが 4 つまでなら読み取りは速いままです。4 つを超えるとキャッシュはメガモーフィックになり、すべての読み取りが遅い検索を通ります。
ゲームがキーの違うオプションのリテラルを書くたびに、あるいは同じキーを違う順番で書くたびに、新しいシェイプが生まれます。8 か所の呼び出しで 8 種類のリテラルを使うのは珍しくありません。Node ではそれだけで、200k 回の呼び出しが 1 つのシェイプのときの 5.3-5.9 ms から 13.9-14.7 ms になりました。
spriteOptions() は、どのオプションオブジェクトも同じキーを同じ順番で持つように作り、使わないキーは undefined にします。
import { spriteOptions } from "spryte";
const hurt = spriteOptions({ tint: "#ff4040", flash: 0.6 });
const ghost = spriteOptions({ alpha: 0.4 });
draw.sprite(player, x, y, hurt);この方法で作った 8 か所の呼び出しは 7.1-8.4 ms でした。シェイプが 1 つのときの速さには届きませんが、8 種類のリテラルのコストのおよそ半分です。undefined の代わりに本当のデフォルト値を入れると遅くなったので、ヘルパーではそうしていません。いつ使えばよいかはスプライトのガイドで説明しています。
効かなかったこと
- テクスチャとサンプラーのバインドのキャッシュと、毎フレームの頂点バッファのオーファニング:計測できる差はありませんでした。
- バックエンドの書き込み関数をさらに削ること:Chrome で mixed シーンが遅くなったので、やめました。
- 軽いティントのパス:200k スプライトあたり 1 ms 未満で、計測ごとのばらつきの範囲内でした。
- シェーダーの高速化:すべての描画を 1x1 のシザーに縮めても、bunny、mixed、tilemap、particles のフレーム時間は変わりませんでした。つまりこれらのシーンはピクセルごとの処理(フラグメント処理)で律速されていません。ただし頂点処理とアップロードはシザーがあっても走るので、それらが原因である可能性は消えていません。
この記事で示せていないこと
- 1 章と 2 章の変更ごとの数字は、それぞれの変更を入れたときの別々のセッションで計ったものです。前後を直接比べているのは冒頭の表の 1 回の計測だけです。
- シェイプの数字はブラウザのフレームではなく Node で計ったものです。効果の大きさを示すもので、ゲームで実際に見える数字ではありません。
- ノート PC 1 台とブラウザ 1 つでの結果です。V8 のヒューリスティクスはバージョンによっても違います。
呼び出しパスの先へ
残っているコストは呼び出しそのものです。200k スプライトの draw.sprite のフレームでは、およそ 60-70% がスプライトの送信に使われています。一括の draw.sprites はスプライトごとの呼び出しを丸ごと省きます。それを土台にしたスプライトプールの試作では、200k スプライトを 1 フレームあたり CPU 時間 1.4-1.8 ms で描画できました。draw.sprite のループでは 13.8-15.8 ms です。これが ADR 0018 で、まだ提案の段階です。
2dbench を使えば、自分のマシンで bunny シーンを試せます:node run.mjs bunny spryte。