パフォーマンスとロバスト性 — 計測・コンテキストロスト
この本はここまで、コストをずっと数えられる量で語ってきました。第26章はpcg3dの呼び出し回数、第28章はmap()の評価回数、第30〜32章はドローコール数・WebGL 呼び出し回数・アプリが WebGL に渡すバイト数。読み出し行に出ていた「rAF 間隔」にも、そのたびに「CPU 側の計測で GPU 時間ではありません」という但し書きが付いていました。
これは慎重さではなく、順番の問題でした。GPU 時間を出すには測る道具が要り、その道具は「いつ結果が返るか」「いつ結果を捨てるべきか」を 知らないと嘘の数字を出します。だから測り方をこの章まで温存していました。この章は、この本で GPU 時間を出す唯一の章です。
扱うのは 2 つです。前半(1〜6 節)は計測— どこが詰まっているのかを推測ではなく手順で決める方法。後半(7〜8 節)はロバスト性— 作ったものを解放すること、そしてコンテキストはいつでも失われうるという前提で書くことです。
この章で学ぶこと:
- WebGL の関数は命令を積むだけで、返ってきた時点では何も終わっていない。だから
performance.now()でdrawElementsを挟んでも GPU の時間は測れない EXT_disjoint_timer_query_webgl2— 結果が返るのは次のフレーム以降、GPU_DISJOINT_EXTが真ならその区間の値は全部捨てる- rAF 間隔が表示のリフレッシュ間隔に張り付いているとき、何が見えないのか
- 強制同期レイアウトを件数で見る手順(時間の分解能に依存しない)
- 解像度スケール — フラグメント側に対していちばん効くつまみ
- 分岐・early-Z・頂点シェーダーの起動回数について、何が仕様の保証で、何が実装の傾向か
- GPU リソースの解放。この本が積み残してきた分の一覧と、放置してよい条件
- コンテキストロストと復帰。
preventDefault()・isContextLost()・作る手続きを 1 か所にまとめる設計
1. 推測する前に切り分ける — CPU バウンドと GPU バウンド
「重い」と感じたとき、最初にやることはコードを速くすることではなく、どちらが詰まっているかを決めることです。仕事は 2 か所にあります。
- CPU 側— JavaScript の実行、行列計算、
gl.*の呼び出しそのもの、ドライバがコマンドを組み立てる時間 - GPU 側 — 頂点処理、ラスタライズ、フラグメント処理、メモリ帯域
この 2 つは並行して動きます。第1章 3 節で「CPU が段取り、GPU が描く」と書いたとおり、CPU は命令を積むところまでが仕事で、GPU はそれを後から自分のペースで実行します。WebGL の関数は、返ってきた時点では何も終わっていません。gl.drawElementsInstanced()が戻ってきたのは「描き終わった」のではなく「描けと書き置きした」だけです。
切り分けの手順
道具が無くても、つまみを 1 つずつ動かして反応を見ることで切り分けはできます。 このデモのつまみは、そのままこの手順に対応しています。
- 解像度を半分にして速くなるか(デモ 3)。速くなるならフラグメント側。ピクセル数は倍率の 2 乗で減るので、効くときは大きく効きます(5 節)
- 体数だけを減らして速くなるか(デモ 1)。速くなるなら頂点側か CPU 側。どちらかはまだ分かりません
- 描画そのものをやめて変わらないか。ドローコールを
if (false)で飛ばす、あるいはgl.enable(gl.RASTERIZER_DISCARD)(第32章 4 節)でラスタライズを捨てる。それでもフレーム時間が変わらなければ、詰まっているのはCPU 側です gl.finish()を挟むと、CPU の待ち時間が見える。gl.finish()は GPU が積まれた命令を終えるまで戻ってこないので、そこで測れば「GPU が終わるまでの時間」に 近いものが出ます。ただしパイプラインを壊します— CPU と GPU を毎フレーム同期させると、本来は重なっていたはずの仕事が直列になります。計測専用で、製品コードには残さないこと
このデモが「つまみ 1 つずつ」になっているのは、そのためです。デモに入るたびに 3 つのつまみを既定値へ戻しているのも同じ理由で、2 つを同時に動かすと、どちらが効いたのか言えなくなるからです。
CPU から GPU への転送は「遠い」
第1章 3 節の補足で「CPU と GPU の間は遠い。データは先に送っておき、毎フレームは命令だけ送るのが基本姿勢」と書きました。 この本の第5部は、その両端を実装として並べています。
- 片方の端 — 第30章 6 節。CPU で粒子を動かし、位置を毎フレーム
bufferSubDataで送る。粒子を増やすと、アプリが WebGL に渡すバイト数がそのまま比例して増えます - もう片方の端 — 第32章。状態を GPU 側のバッファに置いたまま更新するので、毎フレームのバッファ転送は 0 バイト。 30 万粒子でも、送るのは行列といくつかの uniform だけです
この軸で見ると、インスタンシング(第31章)は真ん中にいます。ドローコールは 1 回に減りますが、per-instance 属性を毎フレーム書き換えるなら転送は残ります(第31章 6 節)。第31章のデモ 2 がbufferSubDataを呼び続けているのがそれで、第32章はそのbufferSubDataを消しました。
2. GPU の時間を測る — EXT_disjoint_timer_query_webgl2
GPU 側の時間を測る道具は、WebGL2 では拡張として提供されます。名前はEXT_disjoint_timer_query_webgl2です。WebGL1 版の EXT_disjoint_timer_query とは別の拡張で、API が違います。WebGL1 版はcreateQueryEXT/beginQueryEXTという専用の関数を持っていましたが、WebGL2 はクエリオブジェクトをコアに取り込んでいるので、その必要がありません。
実際、この拡張が足すのは定数 4 つと関数 1 つだけです。仕様の IDL がそのまま短いので、引用します。
interface EXT_disjoint_timer_query_webgl2 {
const GLenum QUERY_COUNTER_BITS_EXT = 0x8864;
const GLenum TIME_ELAPSED_EXT = 0x88BF;
const GLenum TIMESTAMP_EXT = 0x8E28;
const GLenum GPU_DISJOINT_EXT = 0x8FBB;
undefined queryCounterEXT(WebGLQuery query, GLenum target);
};定数のうちTIMESTAMP_EXTと、唯一の関数queryCounterEXTは、「区間の経過時間」ではなくその時点の GPU の時計を打刻するもう一方の方式です。ただし、この方式が使えるかどうかは実装しだいで、使う前に問い合わせる決まりになっています。大元の ES 拡張EXT_disjoint_timer_queryはQUERY_COUNTER_BITS_EXTについて「結果を保持するビット数は 0 でもよく、そのときカウンタは有用な情報を持たない (The number of query counter bits may be zero, in which case the counter contains no useful information.)」と 定めていて、WebGL2 版の拡張仕様に載っているサンプルコードもgl.getQuery(ext.TIMESTAMP_EXT, ext.QUERY_COUNTER_BITS_EXT) > 0で分岐してからqueryCounterEXTを使っています。
この本の環境で読むと、そのビット数は 0 でした。
const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2');
gl.getQuery(ext.TIME_ELAPSED_EXT, ext.QUERY_COUNTER_BITS_EXT); // → 64
gl.getQuery(ext.TIMESTAMP_EXT, ext.QUERY_COUNTER_BITS_EXT); // → 0つまりこの実装ではタイムスタンプ方式が使えません。この章のデモはTIME_ELAPSED_EXTしか使わないので影響はありませんが、これは 1 台の環境で 1 回読んだ値であって、拡張が取れれば両方使えると思ってはいけないという実例です。queryCounterEXTを使うコードを書くなら、ビット数の確認とTIME_ELAPSED_EXTへの退避を必ず入れてください。
残りはコアの API です。gl.createQuery()でクエリを作り、gl.beginQuery(ext.TIME_ELAPSED_EXT, query)とgl.endQuery(ext.TIME_ELAPSED_EXT)で挟み、gl.getQueryParameter(query, gl.QUERY_RESULT)でナノ秒を受け取ります。これだけなら簡単そうに見えますが、正しく使うには 3 つの約束を守る必要があります。
約束 1: 結果は、同じフレームでは絶対に取れない
WebGL 2.0 仕様の「Differences Between WebGL and OpenGL ES 3.0」に、「Queries' results must not be made available in the current frame」という項があります。原文はこう書いています — 「OpenGL ES 3.0 ではglFinish()などの同期 API を呼べば同じフレームで結果が得られることがあるが、WebGL では、アプリケーションの移植性を高めるために、クエリの結果を発行したフレームと同じ フレームでアプリケーションに渡してはならない」。getQueryParameterの項も「ユーザーエージェントのイベントループがタスクを実行していないときにだけ結果を利用可能に しなければならない」「制御を返さずにループの中でQUERY_RESULT_AVAILABLEを繰り返し読んだら、常に同じ値を返さなければならない」と念を押しています。
しかも「次のフレームなら必ず揃う」わけでもありません。同じ項が「単一のsetTimeout(…, 0)や単一のrequestAnimationFrameのコールバックが、実装が結果を用意するのに十分な時間を与える保証は無い」と書いています。結果は非同期にしか来ないという前提で書くしかありません。
ここから、実装の形が決まります。クエリオブジェクトを 1 個で使い回してはいけません。結果を読む前に次のbeginQueryを呼ぶと、その 1 個を上書きすることになります。数本を輪にして順に使い、揃ったものから 読みます。この章のgpu-timer.tsが持っているのはその輪です。
const RING_SIZE = 4;
/** 移動平均の窓。読み出し行の rAF 間隔と同じ本数にしてある */
const SAMPLE_LIMIT = 30;
interface Slot {
query: WebGLQuery;
/** beginQuery 済みで、まだ結果を読んでいない */
pending: boolean;
/** この計測の区間に disjoint が起きた。結果が揃っても捨てる */
spoiled: boolean;
}約束 2: TIME_ELAPSED_EXT のクエリは同時に 1 本だけ
入れ子にはできません。根拠は 2 つあります。ひとつは OpenGL ES 3.0.6 §2.14 のBeginQueryの規定で、「targetに対する active query object name が非ゼロならINVALID_OPERATION」。もうひとつは大元のEXT_disjoint_timer_queryの Issue (6) で、「アプリケーションはタイマークエリを複数、あるいは他の種類のクエリを複数、 同時に実行することはできない (An application can not perform multiple timer queries or multiple queries of other types simultaneously.)」。
だからbegin()は「すでに走っていたら何もしない」「空きスロットが無ければ測らない」の 2 段で守っています。測れないフレームがあるほうが、嘘の数字を出すよりましです。
begin(): void {
// 同時に 1 本だけという制約。end() を呼び忘れた場合もここで止まる
if (active) return;
const slot = slots.find((candidate) => !candidate.pending);
// 全部飛んでいるフレームは測らない。無理に測るより穴が空くほうが安全
if (!slot) return;
slot.pending = true;
slot.spoiled = false;
active = slot;
gl.beginQuery(ext.TIME_ELAPSED_EXT, slot.query);
},
end(): void {
if (!active) return;
gl.endQuery(ext.TIME_ELAPSED_EXT);
active = null;
},約束 3: GPU_DISJOINT_EXT が真なら、その区間は全部捨てる
これがこの拡張の名前になっている部分です。disjoint(不連続)とは、GPU の側で時間軸が飛ぶような変化が起きたことを言います。大元のEXT_disjoint_timer_queryの原文はこう定義しています。
Disjoint operations occur whenever a change in the GPU occurs that will make the values returned by this extension unusable for performance metrics. …When the returned value is non-zero, all time values that were filled since the previous disjoint check should be considered undefined.
EXT_disjoint_timer_query, "Timer Queries" 節
例として挙がっているのはモバイル GPU の省電力です。GPU が低いレベルで眠りに入れば、そのあいだの時間は測っているものと関係がなくなります。同じ原文が 「disjoint がいつ起きるかはプラットフォームごとに違い、実装依存である」とも書いています。
この値はgl.getParameter(ext.GPU_DISJOINT_EXT)で読みますが、読むとクリアされます(「前回この問い合わせをしてから今までに 起きたか」を返す)。だから毎フレーム 1 回だけ読み、真だったらそのとき飛んでいた計測を全部捨てます。ここを書かないと、周波数が変わった瞬間の値が平均に混ざって、章ごと嘘の数字になります。 このデモの読み出し行が捨てた回数まで出しているのは、捨てていることを 見えるようにするためです。
poll(): void {
// 先に disjoint を見る。真なら、いま飛んでいる計測は全部信用できない
if (gl.getParameter(ext.GPU_DISJOINT_EXT)) {
for (const slot of slots) {
if (slot.pending) slot.spoiled = true;
}
}
for (const slot of slots) {
if (!slot.pending || slot === active) continue;
// 揃っていなければ次のフレームに持ち越す。ここでループして待つのは無意味で、
// 仕様が「制御をユーザーエージェントへ返すまで結果は揃わない」と定めている
if (!gl.getQueryParameter(slot.query, gl.QUERY_RESULT_AVAILABLE)) continue;
const nanoseconds = gl.getQueryParameter(slot.query, gl.QUERY_RESULT) as number;
slot.pending = false;
if (slot.spoiled) {
slot.spoiled = false;
discarded++;
continue;
}
samples.push(nanoseconds / 1e6);
if (samples.length > SAMPLE_LIMIT) samples.shift();
}
},使う側はこうなります。区間に clear も入れてあります。
// ここからここまでが GPU 時間の計測区間。clear も入れてある
timer.begin();
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.bindVertexArray(resources.vao);
gl.drawElementsInstanced(
gl.TRIANGLES,
resources.indexCount,
gl.UNSIGNED_SHORT,
0,
instanceCount,
);
gl.bindVertexArray(null);
timer.end();
// 揃っているものだけ回収する。ここで待つことはできない(本文 2 節)
timer.poll();gl.finish() + performance.now() は「目安」として使える
第22章の事前計算(環境マップのプレフィルタ)は、所要時間をgl.finish()のあとにperformance.now()で測っていました。第22章のmain.tsのコメントが「ブラウザによっては GPU の完了を厳密には待たないので、この数字は目安」と 断っているとおりです。
この測り方は1 回きりの処理には向いています。パイプラインを壊すのが 問題にならず、かといってタイマークエリのリングを組むほどでもない — 初期化時の事前計算はまさにその場面です。逆に毎フレームの計測には使えません。CPU と GPU を毎フレーム同期させたら、測っている行為そのものがフレーム時間を変えます。
数えられる量と、測れる量
ここまでの章が「数えられる量」で語ってきたのは、逃げではありません。2 つの量には性格の違いがあります。
- 数えられる量は環境に依存しません。第28章の「1 ピクセルあたり
map()を平均 35.43 回」は、どの GPU で走らせても 35.43 回です。だから章に書き残す価値があり、読者が自分の環境で検算できます - 測れる量は環境に依存します。この章の読み出し行に出る GPU 時間は、あなたの GPU・そのときの電源状態・ウィンドウの大きさで変わります。だから測定条件と一緒でなければ意味を持ちません。この章の読み出し行が体数・ピクセル数・倍率・レンダラ名まで並べているのは、そのためです
ただし、レンダラ名だけは素直には取れません。詳しい名前を出す拡張WEBGL_debug_renderer_infoは、原文の Overview 自身が「WebGL の実装は、プライバシー上の理由で下層のグラフィックスドライバのRENDERERとVENDORの文字列をマスクすることがある」と書いていて、Issue (2) は「ユーザーエージェント(ブラウザ)は、 非特権の環境でこの拡張を露出するかどうかを慎重に検討すべきである」と書いています(Issue (2) はこのあと、拡張を出すことの利点も並べています)。取れない環境がある前提で書くしかありません。この章のmain.tsのreadRendererName()が、拡張が取れなければコアのgl.getParameter(gl.RENDERER)に落とし、それも空なら「不明」と出すのはそのためです。測定条件の 1 つが取れないことがある、というのも測定条件のうちです。
2 つは対立しません。数えた量は、測った量を読むための地図になります。いくつか例を挙げます(値はいずれも元の章のもので、この章で測り直したものではありません)。
- 第26章のドメインワーピング 2 段は、1 ピクセルあたり
pcg3dを 100 回呼びます。このサイトのデモ canvas(dpr 2 で 1,282,512 ピクセル。数え方は 5 節の表と同じ)なら、1 フレームで 1 億 2825 万回。ここに GPU 時間を貼れば「1 回あたり何ナノ秒か」が出ます - 第28章のレイマーチングは 1 ピクセルあたり
map()を primary 20.08 / 影 7.86 / 法線 4.09 / AO 3.41、合計 35.43 回(第28章の表の値。各項は 丸めてあるので、単純に足すと末尾が 0.01 ずれます)。maxDprを 1.5 に下げているので、同じ canvas で 721,413 ピクセル × 35.43 = 約2556 万回 - 第32章の 30 万粒子は、状態バッファ 8,400,000 B × 2 本 + 種 4,800,000 B =20.6 MiBが常駐します。1 フレームで論理的に触るのは「更新パスが状態と種を 読む + 状態を書く + 描画パスが状態を読む」= 30,000,000 B = 28.6 MiB。60 Hz なら毎秒 1.68 GiB です。ただしこれは「シェーダーが読み書きを要求した量」であって、DRAM まで往復した量ではありません(キャッシュが効けば減ります)。実際に帯域で詰まっているかどうかは、体数を減らして GPU 時間が比例して落ちるかを見るしかありません。第32章の「壊してみる」が粒子数を 100 万に 上げてみることを勧めていましたが、「どこが上限か」は数えられる量からは出てきません— 常駐バイト数は 3.3 倍だと分かっても、それが予算内かは環境しだいだからです。上限は測って 決めます
第32章の「30 万粒子の更新と描画の GPU 時間」は、この章のgpu-timer.tsをあのmain.tsに持ち込めば測れます。更新パスと描画パスを別々のクエリで挟めば、どちらが重いかまで分かります — ただし約束 2 のとおり入れ子にはできないので、2 本を順に挟むことになります。DYNAMIC_COPYという usage ヒントについても同じで、ES 3.0.6 §2.10.2 は 9 つの値を「アプリケーションが想定する使い方を示す (indicating the expected application usage pattern of the data store)」としか定めていません。ヒントが実装にどう効くかは仕様の外なので、知りたければ値を変えて測るしかありません。
この環境で 12 条件を測った
せっかく道具があるので、このページのデモを 12 通りの条件で測った値を 1 枚の表にして 載せておきます。この表はこの本で唯一の「時間の実測値」で、1 台の環境で 1 回ずつ測ったものです。以降の節(3 節の rAF 間隔、5 節の解像度スケール)は、値を書き直さずにこの表を参照します。
| # | 条件 | GPU 時間 | rAF 間隔 | 描画バッファ |
|---|---|---|---|---|
| A | デモ 1・100 体 | 0.19 ms | 16.7 ms | 320,628 px |
| B | デモ 1・1,000 体 | 0.37 ms | 18.4 ms | 320,628 px |
| C | デモ 1・10,000 体 | 1.65 ms | 20.0 ms | 320,628 px |
| D | デモ 1・50,000 体 | 6.20 ms | 20.0 ms | 320,628 px |
| E | デモ 2・なし | 1.57 ms | 20.0 ms | 320,628 px |
| F | デモ 2・頂点 | 5.12 ms | 20.0 ms | 320,628 px |
| G | デモ 2・フラグメント | 5.58 ms | 20.0 ms | 320,628 px |
| H | デモ 2・両方 | 9.14 ms | 20.0 ms | 320,628 px |
| I | デモ 3・倍率 1.00 | 5.56 ms | 20.0 ms | 694 × 462 = 320,628 px |
| J | デモ 3・倍率 0.75 | 5.06 ms | 20.0 ms | 520 × 346 = 179,920 px |
| K | デモ 3・倍率 0.50 | 3.91 ms | 20.0 ms | 347 × 231 = 80,157 px |
| L | デモ 3・倍率 0.25 | 2.93 ms | 20.0 ms | 173 × 115 = 19,895 px |
測定条件: Chrome /ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)/ 2026-08-08。この計測ウィンドウの devicePixelRatio は 1だったので、倍率 1.00 の描画バッファは 694 × 462 = 320,628 ピクセルです(5 節の表は dpr 2 を前提にしているので、同じ 320,628 が倍率 0.50 の行に出てきます。混同しないでください)。 体数は A〜D が表のとおり、E〜H と I〜L は各デモの既定値の 10,000 体。追加の仕事は A〜E が「なし」、I〜L が「フラグメント」。GPU 時間・rAF 間隔とも30 標本の移動平均で、条件を変えてから3.5 秒待って読み出し行から読みました。各条件 1 回しか測っていません。「disjoint で捨てた計測」は全条件で 0 回でした。
ばらつきの目安が、表の中から取れます。C と E(どちらも 10,000 体・追加の仕事なし・倍率 1.00)、G と I(どちらも 10,000 体・フラグメント・倍率 1.00)は、それぞれ名目上まったく同じ条件です。 それでも値は0.08 ms と 0.02 ms ずれました。 1 回ずつしか測っていない表なので、0.1 ms 前後の差は「差がない」と読んでください。
読み取れることを 2 つだけ書いておきます。
- 体数(A〜D)にはおおむね比例しますが、完全な比例ではありません。1,000 → 50,000 体は 50 倍ですが、GPU 時間は 16.8 倍にしかなりませんでした。100 → 50,000 体の 500 倍に対しても 32.6 倍です。倍率より鈍いということは、体数に依らない固定のコストが乗っているということです。この「固定のコスト」の正体は 5 節でもう一度出てきます
- 頂点側とフラグメント側の負荷は、この条件ではほぼ足し算になりました。「なし」 (E)を基準にすると、「頂点」(F)の増分が 3.55 ms、「フラグメント」(G)の増分が 4.01 ms。足すと 7.56 ms で、「両方」(H)の増分 7.57 ms とほぼ一致します。ただしこれは「この条件では重なりが見えなかった」以上のことを意味しません— 一方が完全に律速していれば、もう一方を足しても時間は増えないはずで、そうなっていないのは 両者がどちらも余裕のある状態だったから、とも読めます。1 回ずつの計測から機構は決まりません
この表の値をあなたの環境の期待値として使わないでください。GPU も、ウィンドウの大きさも、そのときの電源状態も違います。使いみちは「同じ表の中で条件どうしを 比べる」ことだけです — そしてそれこそが、1 節の切り分けでやることです。
3. CPU 側の時間 — rAF 間隔が見せるもの、隠すもの
第23章と第30〜32章の読み出し行には「rAF 間隔」が出ていました。requestAnimationFrameのコールバックに渡されるtimestampの差分で、この章のデモも同じものを出しています。
const delta = timestamp - lastTimestamp;
if (lastTimestamp !== 0 && delta < 200) {
frameTimes.push(delta);
if (frameTimes.length > FRAME_SAMPLES) frameTimes.shift();
}
lastTimestamp = timestamp;この値が何を意味するかを、はっきりさせておきます。rAF 間隔は「1 フレームの仕事の量」ではありません。ブラウザは表示のリフレッシュに合わせてコールバックを呼ぶので、仕事が予算に収まっているかぎり、間隔はリフレッシュ間隔に張り付きます。60 Hz なら約 16.7 ms、120 Hz なら約 8.3 ms。仕事が半分になっても、値は 1 ミリ秒も動きません。
だから rAF 間隔で「差がない」と言うには、比較対象が要ります。いちばん確実な 比較対象は何もしていないページです。同じブラウザでabout:blankを開き、コンソールで rAF の間隔を測ってみてください。デモの値がそれと同じなら、その値は負荷について何も語っていません。
その一方で、この章のデモでは rAF 間隔が動きました。2 節の表の rAF 間隔の列を見てください。A → B → C と体数を上げるあいだ、値が 1 段ずつ上がっています— つまりこの条件では天井に張り付いていません。第30〜32章で記録した値(3 章 11 通りのデモがどれも 11.7〜11.8 ms で、同じブラウザのabout:blankも中央値 11.8 ms。2026-08-07 の計測)とは、明らかに別の状態です。
ただし、ここから先は分かっていません。
- C から L までがきれいに 20.0 ms で揃う理由は未特定です。20.0 ms は 50 Hz に相当する値ですが、それが表示のリフレッシュなのか、ブラウザの別の上限なのか、 たまたま負荷がその近辺で釣り合ったのかをこの章では確かめていません。 「上限に張り付いている」ようにも見えますし、「予算を超えて 1 周落ちた」ようにも見えます。 どちらとも言えません
- 2026-08-07 の 11.7〜11.8 ms と 2026-08-08 の 16.7〜20.0 ms を、そのまま引き算しては いけません。別の日・別のウィンドウでの計測で、表示の設定が同じだったかを 確認していません。言えるのは「同じ指標が、張り付くこともあれば動くこともある」という一点だけです
この一点が、この節の主張そのものです。rAF 間隔はそのときどきで意味が変わる指標なので、読むたびに、何もしていない状態の値と比べ直す必要があります。 「前に測ったときは負荷が見えた」は、次も見える理由になりません。
では rAF 間隔は何に使えるのか。予算を超えたかどうかの判定には使えます。値が リフレッシュ間隔より明らかに大きくなったら、1 フレームぶんの仕事が予算に収まらなかった ということです。ただしそれが CPU 側なのか GPU 側なのかは分かりません。ブラウザは 合成のために GPU の完了を待つので、GPU が遅れれば rAF も遅れます。切り分けは 1 節の手順と 2 節の道具の仕事です。
第23章の読み出し行に付いていた「CPU 側の計測で、GPU の処理時間ではありません」という但し書きの宛先も、ここです。第23章のデモは ディファードで G-buffer を作るぶんフラグメント側が重く、maxDprを 1.5 に下げていました(5 節)。あの環境で GPU 側が何ミリ秒かかっていたのかは、この章の道具を持ち込まないと分かりません。
デルタ時間 — rAF の timestamp をどう使うか
第4章 3 節で描画ループを作ったとき、時間は rAF の引数timestampから取りました。Date.now()やperformance.now()を自分で呼ばないのには理由があります。同じフレームで呼ばれたコールバックには、すべて同じtimestampが渡されます。自分で時計を読むと、コールバックの中のどこで読んだかによって値がずれ、複数の更新が 微妙に違う時刻で進むことになります。
物理や積分を回すなら、そこから作る デルタ時間 の扱いに 2 つ注意が要ります。
- タブが裏に回ると rAF は止まります。戻ってきた最初のフレームの差分は、 数秒から数十秒になりえます。それをそのままオイラー積分に入れると、粒子が一気に飛びます。 第32章は
MAX_DT = 1 / 30で頭打ちにしていました。この章のデモには積分がありませんが、計測のほうで同じ問題が起きます— だから上のコードはdelta < 200で弾いています。「呼ばれなかっただけ」の間隔を、平均に混ぜてはいけません - 可変ステップと固定ステップ。実測の差分をそのまま使うのが可変ステップで、書くのが簡単ですが、フレームレートによって結果が変わります(第15章 5 節の「補間の収束が速さで変わる」がその一例です)。固定ステップは 「溜まった時間を一定の刻みで割り、その回数だけ更新する」形で、結果が再現しますが、 1 フレームに何回も更新が走る可能性を設計に織り込む必要があります。第29章の
stepsPerFrameは、時間ではなくフレームを基準にした固定ステップです
4. レイアウトを誘発する読み取り — トレースで件数を数える
CPU 側で詰まる原因のうち、WebGL とは直接関係がないのに毎フレームのループに紛れ込みやすいものが強制同期レイアウト (forced synchronous layout)です。clientWidthやgetBoundingClientRect()のような「レイアウトの結果を読む」プロパティを、DOM を書き換えたあとに読むと、ブラウザは先送りしていたレイアウト計算をその場で走らせます。
定義・判別の基準・実測値は第4章 6 節の補足が持っています。この節が引き受けるのは測り方のほうです。「レイアウトが走っているかどうか」を、 自分の手で確かめる手順を書きます。
手順: Performance トレースで件数を見る
- DevTools の Performance パネルで、デモを動かしたまま 10〜30 秒ほど記録します
- 記録の中から、次のイベントの件数を見ます。
Layout/UpdateLayoutTree/RecalculateStyles/LayoutInvalidationTracking/LayoutShift。あわせてFireAnimationFrameの件数も控えておくと、「rAF が N 回走ってレイアウトが 0 件」という形で言えます - レイアウト系が 0 件 なら、そのループは強制同期レイアウトを起こしていません。 1 件でもあれば、どのイベントから来たのかをスタックで辿ります
時間ではなく件数を見るのは、件数が時計の分解能に依存しないからです。強制同期レイアウト 1 回のコストはマイクロ秒台のことがあり、粗粒化されたperformance.now()では 1 回きりでは測れません(第4章 6 節が、20 万回の反復平均でその µs 台の値を出しています)。件数のほうが主、反復平均が補です。この章では測り直していません。
1 件でもあったときの逃がし方
どこで踏んだのかを絞り込む基準は第4章 6 節が持っています(要点は「同じコールバックの中で、 読むより先に書き換えているか」。末尾で書いて、次のフレームの先頭で読むのは安全)。この本のデモが「先頭でresizeIfNeeded()、末尾で読み出し行を書き換える」形に揃っているのは、 その順序に乗るためです。ここでは順序を直す以外の逃がし方を 2 つ挙げます。
ResizeObserver— サイズの変化だけが要るなら、毎フレームclientWidthを読む代わりにこちらで受け取れます。コールバックはレイアウトのあとに呼ばれるので、 構造的にクリーンです(第2章 7 節・第4章 6 節)IntersectionObserver— 「画面に入っているか」をgetBoundingClientRect()で毎フレーム判定する代わりに使えます。画面外のデモの rAF を止める、といった判断にちょうど向きます
複数の要素を「読む → 書く → 読む → 書く」と交互に処理する形(layout thrashing)も同じ罠で、 直し方は第4章 6 節の末尾にあります。
5. 一番効くつまみ — 解像度スケール
フラグメント側が詰まっているとわかったとき、いちばん確実に効くのは描画バッファを小さくすることです。フラグメントシェーダーの実行回数はピクセル数にそのまま比例し、倍率 s に対してピクセル数は s²で減ります。0.75 で 0.5625 倍、0.5 で 0.25 倍。「ちょっと小さくする」がかなり効くのは、この 2 乗のためです。
デモ 3 のオプションはこの倍率です。resizeIfNeededの中でdevicePixelRatioのクランプ(第2章 4 節)の上からさらに掛けているだけで、CSS サイズには触りません。
function resizeIfNeeded(): void {
// dpr のクランプ(第2章 4 節)の上に、さらに倍率を掛ける。これが 5 節の「一番効くつまみ」
const dpr = Math.min(window.devicePixelRatio, MAX_DPR);
const width = Math.max(1, Math.floor(canvas.clientWidth * dpr * resolutionScale));
const height = Math.max(1, Math.floor(canvas.clientHeight * dpr * resolutionScale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
// viewport も必ずセットで切り替える。既定値は canvas の初期サイズ(300×150)のまま
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
}
}この手が使えるのは、CSS サイズと描画バッファサイズが独立しているからです(第2章 3 節)。canvas は「描画バッファの絵を CSS サイズに引き伸ばして表示する」ので、バッファを小さくしても表示の大きさは変わりません。 変わるのは解像度だけです。
| 倍率 s | s² | 描画バッファ | ピクセル数 |
|---|---|---|---|
| 1.00 | 1.0000 | 1388 × 924 | 1,282,512 |
| 0.75 | 0.5625 | 1041 × 693 | 721,413 |
| 0.50 | 0.2500 | 694 × 462 | 320,628 |
| 0.25 | 0.0625 | 347 × 231 | 80,157 |
測定条件: デモ canvas の CSS サイズ 694 × 462(第26章 6 節がブラウザで実測した値。mainのmax-width: 46remが効く幅のウィンドウでの値)、devicePixelRatioは 2、resizeIfNeededと同じMath.floorで計算。ピクセル数の比は 4 桁とも s² にぴったり一致します(1388 と 924 がどちらも 4 の倍数なので、切り捨てが効かないため)。再現はscratchpad/p5b/ch35-counts.mjs。あなたの環境の実際の値は読み出し行に出ます。
同じつまみの、別の名前
この本には、すでに同じつまみが別の名前で何度も出てきています。
- 第23・28・33・36章の
maxDpr1.5。第28章のコメントが 「dpr 2 → 1.5 でピクセル数は 0.5625 倍」と書いているのが、まさに s² です。上の表の 0.75 の行と同じ数。4 章とも下げた理由が違います— 第23章は G-buffer のフラグメント側が重いから、第28章は全画面レイマーチングで 1 ピクセルあたりの評価回数が多いから、第33章はポストプロセスのコストがピクセル数にそのまま比例するから(この節の主張のいちばん素直な実例です。中間 FBO の枚数ぶんだけ倍率が効きます)、第36章はタイル書き出しの中間バッファを抑えるため(仕上がりが描画バッファの 4 倍まで伸びるので、土台を下げておく) - 第21章のシャドウマップの解像度。デモのボタンは 1024 と 256 で、テクセル数は 1,048,576 → 65,536 の 0.0625 倍(上の表の 0.25 の行と同じ比)。深度パスはシーンをもう一度描き直すパスなので、光源が増えれば「シーン全体を描く回数」がそのぶん増えます。ここには解像度以外にもうひとつつまみがあって、光源も物体も動かないなら、深度パスは毎フレーム回す必要がありません— 1 回描いた深度テクスチャを使い回せます。第21章のデモが毎フレーム描き直しているのは、 物体が動くからです
- 第29章の
cellSize。1 セルを何ピクセルで表すかの設定で、cellSizeが 10 なら状態テクスチャの面積は 1/100 になります。名前が違うだけで、やっていることは 「シミュレーションの解像度を落とす」= このつまみです。第29章にはもう 1 つstepsPerFrameというつまみがあって、リアクション・ディフュージョンは 1 フレームに 8 回更新します —1 フレームの更新パスのコストが 8 倍になるということです。cellSizeが 3 なので、その 8 回はいずれも画面の 1/9 の面積に対して走ります
上限を固定するのではなく、動かす
この本のデモはどれもMath.min(window.devicePixelRatio, 2)のように上限を定数で固定しています。書くのが簡単で、挙動が読めるからです。 もう一歩進めるなら、計測値に応じて倍率を動かすという設計があります — 一般に動的解像度 (dynamic resolution)と呼ばれます。
やるならヒステリシスを入れてください。「予算を超えたら下げる、下回ったら 上げる」を同じしきい値で書くと、しきい値のまわりで上げ下げが振動して、解像度がちらつく 絵になります。下げるしきい値と上げるしきい値を離す、上げる側だけ数フレーム連続で条件を 満たすことを要求する、1 回の変更幅を小さくする、といった手当てが要ります。
実測は s² に従わなかった
上のasideは「効かないこともある」という一般論のつもりで書いたものですが、このページのデモを実際に測ったら、そのとおりの結果が出ました。2 節の表の I〜L がそれです(デモ 3・10,000 体・フラグメント側の追加の仕事あり、倍率だけを変えた 4 条件)。
ピクセル数は 16.1 倍違うのに、GPU 時間は 1.9 倍しか違いませんでした。倍率 1.00 と 0.25 の 2 点から 1 次式GPU 時間 = 固定コスト + 係数 × ピクセル数を当てると、こうなります。
- 固定コスト ≈ 2.76 ms(ピクセル数に依らない分)
- ピクセル依存分 ≈ 2.80 ms(倍率 1.00 のとき)
つまりこの条件では、フラグメント側とそれ以外がほぼ半々でした。10,000 体ぶんの頂点の仕事とドローコールの分は、倍率をいくら下げても消えません。だから倍率を 0.25 まで 回しても、時間は半分あたりで止まります。「s² で減る」のはフラグメントシェーダーの実行回数であって、GPU 時間ではありません。この節の冒頭に書いた s² は前者の話で、後者がそれに従うかどうかは何が主役かで決まります。
なお、この計測ウィンドウはdevicePixelRatioが 1 だったので、ピクセル数の比は上の表の s²(1 / 0.5625 / 0.25 / 0.0625)と少しずれます — 実際は 1 / 0.5611 / 0.2500 / 0.06205 でした。上の表で比がぴったり揃っていたのは 1388 と 924 が 4 の倍数で切り捨てが効かないからで、694 と 462 では 0.75 倍と 0.25 倍のときに端数が落ちます。 「16.1 倍」は、s² ではなく表の I と L の実際のピクセル数から出した比です。
この結果は、この節の主張を否定しません。強めます。解像度スケールは 「フラグメント側が詰まっているとわかったとき」に効くつまみで、そうと分かる前に回しても 半分しか動かない、ということが 1 枚の表から読めました。回す前に切り分ける— 1 節に戻る理由が、これです。
6. シェーダーの中のコスト — 分岐・early-Z・頂点シェーダーの起動回数
ここからは測れないものが多い節です。シェーダーの中で何が起きているかを WebGL から観察する手段はほとんどありません。だからこの節では、ひとつずつ「これは仕様が保証していることか、実装の傾向か、観測できることか」を明示します。「一般にこう言われている」で済ませると、環境が変わったときに何を疑えばいいか分からなくなります。
先にこの章で出てくる主張を一覧で格付けしておきます。この節のものだけでなく、2 節(タイマークエリの制約)と 5 節(ピクセル数)で出た主張も同じ表に並べます。
| よく言われること | 格 | 根拠 / 但し書き |
|---|---|---|
| 同じ target のクエリを入れ子にはできない | 仕様 | ES 3.0.6 §2.14「その target の active query object name が非ゼロなら INVALID_OPERATION」。EXT_disjoint_timer_query の Issue (6) も「複数のタイマークエリを同時には実行できない」 |
| フラグメントの数はピクセル数に比例する | 仕様 | ラスタライズの定義。ES 3.0.6 §3.6.1 は「多角形の 2 次元射影の内側にフラグメントの中心があれば、そのフラグメントが生成される」(point sampling)としている。ただし「フラグメントシェーダーが何回走るか」は重なりと early-Z しだい |
| GPU はスレッドを束にして同じ命令を実行する | 実装 | ES 3.0.6 にも GLSL ES 3.00 にも規定は無い。GLSL ES 3.00 §8.9 が dFdx の説明で「SIMD array 上で並列に評価されることを仮定している」と書いているのが、仕様がこの構造に触れている唯一の場所。束のサイズを問い合わせる API は WebGL に無い |
| uniform で決まる分岐は束の中で割れない | 実装 | 「割れない」こと自体は定義から言えるが(全スレッドが同じ値を見る)、それが速いかどうかは実装の話。GLSL ES 3.00 §3.10.2 の uniform / non-uniform control flow は導関数が定義されるかどうかを決める規定であって、コストの規定ではない |
短い分岐は両方計算して mix したほうが速い | 傾向 | 両方の枝を必ず評価するぶん命令は増えるので、割れる頻度と枝の長さしだいで逆転する。手元で測って決めること |
| early-Z で隠れるフラグメントのシェーダーを省ける | 実装 | ES 3.0.6 §4.1 は「フラグメントシェーダーのあとに深度テスト」の順序で書かれている。前倒しの規定は無く、early_fragment_tests のようなレイアウト修飾子も GLSL ES 3.00 には無い(ES 3.1 以降の機能) |
頂点シェーダーの起動回数は count × instanceCount | 誤り | ES 3.0.6 §2.9.3 が定めているのは「GL に転送される頂点の要素数」まで。起動回数を定めた文は無い(第31章 3 節と同じ書き分け) |
状態の切り替え(useProgram など)を減らすと GPU 側が楽になる | 傾向 | 状態変更のコストを定めた文は ES 3.0.6 にも WebGL 2.0 仕様にも無い。実装では切り替えのたびにパイプラインの再検証が起きうるが、その仕事が「切り替えの時点」で起きるのか「次のドローコールの時点」で起きるのかも実装依存で、WebGL からは観察できない。第34章 6 節が測ったのは呼び出し回数まで |
「仕様」= ES 3.0.6 / GLSL ES 3.00 / WebGL 2.0 仕様に明文がある。「実装」= 仕様には無く、実装がそうしている(あるいはそうできる)。「傾向」= 条件しだいで逆転しうる。 「誤り」= 仕様の文言を読み違えている。
分岐 — 束の中で割れると両方走る
第5章 6 節で制御構文を扱ったとき、「性能の話としては第35章で再訪します」と書きました。 その約束を果たします。
GPU は、多数のスレッドを束にして、束の中では同じ命令を同時に実行します。呼び方は 実装によって違い、warp / wavefront / subgroup などと呼ばれます。束の中でifの行き先が割れると、ハードウェアは両方の枝を順に実行して、いらないほうの結果を マスクで捨てます。だから「割れる分岐」は、両方の枝の合計だけ時間がかかりうる、ということになります。
ただし、この束は仕様が規定しているものではありません。ES 3.0.6 にも GLSL ES 3.00 にも、実行が束で行われるという規定はありません。GLSL ES 3.00 が唯一この構造に触れているのは §8.9 のdFdxの説明で、「式が SIMD array の上で並列に評価され、任意の時点でその SIMD array が表す格子点における関数の値が分かっていることを仮定している (We are assuming that the expression is being evaluated in parallel on a SIMD array…)」と書いています。これは導関数がなぜ計算できるのかの説明であって、コストの規定ではありません。束のサイズを WebGL から問い合わせる方法もありません。
§3.10.2「Uniform and Non-Uniform Control Flow」も同じで、「すべてのフラグメントが同じ経路を 通る状態」を uniform control flow と定義していますが、これは導関数が定義されるかどうかを決めるための定義です(非一様な制御フローの中ではdFdx/dFdy/texture()の暗黙のミップ選択が未定義になる)。速さの話は一言も書かれていません。
そのうえで、実務としてよく言われることを 3 つ、格を付けて並べます。
- uniform で決まる分岐は割れない。すべてのスレッドが同じ uniform の値を見るので、行き先が分かれようがありません。この章のデモの 2 つのループがまさにそれで、回数は
u_vertexWork/u_fragmentWorkという uniform です。つまりこのデモのループは「割れる分岐」の例にはなっていません— 純粋に「命令を増やすつまみ」です - ピクセルごとに割れる分岐は高い。模様の境界や物体の輪郭で条件が変わるような 分岐がこれです。どのくらい高いかは、束の中で実際に割れる頻度(空間的にまとまっているか、 ばらけているか)で変わります
- 短い分岐は、両方計算して
mixしたほうが速いことがある。「ことがある」であって、常にではありません。mixにすれば必ず両方の枝を評価するので、割れる頻度が低ければ分岐のほうが速くなります。 この本の中にも例があります — 第32章の01-minimal.update.vertは、3 軸の壁の反射をstepとmixで書いていて、分岐を 1 つも使っていません。あれは「速いから」ではなく3 軸をまとめて書けるから選んだ形です
float wobble = 0.0;
for (int i = 0; i < u_vertexWork; i++) {
float k = float(i) + 1.0;
wobble += sin(dot(worldPosition, vec3(k, k * 1.3, k * 0.7)) + u_time) / k;
}
worldPosition += normal * (wobble * 0.01 * u_scale);
float extra = 0.0;
for (int i = 0; i < u_fragmentWork; i++) {
float k = float(i) + 1.0;
extra += sin(dot(v_worldPosition, vec3(k * 0.9, k * 1.7, k * 1.1)) + u_time) / k;
}
color *= 1.0 + 0.03 * extra;どちらも「回数を uniform で持ち、結果を出力に足す」形にしてあります。回数が定数だとコンパイラが展開して畳み込めてしまい、結果をどこにも使わないと丸ごと消せて しまうからです。負荷のつまみを作るときは、この 2 点を外すと「つまみを回しても何も変わらない」ことになります。
early-Z — 仕様の順序と、実装の前倒し
第13章 7 節で「実際の GPU は条件が揃えば深度テストをシェーダーの前に前倒しする」と予告し、第23章 9 節の補足で「ジオメトリパスでは効き、ライティングパスでは(深度テストを切るので) 関係ない」という 2 つの顔を見ました。ここでは効かなくなる条件を扱います。
まず立ち位置をはっきりさせます。early-Z は仕様に無い概念です。ES 3.0.6 §4.1 は「ラスタライズが生んだフラグメントに対して、以下のテストを実行される順に述べる」として、フラグメントシェーダーのあとに深度テストを置いています。前倒しを許す規定も、 禁じる規定もありません。GLSL ES 3.00 にearly_fragment_testsのような明示的なレイアウト修飾子もありません(あれは OpenGL ES 3.1 以降の機能です)。実装は「結果が変わらないと保証できるときだけ」前倒しをしている、というのが正確な言い方です。
そこから、効かなくなる条件が出てきます。
- フラグメントシェーダーが
discardを書いている。discardは「深度も含めてどのバッファも更新しない」(GLSL ES 3.00 §6.4)ので、走らせてみるまで 深度を書いてよいかが決まりません。前倒しの前提が崩れます。これは仕様が保証している話ではなく、上の「結果が変わらないと保証できるとき」に 当てはまらなくなる、という推論です。この章のシェーダーにdiscardが 1 つも無いのは、その条件を踏まないためです - シェーダーが
gl_FragDepthを書いている。gl_FragDepthは GLSL ES 3.00 §7.2 のフラグメントシェーダーの組み込み出力で、WebGL2 でも使えます。書いた場合、深度はシェーダーを走らせるまで決まらないので、前倒しの前提が 崩れます。深度を自分で書くつもりがないなら、書かない— 「書いてあるだけ」でも前提は崩れます(WebGL 2.0 仕様は「静的に代入があるのに、その フラグメントで代入が実行されなかったら 0 を使う」という規定まで足しています) - 深度書き込みを切っている。第30章 4 節の
gl.depthMask(false)は半透明を重ねるための設定でした。深度を書かないので、後続のフラグメントを捨てる材料が 増えません — early-Z が「効く」も何も、捨てる根拠が積み上がらないのです。 半透明のパスが重なりぶんまるごとコストになるのは、このためです - 描画順が奥から手前(back-to-front)になっている。手前を先に描けば、 奥のフラグメントは深度テストで落ちます。逆順だと全部が一度は色を計算されます。 不透明な物体を手前から奥(front-to-back)に並べて描くのが定石なのは このためで、第23章のディファードはそれを「重なりを構造的に無くす」ことで解決していました
これらはどれも「絵は変わらないが、時間は変わりうる」話です。だから確かめる には時間を測るしかありません。この章のデモで体数を増やすと重なりが増えるので、discardを 1 行入れて GPU 時間がどう動くかを見るのが、いちばん手早い実験になります(「壊してみる」に 入れてあります)。
頂点シェーダーの起動回数は、仕様が定めていない
第31章 3 節の落とし穴で、この書き分けはすでに一度出ています。同じ言い方に揃えます。
ES 3.0.6 §2.9.3 はDrawArraysInstanced/DrawElementsInstancedを「1 インスタンスぶんを描く命令をループで回したもの」として定義していて、そこから確実に 言えるのはGL に転送される頂点の要素数が count × instanceCountだ、ということだけです。この章の読み出し行が「頂点 192 × 10,000 = 1,920,000」と出しているのもこの数で、192 はインデックスの個数です。
一方、頂点シェーダーが実際に何回実行されるかを定めた文は、仕様のどこにもありません。インデックス描画では同じ頂点番号が何度も現れるので(この章の球は 45 頂点を 192 個のインデックスで参照しています)、実装は変換済みの結果を使い回すことができます — 一般にpost-transform キャッシュと呼ばれる仕組みです。キャッシュの 大きさも方式も実装依存で、WebGL から問い合わせる方法はありません。
ですから「起動回数はぴったり 1,920,000 回」とは書けません。書けるのは「体数に比例して増える」ということまでで、この章の主張(体数を増やすと頂点側の仕事が増える)にはそれで足ります。
デモ 1 が1 体の大きさを体数の −1/3 乗に比例させているのは、球の体積の総和N × (4/3)πr³を一定に保つためです — 体数を上げても、絵の詰まり具合が大きく変わりません。ただしこれで頂点側だけを切り離せているわけではありません。投影面積の総和のほうは N × πr² ∝ N^(1/3)で増えます。100 体から 50,000 体で 500 の 3 乗根 = 7.94 倍です。実際に 塗られるピクセル数は重なりのぶんこれより緩やかにしか増えませんが(総和は重なりを二重に 数えています)、増えることは増えます。デモ 1 のつまみは「頂点側だけ」の つまみではありません。頂点側とフラグメント側を本当に分けて動かしたいときは、体数を固定して 追加の仕事を置き分けるデモ 2 のほうを使ってください。
状態の切り替えのコストは、この章の道具では切り出せない
第34章 6 節の Renderer は、描画の前にシェーダー → マテリアルの順で並べ替えて、マテリアルの 切り替えを 14 回から 3 回へ、gl.uniform*の呼び出しを 90 回から 46 回へ減らしていました。そして「状態の切り替えのコストそのものは、この本では最後まで測りません」と書いて閉じています。なぜ測らないのかを引き取るのがこの節です。理由は 2 節で作った道具の性質そのものにあります。
TIME_ELAPSED_EXTのクエリが返すのはbeginQueryからendQueryまでの区間の合計で、その内訳は分かりません。入れ子にもできない(表の 1 行目) ので、区間を細かく割って比べることもできません。しかも状態の切り替えは、切り替えたその場で仕事が起きるとはかぎりません— 実装がパイプラインの再検証を次のドローコールまで遅らせていれば、useProgramだけを囲んで測っても 0 に近い値が出ます。「切り替え 1 回が何 ms」という数字は、この本の 道具では出せません。
できるのは、フレーム全体を 2 通り測って比べることだけです。第34章のsortBatchesをtrueにしたビルドとfalseにしたビルドで、同じシーン・同じウィンドウ・同じ倍率のまま GPU 時間を測り、2 節の表と同じ形で条件ごとに並べる。それで出るのは1 環境の観測であって、「状態の切り替えは重い / 軽い」という一般則ではありません。第34章がこの並べ替えについて呼び出し回数までしか主張しなかったのは、そこから先が測れないからです。
7. 解放する — この本が積み残してきた分
ここから後半です。src/lessons/のあちこちに「リソース解放の一般論は第35章」というコメントが埋まっています。回収します。
WebGL のオブジェクトは、作ったら対になる関数で消せます。全部並べると次のとおりです。
| リソース | 作る API | 消す API | 消さないとどうなるか |
|---|---|---|---|
WebGLBuffer | createBuffer | deleteBuffer | 頂点・インデックス・UBO・transform feedback の実体。この本でいちばん大きいのは第32章の状態バッファ(30 万粒子で 8.0 MiB × 2 本) |
WebGLTexture | createTexture | deleteTexture | 画素の実体。画面いっぱいの RGBA8 で 1 枚あたり 4 バイト × ピクセル数。ミップマップ付きならその約 4/3 倍 |
WebGLFramebuffer | createFramebuffer | deleteFramebuffer | それ自体は「アタッチ先の一覧」なので小さい。重いのはぶら下がっているテクスチャ・レンダーバッファのほう |
WebGLRenderbuffer | createRenderbuffer | deleteRenderbuffer | 深度・ステンシルの実体。DEPTH_COMPONENT24 なら 1 ピクセル 4 バイト |
WebGLProgram | createProgram | deleteProgram | リンク済みの実行コード。1 本のバイト数は小さいが、切り替え式のデモで押すたびに作ると押した回数だけ増える |
WebGLShader | createShader | deleteShader | コンパイル済みの中間表現。src/lib/shader.ts の linkProgram がリンク直後に消しているので、この本では溜まらない |
WebGLVertexArrayObject | createVertexArray | deleteVertexArray | 属性の配線表。小さいが、バッファへの参照を握っている点が効く(下の「すぐには消えない」) |
WebGLQuery | createQuery | deleteQuery | 結果 1 個ぶんの入れ物。この章の gpu-timer.ts が 4 個作る |
WebGLTransformFeedback | createTransformFeedback | deleteTransformFeedback | 出力バッファのバインドの束(第32章 3 節)。それ自体は小さい |
WebGLSync | fenceSync | deleteSync | フェンス 1 本。この本では使っていないが、削除の規定(下)がいちばん分かりやすい例 |
deleteXxx は「すぐには消えない」
gl.deleteBuffer(buffer)を呼んだ瞬間にメモリが返る、というモデルは正しくありません。ES 3.0.6 の Appendix D.1.3 がこう定めています。
When a buffer, texture, sampler, renderbuffer, query, or sync object is deleted, its name immediately becomes invalid (e.g. is marked unused), but the underlying object will not be deleted until it is no longer in use.
OpenGL ES 3.0.6 §D.1.3 "Deleted Object and Object Name Lifetimes"
「使用中 (in use)」の定義も同じ節にあります。バッファ・テクスチャ・サンプラ・レンダーバッファはコンテナオブジェクトにアタッチされているあいだ(VAO に付いたバッファ、FBO に付いたテクスチャなど)、またはどこかのバインドポイントにバインドされているあいだは使用中です。同期オブジェクトは対応するフェンスが完了するまで、クエリオブジェクトは アクティブなあいだ。
プログラムとシェーダーは扱いが少し違い、「削除のフラグが立つ」だけになります。 §2.12.1 は「シェーダーがどのプログラムにもアタッチされていなければ即座に削除される。そうでなければ 削除のフラグが立ち、どのプログラムにもアタッチされなくなったときに削除される」、§2.12.3 は 「プログラムがどのコンテキストのカレントプログラムでもなければ即座に削除される。そうでなければ フラグが立つ」と定めています。src/lib/shader.tsの linkProgram がリンク直後にdeleteShaderを呼んでいるのは、この規定に乗った書き方です— プログラムにアタッチされているので消えず、プログラムが消えたときに一緒に消えます。
この本の積み残し
では、この本自身はどうだったのか。2026-08-08 に src/lessons/ を 自分で数え直した結果が次の表です。
| 章 | 何をしているか | 状態 |
|---|---|---|
| 第6章のハーネス | stop() は rAF を止めて pointermove を外すだけ。プログラムは残る(シェーダーのほうは linkProgram が削除のフラグを立てているので、プログラムが消えれば一緒に消えます) | 未解放 |
| 第7〜10章・第24〜28章(9 章) | デモを切り替えるたびに startFullscreenShader を呼び直すので、古いプログラムが 1 本ずつ残る | 未解放 |
| 第11〜23章・第30〜36章(20 章) | rAF ループを止めず、canvas に付けたリスナーも外さない。この章も含みます | 未解放(意図的) |
| 第16章 | 1×1 のプレースホルダを差し替えたあと deleteTexture で消す | 解放済み |
| 第20章・第21章・第23章 | リサイズで作り直すとき、古い RenderTarget / DepthTarget / G-buffer を消す | 解放済み |
| 第22章 | 事前計算で作ったキューブマップとレンダーターゲットを消していない(作業用の FBO だけは消している) | 未解放 |
| 第29章 | 章ローカルのハーネスの stop() が FBO 2 枚・テクスチャ 2 枚・プログラム 2 本を消す | 解放済み |
| 第32章 | デモを切り替えるたびに deleteParticleSystem で状態バッファと VAO を消す(常駐 20.6 MiB) | 解放済み |
| 第33章 | リサイズでチェーンの中間 FBO を作り直すとき、resizeRenderTarget / deleteRenderTarget と自前の deleteMultisampleTarget で古いものを消す | 解放済み |
| 第34章 | エンジンが Mesh.dispose / Shader.dispose / Renderer.dispose を持っている。ただしデモを切り替えてもメッシュとシェーダーを作り直さないので、呼ぶ場面が無い | 口だけある |
| 第36章 | 書き出しが終わったタイルの FBO を deleteTileCapture で消す。URL.createObjectURL で作った Blob URL も次の書き出しの前に revokeObjectURL で手放す | 解放済み |
| この章 | ジオメトリのバッファ・VAO・プログラム・クエリを作りっぱなしにしている。ロストしたら参照を捨てるだけ | 未解放(意図的) |
数え方(いずれも 2026-08-08 にgrep -rlで数えた実測): 「古いプログラムを解放していない」はsrc/lessons/*/main.tsから「古いプログラムの解放は省略」というコメントを検索して 9 件(第7・8・9・10・24・25・26・27・28章)。 「rAF を止めない」は「ページと寿命を共にする」というコメントを検索して 20 件(第11〜23章・第30〜36章)。 後者は伏線台帳が「11〜20・30〜32」= 13 章としていたところが実測 20 章で、第21・22・23章と、第5部の残り(第33〜36章)もこの形でした。この章自身も 20 件のうちの 1 件です— この節は他の章の積み残しを数え上げているように見えますが、同じ判断をこの章もしています。放置してよい条件(すぐ下)を満たしているから放置している、というだけです。
なぜ放置してきたのか
正直に書きます。このサイトのデモは、ページと寿命を共にする前提で書いてあります。ページを離れればコンテキストごと消え、コンテキストが消えればぶら下がっていた GPU リソースも消えます。だから解放しなくても実害が出ません。第6章のハーネスのstop()が rAF の停止とリスナーの解除だけで、プログラムを消さないのも同じ理由です —あの時点の読者に deleteProgram を見せても、消す理由が分からないからです(第6章の抽象化のルール: 読者がまだ中身を知らないコードは隠さない、の裏返しでもあります)。
問題になるのは、次の 2 つの場合だけです。
- コンテキストが生き続ける— SPA のようにページ遷移でコンテキストが 消えない作り。ルートを移動するたびにリソースが積み上がります
- 同じページで大量に作り直す— 切り替えやリサイズのたびに大きなものを 作り直す作り
この本の中で「消す」ほうを選んだ 6 か所は、どれも 2 番目に当たります。
- 第16章— 画像が届くまでの 1×1 プレースホルダを
texStorage2Dで作った本物に差し替えるとき、古いほうをdeleteTexture。「作り直したら古いものを消す」の最小形です - 第20章・第21章・第23章— サイズや形式が変わるたびに FBO を作り直すので、
src/lib/framebuffer.tsのresizeRenderTargetが新しく作ってから古いものを消す順で書かれています(作成に失敗しても、 呼び出し側が解放済みの参照を掴まないため) - 第29章— 章ローカルのハーネス
feedback.tsのstop()が、FBO 2 枚・テクスチャ 2 枚・プログラム 2 本まで消します。第6章のハーネスとの差はここで、理由は状態テクスチャが画面いっぱいの大きさ × 2 枚だからです。 デモを 3 回切り替えたら、消さなければ画面 6 枚ぶんが残ります - 第32章— デモを切り替えるたびに
deleteParticleSystemで状態バッファと VAO を消します。30 万粒子で常駐 20.6 MiB(2 節)。これも 「大きいから消す」です - 第33章— ポストプロセスのチェーンはリサイズのたびに中間 FBO を全部作り直すので、同じ
resizeRenderTargetと、自前のマルチサンプル FBO 用のdeleteMultisampleTargetで古いものを消します。段数を変えると FBO の枚数そのものが変わる章です - 第36章— 4 倍書き出しのタイルは 1 枚ずつ FBO に描いて貼り合わせるので、終わったら
deleteTileCapture。URL.createObjectURLで作った Blob URL も、次の書き出しの前にrevokeObjectURLで手放します(GPU 側ではないリソースにも同じ話があるという例です)
第34章は、その中間にあります。エンジンはMesh.dispose/Shader.dispose/Renderer.disposeを持っているのに、デモを切り替えてもメッシュとシェーダーを作り直さないので、一度も呼ばれません。抽象化を設計するときは解放の口を先に用意しておく— 呼ぶ場面ができたときに、あとから全体を書き換えずに済みます。
この章のデモも、実は消していません。プログラムもバッファも VAO もクエリも作りっぱなしです。作り直す場面が無く(体数はドローコールの引数を変えるだけ、 解像度は描画バッファのサイズを変えるだけ)、ページと寿命を共にするからです。解放の章が解放していないのは矛盾ではなく、「条件を満たさないから要らない」という 判断の実例として置いてあります。
コンテキストそのものを手放す
個々のオブジェクトではなくコンテキストごと捨てたいときがあります。WebGL にはgl.destroy()のような API はありません。明示的に手放す唯一の手がWEBGL_lose_contextのloseContext()です。拡張の原文がそう書いています — 「このメソッドが呼ばれたとき、実装は下層のグラフィックスコンテキストとすべてのグラフィックス リソースを破棄すべきである。これは、アプリケーションが WebGL API の使用をプログラム的に停止するための、推奨される 仕組みである」。
もうひとつ、ブラウザが同時に保持できるコンテキストの数には上限があります。第2章 1 節でも触れましたが、これは WebGL 仕様の規定ではありません。仕様には コンテキスト数の上限に関する記述がありません(WebGL 1.0 仕様の全文を検索して確認)。実装の 都合です。
Chromium の実装を見ると、上限は定数ではなく実行時の設定値で、WebglPreferences::max_active_webgl_contextsとして持たれています(third_party/blink/public/platform/web_graphics_context_3d_provider.h)。 新しいコンテキストを作るときのActivateContext()が、上限に達していたらForciblyLoseOldestContext()を呼び、「WARNING: Too many active WebGL contexts. Oldest context will be lost.」という警告を コンソールに出して、いちばん古いコンテキストをロストさせます(third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc)。具体的な数値がどこで設定されるかまでは辿れませんでしたので、この章では数字を書きません。覚えておくべきは数ではなく挙動です —上限を超えると、古いほうが「ロスト」という形で消える。だから次の節が要ります。
8. コンテキストロスト — 起こる前提で書く
WebGL のコンテキストは、いつでも失われます。アプリのバグとは関係なく、です。 WebGL 1.0 仕様のisContextLost()の項が「モバイル端末の電源イベントのような出来事によって、WebGL レンダリングコンテキストはいつでも失われることがあり、アプリケーションはそれを作り直す必要が ある」と書いています。原因としてはこのあたりです。
- GPU のリセット、ドライバのクラッシュや再起動
- 他のタブやアプリが GPU のメモリを使い尽くす
- 省電力のための GPU の切り替え、スリープからの復帰
- ブラウザ自身の判断。7 節のコンテキスト数の上限がこれ。仕様も
powerPreferenceの項で「WebGL 実装は、この属性の値にかかわらず、電力とメモリの消費を調整するために コンテキストのロストと復帰のイベントを使う」と明言しています - この節のデモが使う
WEBGL_lose_contextのloseContext()
仕様が定めている手順
WebGL 1.0 仕様の「The Context Lost Event」が、ユーザーエージェント側の手順を定めています (WebGL 2.0 は 1.0 の差分仕様なので、この規定は 1.0 側にあります)。順に並べると:
- コンテキストの webgl context lost フラグ を立てる
- このコンテキストが作ったすべての
WebGLObjectの invalidated フラグを立てる WEBGL_lose_contextを除くすべての拡張を無効にする- タスクをキューに入れて、canvas で
webglcontextlostを発火する - 「イベントの canceled フラグが立っていなければ、以降の手順を中止する」
- そうでなければ、復帰可能な描画バッファを待って、復帰の手順を走らせる
5 番が、この節でいちばん重要な行です。event.preventDefault()を呼ばないと、webglcontextrestoredは永久に来ません。仕様がわざわざ、次の 1 行を例として載せています。
canvas.addEventListener("webglcontextlost", function(e) { e.preventDefault(); }, false);復帰側(「The Context Restored Event」)も 6 手順です。コンテキスト作成パラメータを使って描画バッファを作り直し、ロストフラグを下ろし、OpenGL のエラー状態をリセットし、webglcontextrestoredを発火する。そして仕様は、そのあとにこう書いています。
Once the context is restored, WebGL resources such as textures and buffers that were created before the context was restored are no longer valid. Previously enabled extensions are not restored. The application will want to restore all modified state and destroyed extensions and resources.
WebGL 1.0 仕様 "The Context Restored Event"
拡張も復元されません。この章のタイマークエリは拡張なので、復帰したらgetExtensionからやり直しです。ここを忘れると、復帰後に GPU 時間だけが出なくなります。
ロスト中の gl.* はどうなるか
WebGL 1.0 仕様のメソッド呼び出しのアルゴリズムが定めています。整理すると:
isContextLost()は、ロストフラグが立っていればtrueを返します。これが唯一の判定手段ですgetError()は、ロスト中に最初の 1 回だけCONTEXT_LOST_WEBGLを返し、そのあとは復帰するまでNO_ERRORを返します。「毎回返る」ではありません。ポーリングでロストを検出しようとしてgetError()を使うと、1 回目以降は気づけません- ロスト中の多くのメソッドはエラーを出さずに既定値を返します(戻り値が
anyや nullable ならnull)。だからgl.createBuffer()はnullを返し、gl.getParameter()もnullを返します - 復帰したあとに、古い(invalidated な)オブジェクトを引数として渡すと
INVALID_OPERATIONになります。同じアルゴリズムの分岐に書かれています。だからロストしたコンテキストのオブジェクトをdeleteXxxしようとしてはいけません— 参照を捨てるだけにします
この章のデモの作り
以上を踏まえた設計は、ひとことで言えば「作る手続きを 1 か所にまとめる」です。 復帰時にやることが「その関数をもう一度呼ぶ」だけになれば、書き漏らしが構造的に起きません。
function createResources(gl: WebGL2RenderingContext, geometry: Geometry): Resources {
const program = linkProgram(
gl,
compileShader(gl, gl.VERTEX_SHADER, vertexSource),
compileShader(gl, gl.FRAGMENT_SHADER, resolveIncludes(fragmentTemplate)),
);もうひとつのポイントは、CPU 側のデータと GPU 側のオブジェクトを分けて持つことです。 ジオメトリのFloat32Arrayはロストしても生き残るので、作り直すのは「それを GPU に上げたもの」だけで済みます。
// --- CPU 側のデータ。コンテキストがロストしても生き残る -------------------------
// 1 体 45 頂点・192 インデックス。5 万体を回すので粗くしてある
const geometry = createSphere(0.5, 8, 4);
// --- GPU 側。ロストしたらまるごと作り直す ---------------------------------------
let resources: Resources | null = createResources(gl, geometry);
let timer: GpuTimer = createGpuTimer(gl);ハンドラは 2 本です。
canvas.addEventListener('webglcontextlost', (event) => {
// これを呼ばないと webglcontextrestored は来ない。仕様は
// 「イベントの canceled フラグが立っていなければ以降の手順を中止する」と書いている
event.preventDefault();
lostAt = performance.now();
// GPU 側のものは全部無効になった。参照を捨てるだけで、deleteXxx は呼ばない —
// 無効化されたオブジェクトは、復帰後に引数として渡すと INVALID_OPERATION になる
// (WebGL 1.0 仕様。ロスト中は既定値が返るだけでエラーにはならない)。持ち越さないのが安全
resources = null;
timer = createGpuTimer(gl); // ロスト中なので中身は空のタイマーになる
resetFrameSamples();
updateReadout();
buildOptionButtons();
});
canvas.addEventListener('webglcontextrestored', () => {
// ここが「usably restored」になる最初の場所(WEBGL_lose_context の restoreContext の項)。
// 拡張は復元されないので、タイマークエリも取り直す
resources = createResources(gl, geometry);
timer = createGpuTimer(gl);
resetFrameSamples();そして描画ループは止めません。resourcesがnullのあいだは中身を飛ばすだけです。
// ロスト中はループを止めずに描画だけ飛ばす。止めてしまうと、復帰したときに
// 誰がループを再開するのかを別に考えることになる(本文 8 節)
if (resources && !gl.isContextLost()) {ループを止める設計(仕様のサンプルコードはcancelAnimationFrameしています)も間違いではありませんが、そうすると「誰がループを再開するのか」を復帰側にもう 1 つ書くことになります。止めないほうが、状態がひとつ減ります。
failIfMajorPerformanceCaveat — 落ちた環境で気づく
第2章 2 節で名前だけ挙げたコンテキスト属性を、ここで回収します。WebGL 1.0 仕様の定義はこうです。
If the value is true, context creation will fail if the implementation determines that the performance of the created WebGL context would be dramatically lower than that of a native application making equivalent OpenGL calls.
WebGL 1.0 仕様 "WebGLContextAttributes"
仕様が挙げている理由は 2 つです。
- ユーザーの GPU ドライバが不安定だと分かっている場合に、実装がソフトウェアラスタライザに切り替えることがある
- ページの残りと合成する前に、フレームバッファを GPU メモリからシステムメモリへ 読み戻す必要がある実装がある
既定値は false です。仕様は「高い性能を必要としない アプリケーションは既定値のままにするべきだ」としています。trueにするとgetContextがnullを返し(webglcontextcreationerrorが飛びます)、そこで「この環境では素直に WebGL を使わない」という判断ができます。実際にブラウザがどう判定しているかは、この章では確認していません。
関連して、powerPreference: 'high-performance'についての仕様の注記も引いておきます —「high-performance を要求するアプリケーションは、コンテキストロストの処理をテストし、 堅牢に保つべきである。ユーザーエージェントは、バックグラウンドの high-performance なコンテキストをロストさせる判断を下す可能性が非常に高いからだ」。速さを要求することと、ロストに備えることはセットです。
壊れない前提で書かない
この節をひとことにまとめるなら、「初期化」と「復帰」を別の手続きとして書かない、です。
多くのコードで初期化が復帰に使えないのは、初期化のコードが「起動時に 1 回だけ走る」前提で、DOM の取得・イベントの登録・GPU リソースの生成・状態の初期値の設定を 全部ひとつながりに書いているからです。ロストが起きると、そのうちGPU リソースの生成と状態の設定だけをもう一度やる必要が出ます。分けていなければ、そこで初めて分ける作業が発生します — しかも「どれが GPU 側だったか」を思い出しながら。
先に分けておけば、復帰処理は 1 行です。分ける手間はロストが起きなくても無駄になりません— 「この関数が触るのは GPU 側だけ」という境界は、そのままテストしやすさとリソース解放のしやすさになります。7 節の表で「作る API」と「消す API」を並べたのも、同じ境界の別の見え方です。
コード全文
// 第35章: パフォーマンスとロバスト性 — 計測・コンテキストロスト
//
// 1 つのシーン(インスタンシングした球)に、つまみを 3 つ付けてある。
// ① インスタンス数 100 / 1,000 / 10,000 / 50,000
// ② 追加の仕事の置き場所 なし / 頂点 / フラグメント / 両方
// ③ 解像度スケール 1.0 / 0.75 / 0.5 / 0.25
// デモ 1〜3 は、このうち 1 つだけを動かせるようにしたもの。デモに入るときに
// 3 つとも既定値へ戻すので、「いま何を測っているか」が読み出し行だけで言える。
//
// デモ 4 はコンテキストロストと復帰。このページの唯一のコンテキストを壊すので、
// GPU 側のものは全部 createResources() 1 か所で作り、復帰時にもう一度呼ぶだけで
// 戻るようにしてある(本文 8 節)。CPU 側のデータ(ジオメトリ・カメラ・つまみ)は
// ロストしても生き残るので、作り直すのは GPU 側だけ。
//
// この章は、この本で GPU 時間を出す唯一の章。測定条件は読み出し行に全部出している。
import { mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { createSphere, type Geometry, interleave } from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import colorChunkSource from './color.glsl?raw';
import { createGpuTimer, type GpuTimer } from './gpu-timer';
import fragmentTemplate from './scene.frag?raw';
import vertexSource from './scene.vert?raw';
// #include の解決は第23章からの文字列置換。置換文字列を関数で渡しているのは、
// String.replace が `$&` などを特別扱いするため
function resolveIncludes(source: string): string {
return source.replace('#include "color.glsl"', () => colorChunkSource.trim());
}
// ---------------------------------------------------------------------------
// 定数
// ---------------------------------------------------------------------------
// カメラ定数(第3部の標準): fovy 45°・near 0.1・far 100
const FOVY = (45 * Math.PI) / 180;
const NEAR = 0.1;
const FAR = 100;
const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
const TARGET: ReadonlyVec3 = vec3.fromValues(0, 0, 0);
// 面から光源へ向かう単位ベクトル(第17章の約束)
const LIGHT_DIRECTION: ReadonlyVec3 = vec3.normalize(
vec3.create(),
vec3.fromValues(0.4, 0.75, 0.52),
);
// どちらもリニア値。出す直前に scene.frag が sRGB へ変換する(第27章)
const COLOR_INNER: ReadonlyVec3 = vec3.fromValues(0.85, 0.42, 0.14);
const COLOR_OUTER: ReadonlyVec3 = vec3.fromValues(0.09, 0.24, 0.62);
// 球を散らす範囲の半径(ワールド)
const BALL_RADIUS = 5.2;
// 1 体の基準の大きさ。実際にはここに (REFERENCE_COUNT / count)^(1/3) を掛ける
const REFERENCE_COUNT = 1000;
const REFERENCE_SCALE = 0.32;
// 追加の仕事の反復回数。頂点側とフラグメント側で別々に持つ
const VERTEX_WORK = 96;
const FRAGMENT_WORK = 64;
// devicePixelRatio の上限(第2章 4 節)。解像度スケールはこの上からさらに掛ける
const MAX_DPR = 2;
// 移動平均の窓。gpu-timer.ts の SAMPLE_LIMIT と同じ本数にしてある
const FRAME_SAMPLES = 30;
type WorkMode = 'none' | 'vertex' | 'fragment' | 'both';
const WORK_LABELS: Record<WorkMode, string> = {
none: 'なし',
vertex: '頂点',
fragment: 'フラグメント',
both: '両方',
};
function vertexWorkOf(mode: WorkMode): number {
return mode === 'vertex' || mode === 'both' ? VERTEX_WORK : 0;
}
function fragmentWorkOf(mode: WorkMode): number {
return mode === 'fragment' || mode === 'both' ? FRAGMENT_WORK : 0;
}
// ---------------------------------------------------------------------------
// GPU 側のリソース(コンテキストロストで全部無効になるもの)
// ---------------------------------------------------------------------------
interface SceneLocations {
view: WebGLUniformLocation | null;
projection: WebGLUniformLocation | null;
time: WebGLUniformLocation | null;
count: WebGLUniformLocation | null;
ballRadius: WebGLUniformLocation | null;
scale: WebGLUniformLocation | null;
vertexWork: WebGLUniformLocation | null;
lightDirection: WebGLUniformLocation | null;
cameraPosition: WebGLUniformLocation | null;
colorInner: WebGLUniformLocation | null;
colorOuter: WebGLUniformLocation | null;
fragmentWork: WebGLUniformLocation | null;
}
interface Resources {
program: WebGLProgram;
locations: SceneLocations;
vao: WebGLVertexArrayObject;
indexCount: number;
/** 読み出し行に出す「このレンダラで測った」の材料 */
renderer: string;
}
/**
* レンダラ名。WEBGL_debug_renderer_info は取れないことがある(拡張仕様自身が
* 「実装はフィンガープリンティング対策として値を伏せることがある」と書いている)ので、
* 取れなかったらコアの RENDERER に落とす。それも空なら諦める(本文 2 節)。
* どのブラウザがどれだけ伏せるかは調べていない
*/
function readRendererName(gl: WebGL2RenderingContext): string {
const info = gl.getExtension('WEBGL_debug_renderer_info');
if (info) {
const unmasked = gl.getParameter(info.UNMASKED_RENDERER_WEBGL);
if (typeof unmasked === 'string' && unmasked.length > 0) return unmasked;
}
const generic = gl.getParameter(gl.RENDERER);
return typeof generic === 'string' && generic.length > 0 ? generic : '不明';
}
/**
* GPU 側のものを全部ここで作る。コンテキストが復帰したら、もう一度これを呼ぶだけで戻る。
* 「作る手続きが 1 か所にまとまっていること」がロスト対応の設計の要点(本文 8 節)
*/
function createResources(gl: WebGL2RenderingContext, geometry: Geometry): Resources {
const program = linkProgram(
gl,
compileShader(gl, gl.VERTEX_SHADER, vertexSource),
compileShader(gl, gl.FRAGMENT_SHADER, resolveIncludes(fragmentTemplate)),
);
const at = (name: string): WebGLUniformLocation | null => gl.getUniformLocation(program, name);
const locations: SceneLocations = {
view: at('u_view'),
projection: at('u_projection'),
time: at('u_time'),
count: at('u_count'),
ballRadius: at('u_ballRadius'),
scale: at('u_scale'),
vertexWork: at('u_vertexWork'),
lightDirection: at('u_lightDirection'),
cameraPosition: at('u_cameraPosition'),
colorInner: at('u_colorInner'),
colorOuter: at('u_colorOuter'),
fragmentWork: at('u_fragmentWork'),
};
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, interleave(geometry), gl.STATIC_DRAW);
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const stride = 8 * FLOAT_BYTES; // interleave() は [位置 3, 法線 3, UV 2](第14章)
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * FLOAT_BYTES);
// UV は使わないので配線しない
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
// コンテキストの状態も作り直す。WebGL 1.0 仕様の復帰の手順が明記しているのは
// 「描画バッファを作り直す」「エラー状態をリセットする」「オブジェクトは全部無効」
// 「拡張は復元されない」の 4 つで、gl.enable などをどう戻すかは、手順のあとの注記が
// 「アプリケーションは変更した状態を全部戻したくなるだろう」と書いているだけ。
// だから初期値に戻る前提で書き直しておく(本文 8 節)
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE); // geometry.ts の三角形は外から見て CCW(第14章)
gl.clearColor(0.06, 0.07, 0.09, 1.0);
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
// ジオメトリのバッファは VAO が参照し続けるので、ここで deleteBuffer を呼んでも
// 実体は消えない(ES 3.0.6 Appendix D.1.3)。とはいえ紛らわしいので呼ばない。
// このデモは VAO ごと作り直すことがないため、7 節の表でいう「作りっぱなし」に当たる
return {
program,
locations,
vao,
indexCount: geometry.indices.length,
renderer: readRendererName(gl),
};
}
// ---------------------------------------------------------------------------
// デモ
// ---------------------------------------------------------------------------
interface OptionButton {
label: string;
isActive: () => boolean;
select: () => void;
}
interface Demo {
id: string;
label: string;
/** このデモに入るときに 3 つのつまみをここへ戻す */
preset: { count: number; work: WorkMode; scale: number };
options: () => OptionButton[];
note: string;
}
// ---------------------------------------------------------------------------
// デモ本体
// ---------------------------------------------------------------------------
function setup(
gl: WebGL2RenderingContext,
canvas: HTMLCanvasElement,
demoControls: HTMLParagraphElement,
optionControls: HTMLParagraphElement,
readout: HTMLParagraphElement,
): void {
// --- CPU 側のデータ。コンテキストがロストしても生き残る -------------------------
// 1 体 45 頂点・192 インデックス。5 万体を回すので粗くしてある
const geometry = createSphere(0.5, 8, 4);
// --- GPU 側。ロストしたらまるごと作り直す ---------------------------------------
let resources: Resources | null = createResources(gl, geometry);
let timer: GpuTimer = createGpuTimer(gl);
// --- 3 つのつまみ ---------------------------------------------------------------
let instanceCount = 1000;
let workMode: WorkMode = 'none';
let resolutionScale = 1;
// --- 軌道カメラ(第15章。第23章 main.ts と同じ形) -------------------------------
const orbit = { theta: 0.7, phi: 0.35, radius: 15 };
const PHI_LIMIT = Math.PI / 2 - 0.05;
const MIN_RADIUS = 7;
const MAX_RADIUS = 40;
let dragging = false;
let activePointerId: number | null = null;
let lastX = 0;
let lastY = 0;
canvas.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
dragging = true;
activePointerId = event.pointerId;
lastX = event.clientX;
lastY = event.clientY;
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener('pointermove', (event) => {
if (!dragging || event.pointerId !== activePointerId) return;
const speed = (2 * Math.PI) / canvas.clientHeight;
orbit.theta -= (event.clientX - lastX) * speed;
orbit.phi += (event.clientY - lastY) * speed;
orbit.phi = Math.min(PHI_LIMIT, Math.max(-PHI_LIMIT, orbit.phi));
lastX = event.clientX;
lastY = event.clientY;
});
function endDrag(event: PointerEvent): void {
if (event.pointerId !== activePointerId) return;
dragging = false;
activePointerId = null;
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
}
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
canvas.addEventListener(
'wheel',
(event) => {
event.preventDefault();
const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1;
orbit.radius *= Math.exp(event.deltaY * scale * 0.001);
orbit.radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, orbit.radius));
},
{ passive: false },
);
// --- コンテキストロスト(デモ 4) ------------------------------------------------
// この拡張だけは、ロストしても無効化されない(WebGL 1.0 仕様「The Context Lost Event」の
// 「WEBGL_lose_context 以外のすべての拡張を無効にする」)。だから参照を持ち続けてよい
const loseContextExtension = gl.getExtension('WEBGL_lose_context');
/** ロストしていた合計時間の記録。読み出し行に出す */
let lostAt: number | null = null;
let lastLostDurationMs: number | null = null;
let restoreCount = 0;
canvas.addEventListener('webglcontextlost', (event) => {
// これを呼ばないと webglcontextrestored は来ない。仕様は
// 「イベントの canceled フラグが立っていなければ以降の手順を中止する」と書いている
event.preventDefault();
lostAt = performance.now();
// GPU 側のものは全部無効になった。参照を捨てるだけで、deleteXxx は呼ばない —
// 無効化されたオブジェクトは、復帰後に引数として渡すと INVALID_OPERATION になる
// (WebGL 1.0 仕様。ロスト中は既定値が返るだけでエラーにはならない)。持ち越さないのが安全
resources = null;
timer = createGpuTimer(gl); // ロスト中なので中身は空のタイマーになる
resetFrameSamples();
updateReadout();
buildOptionButtons();
});
canvas.addEventListener('webglcontextrestored', () => {
// ここが「usably restored」になる最初の場所(WEBGL_lose_context の restoreContext の項)。
// 拡張は復元されないので、タイマークエリも取り直す
resources = createResources(gl, geometry);
timer = createGpuTimer(gl);
resetFrameSamples();
if (lostAt !== null) {
lastLostDurationMs = performance.now() - lostAt;
lostAt = null;
}
restoreCount++;
updateReadout();
buildOptionButtons();
});
// --- デモ 4 本 -------------------------------------------------------------------
const countOption = (value: number): OptionButton => ({
label: `${value.toLocaleString('en-US')} 体`,
isActive: () => instanceCount === value,
select: () => {
instanceCount = value;
resetSamples();
},
});
const workOption = (mode: WorkMode): OptionButton => ({
label: WORK_LABELS[mode],
isActive: () => workMode === mode,
select: () => {
workMode = mode;
resetSamples();
},
});
const scaleOption = (value: number): OptionButton => ({
label: value.toFixed(2),
isActive: () => resolutionScale === value,
select: () => {
resolutionScale = value;
resetSamples();
},
});
const demos: readonly Demo[] = [
{
id: 'load',
label: '1. 負荷を上げる',
preset: { count: 1000, work: 'none', scale: 1 },
options: () => [countOption(100), countOption(1000), countOption(10000), countOption(50000)],
note: '体数だけを変える。1 体の大きさは count^(-1/3) に比例(体積の総和は一定・投影面積の総和は count^(1/3) で増える)',
},
{
id: 'where',
label: '2. どこが重いのか',
preset: { count: 10000, work: 'none', scale: 1 },
options: () => [
workOption('none'),
workOption('vertex'),
workOption('fragment'),
workOption('both'),
],
note: '同じ絵のまま、追加の反復を頂点側とフラグメント側に置き分ける',
},
{
id: 'resolution',
label: '3. 解像度スケール',
preset: { count: 10000, work: 'fragment', scale: 1 },
options: () => [scaleOption(1), scaleOption(0.75), scaleOption(0.5), scaleOption(0.25)],
note: 'CSS サイズはそのままで、描画バッファだけを縮める。ピクセル数は倍率の 2 乗',
},
{
id: 'lost',
label: '4. コンテキストロスト',
preset: { count: 1000, work: 'none', scale: 1 },
options: () => [
{
label: 'ロストさせる',
isActive: () => gl.isContextLost(),
select: () => {
if (!loseContextExtension || gl.isContextLost()) return;
loseContextExtension.loseContext();
},
},
{
label: '復帰させる',
isActive: () => !gl.isContextLost(),
select: () => {
if (!loseContextExtension || !gl.isContextLost()) return;
loseContextExtension.restoreContext();
},
},
],
note: 'ページの唯一のコンテキストを壊して、戻す',
},
];
let current = demos[0];
// --- 切替ボタン(第31章と同じ作り) ----------------------------------------------
for (const demo of demos) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = demo.label;
button.setAttribute('aria-pressed', demo === current ? 'true' : 'false');
button.addEventListener('click', () => {
current = demo;
// つまみを既定へ戻す。戻さないと「前のデモで動かしたつまみ」が残って、
// 読み出し行の値がどの条件のものか言えなくなる
instanceCount = demo.preset.count;
workMode = demo.preset.work;
resolutionScale = demo.preset.scale;
resetSamples();
for (const other of demoControls.querySelectorAll('button')) {
other.setAttribute('aria-pressed', 'false');
}
button.setAttribute('aria-pressed', 'true');
buildOptionButtons();
});
demoControls.append(button);
}
/** デモを切り替えるたびにオプション行を作り直す */
function buildOptionButtons(): void {
optionControls.replaceChildren();
const options = current.options();
optionControls.hidden = options.length === 0;
for (const option of options) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = option.label;
button.setAttribute('aria-pressed', option.isActive() ? 'true' : 'false');
button.addEventListener('click', () => {
option.select();
// 押した結果を状態から読み直す。デモ 4 の 2 つは「押しても状態が変わらない」
// ことがある(ロスト中に「ロストさせる」を押した場合など)
optionControls.querySelectorAll('button').forEach((other, index) => {
other.setAttribute('aria-pressed', options[index].isActive() ? 'true' : 'false');
});
});
optionControls.append(button);
}
}
buildOptionButtons();
// --- リサイズと解像度スケール -----------------------------------------------------
/** いま実際に使っている描画バッファのサイズ。読み出し行が読む */
function resizeIfNeeded(): void {
// dpr のクランプ(第2章 4 節)の上に、さらに倍率を掛ける。これが 5 節の「一番効くつまみ」
const dpr = Math.min(window.devicePixelRatio, MAX_DPR);
const width = Math.max(1, Math.floor(canvas.clientWidth * dpr * resolutionScale));
const height = Math.max(1, Math.floor(canvas.clientHeight * dpr * resolutionScale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
// viewport も必ずセットで切り替える。既定値は canvas の初期サイズ(300×150)のまま
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
}
}
// --- 読み出し行 -------------------------------------------------------------------
const frameTimes: number[] = [];
let lastTimestamp = 0;
let lastReadoutAt = 0;
function resetFrameSamples(): void {
frameTimes.length = 0;
}
/** CPU 側と GPU 側、両方の移動平均を捨てる。条件を変えたら必ず呼ぶ */
function resetSamples(): void {
resetFrameSamples();
timer.reset();
}
function averageFrameMs(): number | null {
if (frameTimes.length === 0) return null;
let total = 0;
for (const value of frameTimes) total += value;
return total / frameTimes.length;
}
function updateReadout(): void {
const n = (value: number): string => value.toLocaleString('en-US');
if (!resources || gl.isContextLost()) {
readout.textContent =
'コンテキストはロスト中です。GPU 側のオブジェクトはすべて無効で、描画はスキップしています。' +
'「復帰させる」を押すと createResources() をもう一度呼んでシーンを組み直します(本文 8 節)';
return;
}
const frameMs = averageFrameMs();
const gpuMs = timer.averageMs();
const gpuText = !timer.supported
? 'GPU 時間 拡張なし'
: gpuMs === null
? 'GPU 時間 —(まだ結果が揃っていません)'
: `GPU 時間 ${gpuMs.toFixed(2)} ms(${timer.sampleCount()} 標本の移動平均・disjoint で捨てた計測 ${timer.discardedCount()} 回)`;
const pixels = gl.drawingBufferWidth * gl.drawingBufferHeight;
const restored =
lastLostDurationMs === null
? ''
: ` / 復帰 ${restoreCount} 回(直近のロストは ${(lastLostDurationMs / 1000).toFixed(1)} 秒)`;
readout.textContent =
`${current.note} / 球 ${n(instanceCount)} 体 / ドローコール 1 回 / ` +
`頂点 ${n(resources.indexCount)} × ${n(instanceCount)} = ${n(resources.indexCount * instanceCount)} / ` +
`描画バッファ ${gl.drawingBufferWidth}×${gl.drawingBufferHeight} = ${n(pixels)} px` +
`(倍率 ${resolutionScale.toFixed(2)} / dpr 上限 ${MAX_DPR})/ ` +
`追加の反復 ${WORK_LABELS[workMode]}(頂点 ${vertexWorkOf(workMode)} 回・フラグメント ${fragmentWorkOf(workMode)} 回)/ ` +
`${gpuText} / ` +
`rAF 間隔 ${frameMs === null ? '—' : `${frameMs.toFixed(1)} ms`}(CPU 側。表示のリフレッシュ間隔に張り付いていないかは 3 節)/ ` +
`レンダラ ${resources.renderer}${restored}`;
}
// --- 毎フレーム使い回す入れ物 -----------------------------------------------------
const eye = vec3.create();
const view = mat4.create();
const projection = mat4.create();
// --- 描画ループ -------------------------------------------------------------------
function frame(timestamp: DOMHighResTimeStamp): void {
// rAF の間隔。タブが裏に回っているあいだ rAF は止まるので、復帰直後の巨大な差分は
// 平均に混ぜない(計測ではなく、単に呼ばれなかっただけなので。本文 3 節)
const delta = timestamp - lastTimestamp;
if (lastTimestamp !== 0 && delta < 200) {
frameTimes.push(delta);
if (frameTimes.length > FRAME_SAMPLES) frameTimes.shift();
}
lastTimestamp = timestamp;
// ロスト中はループを止めずに描画だけ飛ばす。止めてしまうと、復帰したときに
// 誰がループを再開するのかを別に考えることになる(本文 8 節)
if (resources && !gl.isContextLost()) {
resizeIfNeeded();
const time = timestamp / 1000;
const scale = REFERENCE_SCALE * (REFERENCE_COUNT / instanceCount) ** (1 / 3);
eye[0] = orbit.radius * Math.cos(orbit.phi) * Math.sin(orbit.theta);
eye[1] = orbit.radius * Math.sin(orbit.phi);
eye[2] = orbit.radius * Math.cos(orbit.phi) * Math.cos(orbit.theta);
mat4.lookAt(view, eye, TARGET, UP);
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
mat4.perspective(projection, FOVY, aspect, NEAR, FAR);
const { locations } = resources;
gl.useProgram(resources.program);
gl.uniformMatrix4fv(locations.view, false, view); // transpose は常に false
gl.uniformMatrix4fv(locations.projection, false, projection);
gl.uniform1f(locations.time, time);
gl.uniform1f(locations.count, instanceCount);
gl.uniform1f(locations.ballRadius, BALL_RADIUS);
gl.uniform1f(locations.scale, scale);
gl.uniform1i(locations.vertexWork, vertexWorkOf(workMode));
gl.uniform1i(locations.fragmentWork, fragmentWorkOf(workMode));
gl.uniform3f(
locations.lightDirection,
LIGHT_DIRECTION[0],
LIGHT_DIRECTION[1],
LIGHT_DIRECTION[2],
);
gl.uniform3f(locations.cameraPosition, eye[0], eye[1], eye[2]);
gl.uniform3f(locations.colorInner, COLOR_INNER[0], COLOR_INNER[1], COLOR_INNER[2]);
gl.uniform3f(locations.colorOuter, COLOR_OUTER[0], COLOR_OUTER[1], COLOR_OUTER[2]);
// ここからここまでが GPU 時間の計測区間。clear も入れてある
timer.begin();
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.bindVertexArray(resources.vao);
gl.drawElementsInstanced(
gl.TRIANGLES,
resources.indexCount,
gl.UNSIGNED_SHORT,
0,
instanceCount,
);
gl.bindVertexArray(null);
timer.end();
// 揃っているものだけ回収する。ここで待つことはできない(本文 2 節)
timer.poll();
}
if (timestamp - lastReadoutAt > 250) {
updateReadout();
lastReadoutAt = timestamp;
}
requestAnimationFrame(frame);
}
// このページのデモはページと寿命を共にするので、rAF ループの停止もリスナーの解除も
// していない。この章の 7 節が扱っているのは、まさにこの行のことです
requestAnimationFrame(frame);
}
// ---------------------------------------------------------------------------
// 要素とコンテキストの取得
// ---------------------------------------------------------------------------
const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const demoControls = document.querySelector<HTMLParagraphElement>('#demo-buttons');
const optionControls = document.querySelector<HTMLParagraphElement>('#option-buttons');
const readout = document.querySelector<HTMLParagraphElement>('#readout');
if (!canvas || !demoControls || !optionControls || !readout) {
throw new Error('デモに必要な要素が見つかりません');
}
const gl = canvas.getContext('webgl2');
if (!gl) {
throw new Error('このブラウザは WebGL2 に対応していません');
}
setup(gl, canvas, demoControls, optionControls, readout);// 第35章: GPU 時間を測るためのリングバッファ(この章ローカル。src/lib へは上げない)
//
// EXT_disjoint_timer_query_webgl2 は「WebGL2 のコアのクエリ API に
// TIME_ELAPSED_EXT / TIMESTAMP_EXT / GPU_DISJOINT_EXT を足すだけ」の拡張で、
// createQuery / beginQuery / endQuery / getQueryParameter はコアのものを使う。
//
// この小さなモジュールが引き受けているのは、次の 3 つの約束(本文 2 節):
//
// ① 結果は同じフレームでは絶対に取れない。WebGL 2.0 仕様が
// 「クエリの結果を、発行したフレームと同じフレームでアプリへ渡してはならない」と
// 定めている。だからクエリオブジェクトを 1 個で使い回すと、結果を読む前に
// 次の beginQuery で上書きすることになる → 何本かをリングにして順に使う
// ② TIME_ELAPSED のクエリは同時に 1 本だけ。ES 3.0.6 §2.14 の BeginQuery は
// 「その target の active query が非ゼロなら INVALID_OPERATION」と定めていて、
// EXT_disjoint_timer_query の Issue (6) も「複数のタイマークエリを同時には
// 実行できない」と書いている → 走っているあいだは begin() を素通りさせる
// ③ GPU_DISJOINT_EXT が真なら、前回の問い合わせ以降に埋まった時間はすべて未定義。
// 捨てる。捨てた回数は読み出し行に出す
//
// 拡張が取れない環境では supported が false になり、他のメソッドは何もしない。
/** getExtension が返すオブジェクトのうち、この章で使う定数だけ */
interface TimerQueryExtension {
TIME_ELAPSED_EXT: GLenum;
GPU_DISJOINT_EXT: GLenum;
}
export interface GpuTimer {
/** 拡張が取れたか。false なら begin / end / poll は何もしない */
readonly supported: boolean;
/** 計測区間の開始。空きスロットが無いフレームは黙って測らない */
begin(): void;
/** 計測区間の終了。begin が素通りしたフレームでは何もしない */
end(): void;
/** 揃った結果を回収する。フレームの終わりに 1 回だけ呼ぶ */
poll(): void;
/** 移動平均(ミリ秒)。まだ 1 本も揃っていなければ null */
averageMs(): number | null;
/** 平均に使っている標本の本数 */
sampleCount(): number;
/** disjoint で捨てた計測の累計 */
discardedCount(): number;
/** 移動平均の標本を捨てる(デモを切り替えたとき) */
reset(): void;
}
/** 同時に飛ばせる計測の本数。結果は最短でも次のフレームなので、数フレームぶんあれば足りる */
const RING_SIZE = 4;
/** 移動平均の窓。読み出し行の rAF 間隔と同じ本数にしてある */
const SAMPLE_LIMIT = 30;
interface Slot {
query: WebGLQuery;
/** beginQuery 済みで、まだ結果を読んでいない */
pending: boolean;
/** この計測の区間に disjoint が起きた。結果が揃っても捨てる */
spoiled: boolean;
}
/**
* 拡張が取れなかったときに返す「何もしないタイマー」。
* 呼ぶ側に if を撒かずに済ませるための空実装で、読み出し行だけが supported を見る
*/
function createNullTimer(): GpuTimer {
return {
supported: false,
begin() {},
end() {},
poll() {},
averageMs: () => null,
sampleCount: () => 0,
discardedCount: () => 0,
reset() {},
};
}
export function createGpuTimer(gl: WebGL2RenderingContext): GpuTimer {
// 拡張が無い環境は珍しくない。ここで落とさずに、読み出し行に「拡張なし」と出す
const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2') as TimerQueryExtension | null;
if (!ext) return createNullTimer();
const slots: Slot[] = [];
for (let i = 0; i < RING_SIZE; i++) {
const query = gl.createQuery();
if (!query) {
// 途中で作れなくなったら、そこまでに作ったぶんを消してから諦める。
// 「作りかけで投げ出さない」は 7 節の話そのもの
for (const made of slots) gl.deleteQuery(made.query);
return createNullTimer();
}
slots.push({ query, pending: false, spoiled: false });
}
// GPU_DISJOINT_EXT は「前回この問い合わせをしてから今までに disjoint が起きたか」を
// 返すので、読むとクリアされる。測り始める前に 1 回読んで、それまでの分を落としておく
// (EXT_disjoint_timer_query のサンプルコードがそうしている)
gl.getParameter(ext.GPU_DISJOINT_EXT);
let active: Slot | null = null;
const samples: number[] = [];
let discarded = 0;
return {
supported: true,
begin(): void {
// 同時に 1 本だけという制約。end() を呼び忘れた場合もここで止まる
if (active) return;
const slot = slots.find((candidate) => !candidate.pending);
// 全部飛んでいるフレームは測らない。無理に測るより穴が空くほうが安全
if (!slot) return;
slot.pending = true;
slot.spoiled = false;
active = slot;
gl.beginQuery(ext.TIME_ELAPSED_EXT, slot.query);
},
end(): void {
if (!active) return;
gl.endQuery(ext.TIME_ELAPSED_EXT);
active = null;
},
poll(): void {
// 先に disjoint を見る。真なら、いま飛んでいる計測は全部信用できない
if (gl.getParameter(ext.GPU_DISJOINT_EXT)) {
for (const slot of slots) {
if (slot.pending) slot.spoiled = true;
}
}
for (const slot of slots) {
if (!slot.pending || slot === active) continue;
// 揃っていなければ次のフレームに持ち越す。ここでループして待つのは無意味で、
// 仕様が「制御をユーザーエージェントへ返すまで結果は揃わない」と定めている
if (!gl.getQueryParameter(slot.query, gl.QUERY_RESULT_AVAILABLE)) continue;
const nanoseconds = gl.getQueryParameter(slot.query, gl.QUERY_RESULT) as number;
slot.pending = false;
if (slot.spoiled) {
slot.spoiled = false;
discarded++;
continue;
}
samples.push(nanoseconds / 1e6);
if (samples.length > SAMPLE_LIMIT) samples.shift();
}
},
averageMs(): number | null {
if (samples.length === 0) return null;
let total = 0;
for (const value of samples) total += value;
return total / samples.length;
},
sampleCount: () => samples.length,
discardedCount: () => discarded,
reset(): void {
samples.length = 0;
},
};
}#version 300 es
// 第35章: 負荷をつまみで変えられるシーン(インスタンシングした球)
//
// 位置は gl_InstanceID から作るので、per-instance 属性は 1 本も無い(第31章 5 節と同じ形)。
// インスタンス数を変えてもバッファを作り直さずに済むので、
// 「体数だけを変えて測る」がそのままできる。
//
// u_vertexWork が「頂点側だけを重くする」つまみ(本文 2 節・6 節)。
// 頂点言語の既定は precision highp float; / precision highp int;(GLSL ES 3.00 §4.5.4)なので、
// ここでの precision 文は「明示している」だけで、書かなくても同じ
precision highp float;
precision highp int;
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform float u_time;
/** いま描いているインスタンス数 */
uniform float u_count;
/** 球を散らす範囲の半径(ワールド) */
uniform float u_ballRadius;
/** 球 1 個の大きさ。CPU 側で count^(-1/3) に比例させてある(本文 6 節) */
uniform float u_scale;
/** 追加で回す反復回数。0 なら 1 回も回らない */
uniform int u_vertexWork;
out vec3 v_normal;
out vec3 v_worldPosition;
/** 中心からの距離(0〜1)。色の混ぜ率に使う */
out float v_radial;
// 黄金角 π(3 − √5) と、それを 2π で割った比
const float GOLDEN_ANGLE = 2.399963229728653;
void main() {
float index = float(gl_InstanceID);
// 向きはフィボナッチ球。極角の cos を等分し、方位角を黄金角ずつ回す
float cosPolar = 1.0 - 2.0 * (index + 0.5) / u_count;
float sinPolar = sqrt(max(0.0, 1.0 - cosPolar * cosPolar));
// 半径は向きと別の系列から取る。fract(index × 黄金比) は index が数万になると
// float の刻み(2^24 分の 1 相当)に負けて段が出るので、整数の剰余で作る。
// 7919 は奇素数で 65536 = 2^16 と互いに素なので、i → 7919i mod 65536 は
// 0〜65535 を 1 回ずつ通る = 偏りが出ない
int scrambled = (gl_InstanceID * 7919) % 65536;
// 3 乗根は「球の体積で均す」ため。これを付けないと外側が薄くなる
float radial = pow((float(scrambled) + 0.5) / 65536.0, 1.0 / 3.0);
// 内側ほど速く回す。全体が同じ速さで回ると、カメラを回したのと区別がつかない
float azimuth = index * GOLDEN_ANGLE + u_time * 0.5 / (0.35 + radial);
vec3 center =
u_ballRadius * radial * vec3(sinPolar * cos(azimuth), cosPolar, sinPolar * sin(azimuth));
// モデル変換は「平行移動 + 一様スケール」だけなので、法線は変換しなくてよい。
// 法線行列が要るのは非一様スケールや回転が入るとき(第17章)
vec3 normal = a_normal;
vec3 worldPosition = center + a_position * u_scale;
// --- 頂点側のつまみ --------------------------------------------------------
// 反復回数を uniform で持つので、コンパイラが展開して畳み込むことはできない。
// 結果を位置に足しているのは、どこにも使われない値なら消してよいから(本文 6 節)。
// wobble は sin(…)/k の和なので大きさは調和級数どまり(96 回で 5 強)。
// 係数 0.01 を掛けて、球の半径の 1 割に届かない変位に抑えてある —
// 0 ではないので、切り替えると形はわずかに変わる
float wobble = 0.0;
for (int i = 0; i < u_vertexWork; i++) {
float k = float(i) + 1.0;
wobble += sin(dot(worldPosition, vec3(k, k * 1.3, k * 0.7)) + u_time) / k;
}
worldPosition += normal * (wobble * 0.01 * u_scale);
v_normal = normal;
v_worldPosition = worldPosition;
v_radial = radial;
gl_Position = u_projection * u_view * vec4(worldPosition, 1.0);
}#version 300 es
// 第35章: シーンのフラグメントシェーダー。
// ライティングは第17章の Blinn-Phong そのままで、この章の主題ではない。
// u_fragmentWork が「フラグメント側だけを重くする」つまみ(本文 2 節・6 節)。
//
// この章のシェーダーには discard が 1 つも無く、深度書き込みも切っていない。
// 6 節で early-Z の話をするときの前提がこれ。
precision highp float;
// 整数の既定は mediump(GLSL ES 3.00 §4.5.4)。u_fragmentWork の範囲は数十なので
// mediump の保証(±32767・§4.5.1)で足りる。ここを highp にする必要は無い
in vec3 v_normal;
in vec3 v_worldPosition;
in float v_radial;
/** 面から光源へ向かう単位ベクトル(第17章の約束) */
uniform vec3 u_lightDirection;
uniform vec3 u_cameraPosition;
/** 中心側・外側の色(どちらもリニア値) */
uniform vec3 u_colorInner;
uniform vec3 u_colorOuter;
uniform float u_time;
/** 追加で回す反復回数。0 なら 1 回も回らない */
uniform int u_fragmentWork;
out vec4 fragColor;
#include "color.glsl"
void main() {
vec3 N = normalize(v_normal);
vec3 L = u_lightDirection;
vec3 V = normalize(u_cameraPosition - v_worldPosition);
vec3 H = normalize(L + V);
vec3 base = mix(u_colorInner, u_colorOuter, v_radial);
float diffuse = max(dot(N, L), 0.0);
float specular = pow(max(dot(N, H), 0.0), 48.0);
vec3 color = base * (0.10 + 0.90 * diffuse) + vec3(0.30) * specular;
// --- フラグメント側のつまみ ------------------------------------------------
// 頂点側と同じ形。回数は uniform なので、束の中で分岐先が割れることはない
// (このループは「割れる分岐」の例にはなっていない。本文 6 節)
float extra = 0.0;
for (int i = 0; i < u_fragmentWork; i++) {
float k = float(i) + 1.0;
extra += sin(dot(v_worldPosition, vec3(k * 0.9, k * 1.7, k * 1.1)) + u_time) / k;
}
color *= 1.0 + 0.03 * extra;
// 出す直前に 1 回だけ sRGB へ(第27章)
fragColor = vec4(linearToSrgb(color), 1.0);
}// 第35章 出力の最後の 1 行のための色変換。
// 中身は第27章の src/lessons/27-color-and-palette/color.glsl からの複製(内容は同一)で、
// srgbToLinear / linearToSrgb の 2 関数だけを持ってきている。
// 片方を直したらもう片方も直すこと(第25→26章の noise.glsl と同じ流儀。理由は
// docs/plan.md の抽象化タイムライン: 読者が壊して遊ぶ対象なので章をまたいだ結合を作らない)。
//
// この章で使うのは linearToSrgb のほう。球の色はリニア値として組み立て、
// 画面へ出す直前にここでエンコードする。変換の体系そのものは第27章 2 節。
/**
* sRGB エンコード値 → リニア値。
* 出典: OpenGL ES 3.0.6 §3.8.16 式 (3.26)。GPU が sRGB テクスチャを読むときの変換そのもの。
* 境界 (0.04045) では 2 つの式の差が 2.3e-9 しかないので、等号がどちら側かは問題にならない。
*/
vec3 srgbToLinear(vec3 c) {
vec3 lo = c / 12.92;
vec3 hi = pow((c + 0.055) / 1.055, vec3(2.4));
return mix(lo, hi, step(vec3(0.04045), c));
}
/**
* リニア値 → sRGB エンコード値。画面へ出す最後の 1 行はこれになる。
* 出典: OpenGL ES 3.0.6 §4.1.8 式 (4.1)。仕様は指数を 0.41666 と書いているが、
* これは 1/2.4 = 0.4166666… を打ち切った値で、8 ビット出力での差は最大 1 段。1/2.4 を使う。
* 仕様の式は 0 以下と 1 以上を切り落とすので、clamp がその 2 本の枝にあたる。
*/
vec3 linearToSrgb(vec3 c) {
c = clamp(c, 0.0, 1.0);
vec3 lo = c * 12.92;
vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
return mix(lo, hi, step(vec3(0.0031308), c));
}
three.js との対応
three.js は、この章の内容のうち「数える」と「解放する」と「ロストに備える」を 持っていて、「GPU 時間を測る」は持っていません。以下はthree@0.185.1のソースを展開して確認したものです(2026-08-08 時点)。
| three.js | この章 |
|---|---|
renderer.info.render.calls/triangles/points/lines/frame | 読み出し行のドローコール数・頂点の要素数を自分で数えている。三角形の数え方も同じで、WebGLInfoはrender.triangles += instanceCount * (count / 3)とインスタンス数を掛けている |
renderer.info.memory.geometries/textures | この本には対応するものが無い。7 節の表を見ながら自分で把握する |
GPU 時間の計測 API は WebGLRenderer に無い | gpu-timer.tsを章ローカルに書いた(2 節)。src/lib/へは上げていない |
renderer.setPixelRatio(value) | resizeIfNeededの中のMath.min(devicePixelRatio, MAX_DPR) * resolutionScale。three.js のsetSizeもcanvas.width = Math.floor(width * _pixelRatio)という同じ式 |
geometry.dispose()/material.dispose()/texture.dispose() | gl.deleteBuffer/gl.deleteProgram/gl.deleteTexture(7 節の表)。BufferGeometry.dispose()の中身はdispatchEvent1 行で、実際に消すのはレンダラ側のリスナー |
renderer.dispose() | この本には無い。three.js のdispose()はwebglcontextlost/webglcontextrestoredのリスナーを外し、内部のキャッシュを一斉に片づける |
renderer.forceContextLoss()/forceContextRestore() | デモ 4 のボタン。three.js の中身もextensions.get('WEBGL_lose_context')を取ってloseContext()/restoreContext()を呼ぶだけで、この章と同じ |
WebGLRendererのonContextLost/onContextRestore | 8 節の 2 本のハンドラ。three.js もevent.preventDefault()を呼び、復帰時はinitGLContext()で内部モジュールを丸ごと作り直す。利用者側の BufferGeometry や Texture は CPU 側にデータを持っているので、次の描画で自動的に上げ直される— この章の「CPU 側と GPU 側を分けて持つ」と同じ構造 |
手元で動かして、壊してみる
この章のコードはsrc/lessons/35-performance/にあります。時間を扱う実験は、値が落ち着くまで数秒待ってから読んでください— GPU 時間も rAF 間隔も 30 標本の移動平均で、条件を変えると標本は捨てられます。
- まず、天井を確かめる。別のタブで
about:blankを開き、コンソールに次を貼って、何もしていないページの rAF 間隔を測ってください。デモの rAF 間隔がこれと同じなら、その値は負荷について何も語っていません(3 節)。著者の環境では、この章のデモの rAF 間隔は負荷で動きました(2 節の表)。第30〜32章とは逆の結果です。あなたの環境はどちらですか。let n = 0, prev = 0, sum = 0; const tick = (t) => { if (prev) { sum += t - prev; n++; } prev = t; if (n < 120) requestAnimationFrame(tick); else console.log('rAF 間隔の平均', (sum / n).toFixed(2), 'ms'); }; requestAnimationFrame(tick); - デモ 1 で体数を 100 → 50,000 と上げる。GPU 時間はどう動きますか。体数に 比例していますか、それとも途中から急に増えますか。読み出し行の「頂点」の数も一緒に見てください。 1 体の大きさは体数の −1/3 乗に比例させてあるので、体積の総和は一定でも、投影面積の総和は
N^(1/3)で増えます(6 節)。著者の環境での答えは 2 節の表の A〜Dにあります(比例より鈍かった)。あなたの環境の値と見比べてください - デモ 2 で「頂点」と「フラグメント」を切り替える。どちらが重いですか。 それは体数(既定は 10,000)によって逆転しますか。著者の環境では 2 節の表の F と G がほぼ同じ重さで、どちらが重いとは言えませんでした。デモ 1 で 50,000 にしてから、 デモ 2 のボタンだけを押して比べてみてください(デモを切り替えると体数は 10,000 に戻るので、その場合は
demos配列のpresetを書き換えます) - デモ 3 で倍率を 1.0 → 0.25 に下げる。GPU 時間はピクセル数の比(5 節の表)に近い落ち方をしますか。著者の環境では、まったく近くありませんでした— 5 節の「実測は s² に従わなかった」のとおりです。あなたの環境ではどうか、固定コストが どれくらい残るかを、同じやり方(両端の 2 点から 1 次式を当てる)で出してみてください。 次に、フラグメント側が軽いときの倍率の効き方も見てください。デモ 3 の倍率ボタンとデモ 2 の「なし」は同時には出せない(つまみはデモごとに 1 つしか露出していません)ので、
demos配列のデモ 3 のpresetを{ count: 10000, work: 'none', scale: 1 }に書き換えてビルドし直します。効き方はどう変わりますか。 scene.fragにdiscardを 1 行入れる。main()の先頭にif (v_radial > 0.99) discard;を足すと、ほとんどのフラグメントは残るのにシェーダーにはdiscardが入った状態になります。早期の深度テストが使えなくなる条件です(6 節)。GPU 時間は動きますか。動かないかもしれません— 実装が前倒しをしていなかった、重なりが少なかった、どちらもありえます。この実験の結果は環境依存で、著者の環境でも確かめていません。何が起きたかを記録しておいてください- ループの結果を捨ててみる。
scene.fragのcolor *= 1.0 + 0.03 * extra;を消して、extraをどこにも使わないようにします。GPU 時間はどうなりますか。コンパイラがループごと消してよい形になります(結果がどの出力にも効かないため)。実際に消すかどうかは実装しだいなので、 「なし」と「フラグメント」で GPU 時間が変わらなくなるかを確かめてください(6 節)。つまみを反対側から切ることもできます— ループ条件をi < 64に書き換えてu_fragmentWorkをどこも読まなくすると、こちらは「なし」でも 64 回まわるので、やはり「なし」と 「フラグメント」が同じ時間になります。どちらもつまみが出力に繋がっていない状態で、負荷のつまみを自作するときに踏みやすい罠です - disjoint を見るのをやめる。
gpu-timer.tsのpoll()からif (gl.getParameter(ext.GPU_DISJOINT_EXT))のブロックを消します。読み出し行の「捨てた計測」が 0 のまま増えなくなります。ノートパソコンなら、電源を抜く・充電を始める・他のタブで重い 3D を開く、といったことをしてから GPU 時間を見比べてください。捨てるべき値が混ざったとき、平均がどう動くかが見どころです - クエリの輪を 1 本にする。
gpu-timer.tsのRING_SIZEを 1 にします。結果は同じフレームでは取れない(2 節)ので、 次のフレームのbegin()が空きスロットを見つけられません。GPU 時間はどうなりますか preventDefault()を消す。main.tsのwebglcontextlostハンドラからevent.preventDefault();を消して、デモ 4 で「ロストさせる」を押してください。「復帰させる」を押しても戻りません。仕様が「イベントの canceled フラグが立っていなければ以降の手順を中止する」と定めているためです(8 節)。このときrestoreContext()はINVALID_OPERATIONを生成します(ブラウザがそれをコンソールへ出すかは実装しだいです)。戻すにはページを再読み込みしてください- 復帰時に拡張を取り直さない。
webglcontextrestoredのハンドラからtimer = createGpuTimer(gl);を消します。シーンは戻りますが、GPU 時間だけが戻りません— 「拡張は復元されない」の実演です。読み出し行は「まだ結果が揃っていません」ではなくGPU 時間 拡張なしに変わります。ロスト側のハンドラもcreateGpuTimer(gl)を呼んでいますが、そのときコンテキストはロスト中でgl.getExtensionはnullを返すので、そこでcreateNullTimer()の何もしないタイマーに置き換わっているからです。逆にロスト側の 1 行のほうを消すと、 無効化されたクエリを抱えたタイマーが復帰後もbeginQueryを呼び続けることになります。そちらでコンソールにINVALID_OPERATIONが出るかどうかも確かめてみてください(著者は確かめていません) - 復帰時に
createResourcesを呼ばない。その行を消すと、 「復帰させる」を押してもコンテキストは戻るのにcanvas には何も描かれません。resourcesがnullのままなので描画がスキップされ続け、読み出し行も「ロスト中」の文言のままになります。 このとき canvas に見えているのはclearColorの色ではなく、CSS のbackgroundです — 復帰で作り直された描画バッファは仕様どおり色 (0, 0, 0, 0) で初期化され、gl.clearは一度も呼ばれないので、透けます(global.cssの#101317がclearColor(0.06, 0.07, 0.09, 1)の#0F1217とほぼ同じ色なので、見分けは付きません)。ロスト対応でいちばん起きやすい書き忘れがこれです - デモ 4 で、ロストしたまま他のデモへ行く。ロストさせてから「1. 負荷を上げる」を押してください。読み出し行は「ロスト中」のままで、ボタンは効きますが 絵は出ません。デモ 4 に戻って「復帰させる」を押せば、そのとき選ばれているつまみの状態で描き直されます(デモ 4 に入り直した時点で、つまみはデモ 4 の既定値に戻っています)
まとめ
- WebGL の関数は命令を積むだけで、返ってきた時点では何も終わっていない。
performance.now()でドローコールを挟んで得られるのは「積む時間」で、小さくても大きくても意味を 取り違える - 切り分けは 4 手順 — 解像度を下げる / 体数を減らす / 描画をやめる /
gl.finish()を挟む。つまみは 1 つずつ動かす - GPU 時間は
EXT_disjoint_timer_query_webgl2。WebGL1 版とは別の拡張で、 足すのは定数 4 つとqueryCounterEXTだけ。あとは WebGL2 コアのcreateQuery/beginQuery/endQuery/getQueryParameter - 守る約束は 3 つ。①結果は同じフレームでは絶対に取れない(WebGL 2.0 仕様の明文)のでクエリを輪にする。②
TIME_ELAPSED_EXTは同時に 1 本だけ(ES 3.0.6 §2.14 + 拡張の Issue (6))。③GPU_DISJOINT_EXTが真なら、その区間の値は全部捨てる。捨てた回数を出しておくと、捨てていることが見える - タイムスタンプ方式(
queryCounterEXT)が使えるかはQUERY_COUNTER_BITS_EXTで問い合わせてから決める。ビット数が 0 ならカウンタは使えないと原文が明文で 定めていて、この本の環境のTIMESTAMP_EXTがまさに 0 だった(2 節) - 拡張が取れない環境では「何もしないタイマー」に落とす。値の刻みについては、 大元の ES 拡張が「粒度は実装依存」としか書いていない(全文を検索して確認。 security / privacy / fingerprinting の記述は 3 本とも 0 件)。ただし実装が粗くしていないことは確かめていない— ビット幅と刻みは別の量
- rAF 間隔は「仕事の量」ではない。リフレッシュ間隔に張り付いていると、 仕事が半分になっても値は動かない。第30〜32章の 11 通りのデモはすべてその状態で、
about:blankでも同じ値だった — あの 3 章の rAF 間隔は、デモどうしの比較には使えない。 一方この章のデモでは、体数を上げると rAF 間隔が動いた(2 節の表)。同じ指標が、張り付くこともあれば動くこともある— だから読むたびに、何もしていない状態と比べ直す - デルタ時間は rAF の
timestampから作る。タブが裏から戻った直後の巨大な差分はクランプするか捨てる(第32章のMAX_DT、この章のdelta < 200) - 強制同期レイアウトは件数で見る— Performance トレースの
Layout/UpdateLayoutTree/RecalculateStyles/LayoutInvalidationTracking/LayoutShift。performance.now()は 0.1 ms に粗粒化されるので、µs 台は 1 回では測れない。定石は同じコールバックの中で、読むより先に書かない(定義と実測値は第4章 6 節) - 解像度スケールは倍率 s に対して s²。0.75 で 0.5625 倍、0.5 で 0.25 倍。同じつまみが第23・28・33・36章の
maxDpr、第21章のシャドウマップ解像度、第29章のcellSizeという名前で出ていた。動的にするならヒステリシスを入れる。ただし s² で減るのはフラグメントシェーダーの実行回数であって GPU 時間ではない。実測(2 節の表・5 節)では固定コストが半分近くを占め、倍率を 下げても時間は半分あたりで止まった - 分岐・early-Z・頂点シェーダーの起動回数は、仕様が定めていないことばかり。束(warp / wavefront)は仕様に無く、GLSL ES 3.00 が SIMD に触れているのは §8.9 の
dFdxの説明だけ。early-Z も仕様に無く、early_fragment_testsは ES 3.1 以降。起動回数は「転送される頂点の要素数 =count × instanceCount」までしか 言えない(第31章 3 節と同じ書き分け) deleteXxxはすぐには消さない。バインドされている / コンテナに付いている あいだは生き残る(ES 3.0.6 §D.1.3)。プログラムとシェーダーは削除のフラグが立つだけ。WebGL では JS オブジェクトが GC されれば自動的に削除のマークが付くが、GC のタイミングは制御できず、GPU 側の大きさは GC に見えていない- この本は、切り替え式デモで古いプログラムを解放していない章が 9 つ(第7〜10・24〜28章)、rAF を止めずリスナーも外していない章が 20(第11〜23章・第30〜36章。この章を含む)。消しているのは第16・20・21・22(一部)・23・29・32・33・36章で、 理由はどれも「同じページで大量に作り直すから」。第34章は
disposeを用意しただけで呼ぶ場面が無い - コンテキストはいつでも失われる。
webglcontextlostでpreventDefault()を呼ばないと復帰イベントは来ない。 ロストするとすべてのWebGLObjectが無効になり、拡張も無効になる(WEBGL_lose_contextを除く)。getError()がCONTEXT_LOST_WEBGLを返すのは最初の 1 回だけ - だから「初期化」と「復帰」を別の手続きとして書かない。GPU 側を作る手続きを 1 か所にまとめ、CPU 側のデータは別に持つ。復帰処理はその関数を もう一度呼ぶだけになる
これで「速くする」の前に立つべき道具が揃いました。測ってから直す、そして壊れる前提で書く— この 2 つは、どんな環境でも変わりません。 次章第36章「インタラクションと出力 — ポインタ・オーディオ反応・書き出し」は この本の最終章です。ここまでずっと「画面に何をどう出すか」を扱ってきましたが、最後は外から入ってくるもの(ポインタ・キーボード・音)と、外へ出していくもの(画像の 書き出し)を扱います。第15章から使い回してきた軌道カメラの入力まわりも、そこで 1 本の部品にまとまります。