第5部 大量描画・GPGPU・統合 — 第35章

パフォーマンスとロバスト性 — 計測・コンテキストロスト

この本はここまで、コストをずっと数えられる量で語ってきました。第26章はpcg3dの呼び出し回数、第28章はmap()の評価回数、第30〜32章はドローコール数・WebGL 呼び出し回数・アプリが WebGL に渡すバイト数。読み出し行に出ていた「rAF 間隔」にも、そのたびに「CPU 側の計測で GPU 時間ではありません」という但し書きが付いていました。

これは慎重さではなく、順番の問題でした。GPU 時間を出すには測る道具が要り、その道具は「いつ結果が返るか」「いつ結果を捨てるべきか」を 知らないと嘘の数字を出します。だから測り方をこの章まで温存していました。この章は、この本で GPU 時間を出す唯一の章です。

扱うのは 2 つです。前半(1〜6 節)は計測— どこが詰まっているのかを推測ではなく手順で決める方法。後半(7〜8 節)はロバスト性— 作ったものを解放すること、そしてコンテキストはいつでも失われうるという前提で書くことです。

1 つのシーン(インスタンシングした球)に、つまみを 3 つ付けてあります。ドラッグで回転、ホイールで寄り引き。デモ 1〜3 はそのつまみを 1 つずつ動かすためのもので、デモに入ると 3 つとも既定値に戻ります。読み出し行にGPU 時間・rAF 間隔・描画バッファのピクセル数・体数・ドローコール数・レンダラ名が出ます。デモ 4 はこのページの WebGL コンテキストを本当に壊します。「復帰させる」を 押すと戻ります。

この章で学ぶこと:

1. 推測する前に切り分ける — CPU バウンドと GPU バウンド

「重い」と感じたとき、最初にやることはコードを速くすることではなく、どちらが詰まっているかを決めることです。仕事は 2 か所にあります。

この 2 つは並行して動きます。第1章 3 節で「CPU が段取り、GPU が描く」と書いたとおり、CPU は命令を積むところまでが仕事で、GPU はそれを後から自分のペースで実行します。WebGL の関数は、返ってきた時点では何も終わっていません。gl.drawElementsInstanced()が戻ってきたのは「描き終わった」のではなく「描けと書き置きした」だけです。

切り分けの手順

道具が無くても、つまみを 1 つずつ動かして反応を見ることで切り分けはできます。 このデモのつまみは、そのままこの手順に対応しています。

  1. 解像度を半分にして速くなるか(デモ 3)。速くなるならフラグメント側。ピクセル数は倍率の 2 乗で減るので、効くときは大きく効きます(5 節)
  2. 体数だけを減らして速くなるか(デモ 1)。速くなるなら頂点側か CPU 側。どちらかはまだ分かりません
  3. 描画そのものをやめて変わらないか。ドローコールをif (false)で飛ばす、あるいはgl.enable(gl.RASTERIZER_DISCARD)(第32章 4 節)でラスタライズを捨てる。それでもフレーム時間が変わらなければ、詰まっているのはCPU 側です
  4. gl.finish() を挟むと、CPU の待ち時間が見える。gl.finish()は GPU が積まれた命令を終えるまで戻ってこないので、そこで測れば「GPU が終わるまでの時間」に 近いものが出ます。ただしパイプラインを壊します— CPU と GPU を毎フレーム同期させると、本来は重なっていたはずの仕事が直列になります。計測専用で、製品コードには残さないこと

このデモが「つまみ 1 つずつ」になっているのは、そのためです。デモに入るたびに 3 つのつまみを既定値へ戻しているのも同じ理由で、2 つを同時に動かすと、どちらが効いたのか言えなくなるからです。

CPU から GPU への転送は「遠い」

第1章 3 節の補足で「CPU と GPU の間は遠い。データは先に送っておき、毎フレームは命令だけ送るのが基本姿勢」と書きました。 この本の第5部は、その両端を実装として並べています。

この軸で見ると、インスタンシング(第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 がそのまま短いので、引用します。

EXT_disjoint_timer_query_webgl2(Revision 4, 2023-06-01)の 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 でした。

デモのページのコンソールで実行(Chrome / ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version) / 2026-08-08)
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が持っているのはその輪です。

src/lessons/35-performance/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 段で守っています。測れないフレームがあるほうが、嘘の数字を出すよりましです。

src/lessons/35-performance/gpu-timer.ts(抜粋)
    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 回だけ読み、真だったらそのとき飛んでいた計測を全部捨てます。ここを書かないと、周波数が変わった瞬間の値が平均に混ざって、章ごと嘘の数字になります。 このデモの読み出し行が捨てた回数まで出しているのは、捨てていることを 見えるようにするためです。

src/lessons/35-performance/gpu-timer.ts(抜粋)
    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 も入れてあります。

src/lessons/35-performance/main.ts(抜粋)
      // ここからここまでが 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 つの量には性格の違いがあります。

ただし、レンダラ名だけは素直には取れません。詳しい名前を出す拡張WEBGL_debug_renderer_infoは、原文の Overview 自身が「WebGL の実装は、プライバシー上の理由で下層のグラフィックスドライバのRENDERERとVENDORの文字列をマスクすることがある」と書いていて、Issue (2) は「ユーザーエージェント(ブラウザ)は、 非特権の環境でこの拡張を露出するかどうかを慎重に検討すべきである」と書いています(Issue (2) はこのあと、拡張を出すことの利点も並べています)。取れない環境がある前提で書くしかありません。この章のmain.tsのreadRendererName()が、拡張が取れなければコアのgl.getParameter(gl.RENDERER)に落とし、それも空なら「不明」と出すのはそのためです。測定条件の 1 つが取れないことがある、というのも測定条件のうちです。

2 つは対立しません。数えた量は、測った量を読むための地図になります。いくつか例を挙げます(値はいずれも元の章のもので、この章で測り直したものではありません)。

第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 ms16.7 ms320,628 px
Bデモ 1・1,000 体0.37 ms18.4 ms320,628 px
Cデモ 1・10,000 体1.65 ms20.0 ms320,628 px
Dデモ 1・50,000 体6.20 ms20.0 ms320,628 px
Eデモ 2・なし1.57 ms20.0 ms320,628 px
Fデモ 2・頂点5.12 ms20.0 ms320,628 px
Gデモ 2・フラグメント5.58 ms20.0 ms320,628 px
Hデモ 2・両方9.14 ms20.0 ms320,628 px
Iデモ 3・倍率 1.005.56 ms20.0 ms694 × 462 = 320,628 px
Jデモ 3・倍率 0.755.06 ms20.0 ms520 × 346 = 179,920 px
Kデモ 3・倍率 0.503.91 ms20.0 ms347 × 231 = 80,157 px
Lデモ 3・倍率 0.252.93 ms20.0 ms173 × 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 つだけ書いておきます。

この表の値をあなたの環境の期待値として使わないでください。GPU も、ウィンドウの大きさも、そのときの電源状態も違います。使いみちは「同じ表の中で条件どうしを 比べる」ことだけです — そしてそれこそが、1 節の切り分けでやることです。

3. CPU 側の時間 — rAF 間隔が見せるもの、隠すもの

第23章と第30〜32章の読み出し行には「rAF 間隔」が出ていました。requestAnimationFrameのコールバックに渡されるtimestampの差分で、この章のデモも同じものを出しています。

src/lessons/35-performance/main.ts(抜粋)
    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 の計測)とは、明らかに別の状態です。

ただし、ここから先は分かっていません。

この一点が、この節の主張そのものです。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 つ注意が要ります。

4. レイアウトを誘発する読み取り — トレースで件数を数える

CPU 側で詰まる原因のうち、WebGL とは直接関係がないのに毎フレームのループに紛れ込みやすいものが強制同期レイアウト (forced synchronous layout)です。clientWidthやgetBoundingClientRect()のような「レイアウトの結果を読む」プロパティを、DOM を書き換えたあとに読むと、ブラウザは先送りしていたレイアウト計算をその場で走らせます。

定義・判別の基準・実測値は第4章 6 節の補足が持っています。この節が引き受けるのは測り方のほうです。「レイアウトが走っているかどうか」を、 自分の手で確かめる手順を書きます。

手順: Performance トレースで件数を見る

  1. DevTools の Performance パネルで、デモを動かしたまま 10〜30 秒ほど記録します
  2. 記録の中から、次のイベントの件数を見ます。Layout/UpdateLayoutTree/RecalculateStyles/LayoutInvalidationTracking/LayoutShift。あわせてFireAnimationFrameの件数も控えておくと、「rAF が N 回走ってレイアウトが 0 件」という形で言えます
  3. レイアウト系が 0 件 なら、そのループは強制同期レイアウトを起こしていません。 1 件でもあれば、どのイベントから来たのかをスタックで辿ります

時間ではなく件数を見るのは、件数が時計の分解能に依存しないからです。強制同期レイアウト 1 回のコストはマイクロ秒台のことがあり、粗粒化されたperformance.now()では 1 回きりでは測れません(第4章 6 節が、20 万回の反復平均でその µs 台の値を出しています)。件数のほうが主、反復平均が補です。この章では測り直していません。

1 件でもあったときの逃がし方

どこで踏んだのかを絞り込む基準は第4章 6 節が持っています(要点は「同じコールバックの中で、 読むより先に書き換えているか」。末尾で書いて、次のフレームの先頭で読むのは安全)。この本のデモが「先頭でresizeIfNeeded()、末尾で読み出し行を書き換える」形に揃っているのは、 その順序に乗るためです。ここでは順序を直す以外の逃がし方を 2 つ挙げます。

複数の要素を「読む → 書く → 読む → 書く」と交互に処理する形(layout thrashing)も同じ罠で、 直し方は第4章 6 節の末尾にあります。

5. 一番効くつまみ — 解像度スケール

フラグメント側が詰まっているとわかったとき、いちばん確実に効くのは描画バッファを小さくすることです。フラグメントシェーダーの実行回数はピクセル数にそのまま比例し、倍率 s に対してピクセル数は s²で減ります。0.75 で 0.5625 倍、0.5 で 0.25 倍。「ちょっと小さくする」がかなり効くのは、この 2 乗のためです。

デモ 3 のオプションはこの倍率です。resizeIfNeededの中でdevicePixelRatioのクランプ(第2章 4 節)の上からさらに掛けているだけで、CSS サイズには触りません。

src/lessons/35-performance/main.ts(抜粋)
  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 サイズに引き伸ばして表示する」ので、バッファを小さくしても表示の大きさは変わりません。 変わるのは解像度だけです。

倍率 ss²描画バッファピクセル数
1.001.00001388 × 9241,282,512
0.750.56251041 × 693721,413
0.500.2500694 × 462320,628
0.250.0625347 × 23180,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。あなたの環境の実際の値は読み出し行に出ます。

同じつまみの、別の名前

この本には、すでに同じつまみが別の名前で何度も出てきています。

上限を固定するのではなく、動かす

この本のデモはどれも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 時間 = 固定コスト + 係数 × ピクセル数を当てると、こうなります。

つまりこの条件では、フラグメント側とそれ以外がほぼ半々でした。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 つ、格を付けて並べます。

src/lessons/35-performance/scene.vert と scene.frag(抜粋・並べ替え)
  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を 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消さないとどうなるか
WebGLBuffercreateBufferdeleteBuffer頂点・インデックス・UBO・transform feedback の実体。この本でいちばん大きいのは第32章の状態バッファ(30 万粒子で 8.0 MiB × 2 本)
WebGLTexturecreateTexturedeleteTexture画素の実体。画面いっぱいの RGBA8 で 1 枚あたり 4 バイト × ピクセル数。ミップマップ付きならその約 4/3 倍
WebGLFramebuffercreateFramebufferdeleteFramebufferそれ自体は「アタッチ先の一覧」なので小さい。重いのはぶら下がっているテクスチャ・レンダーバッファのほう
WebGLRenderbuffercreateRenderbufferdeleteRenderbuffer深度・ステンシルの実体。DEPTH_COMPONENT24 なら 1 ピクセル 4 バイト
WebGLProgramcreateProgramdeleteProgramリンク済みの実行コード。1 本のバイト数は小さいが、切り替え式のデモで押すたびに作ると押した回数だけ増える
WebGLShadercreateShaderdeleteShaderコンパイル済みの中間表現。src/lib/shader.ts の linkProgram がリンク直後に消しているので、この本では溜まらない
WebGLVertexArrayObjectcreateVertexArraydeleteVertexArray属性の配線表。小さいが、バッファへの参照を握っている点が効く(下の「すぐには消えない」)
WebGLQuerycreateQuerydeleteQuery結果 1 個ぶんの入れ物。この章の gpu-timer.ts が 4 個作る
WebGLTransformFeedbackcreateTransformFeedbackdeleteTransformFeedback出力バッファのバインドの束(第32章 3 節)。それ自体は小さい
WebGLSyncfenceSyncdeleteSyncフェンス 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 つの場合だけです。

この本の中で「消す」ほうを選んだ 6 か所は、どれも 2 番目に当たります。

第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 レンダリングコンテキストはいつでも失われることがあり、アプリケーションはそれを作り直す必要が ある」と書いています。原因としてはこのあたりです。

仕様が定めている手順

WebGL 1.0 仕様の「The Context Lost Event」が、ユーザーエージェント側の手順を定めています (WebGL 2.0 は 1.0 の差分仕様なので、この規定は 1.0 側にあります)。順に並べると:

  1. コンテキストの webgl context lost フラグ を立てる
  2. このコンテキストが作ったすべての WebGLObject の invalidated フラグを立てる
  3. WEBGL_lose_context を除くすべての拡張を無効にする
  4. タスクをキューに入れて、canvas でwebglcontextlostを発火する
  5. 「イベントの canceled フラグが立っていなければ、以降の手順を中止する」
  6. そうでなければ、復帰可能な描画バッファを待って、復帰の手順を走らせる

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 仕様のメソッド呼び出しのアルゴリズムが定めています。整理すると:

この章のデモの作り

以上を踏まえた設計は、ひとことで言えば「作る手続きを 1 か所にまとめる」です。 復帰時にやることが「その関数をもう一度呼ぶ」だけになれば、書き漏らしが構造的に起きません。

src/lessons/35-performance/main.ts(抜粋)
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 に上げたもの」だけで済みます。

src/lessons/35-performance/main.ts(抜粋)
  // --- 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 本です。

src/lessons/35-performance/main.ts(抜粋)
  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のあいだは中身を飛ばすだけです。

src/lessons/35-performance/main.ts(抜粋)
    // ロスト中はループを止めずに描画だけ飛ばす。止めてしまうと、復帰したときに
    // 誰がループを再開するのかを別に考えることになる(本文 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 つです。

既定値は false です。仕様は「高い性能を必要としない アプリケーションは既定値のままにするべきだ」としています。trueにするとgetContextがnullを返し(webglcontextcreationerrorが飛びます)、そこで「この環境では素直に WebGL を使わない」という判断ができます。実際にブラウザがどう判定しているかは、この章では確認していません。

関連して、powerPreference: 'high-performance'についての仕様の注記も引いておきます —「high-performance を要求するアプリケーションは、コンテキストロストの処理をテストし、 堅牢に保つべきである。ユーザーエージェントは、バックグラウンドの high-performance なコンテキストをロストさせる判断を下す可能性が非常に高いからだ」。速さを要求することと、ロストに備えることはセットです。

壊れない前提で書かない

この節をひとことにまとめるなら、「初期化」と「復帰」を別の手続きとして書かない、です。

多くのコードで初期化が復帰に使えないのは、初期化のコードが「起動時に 1 回だけ走る」前提で、DOM の取得・イベントの登録・GPU リソースの生成・状態の初期値の設定を 全部ひとつながりに書いているからです。ロストが起きると、そのうちGPU リソースの生成と状態の設定だけをもう一度やる必要が出ます。分けていなければ、そこで初めて分ける作業が発生します — しかも「どれが GPU 側だったか」を思い出しながら。

先に分けておけば、復帰処理は 1 行です。分ける手間はロストが起きなくても無駄になりません— 「この関数が触るのは GPU 側だけ」という境界は、そのままテストしやすさとリソース解放のしやすさになります。7 節の表で「作る API」と「消す API」を並べたのも、同じ境界の別の見え方です。

コード全文

src/lessons/35-performance/main.ts
// 第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);
src/lessons/35-performance/gpu-timer.ts
// 第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;
    },
  };
}
src/lessons/35-performance/scene.vert
#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);
}
src/lessons/35-performance/scene.frag
#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);
}
src/lessons/35-performance/color.glsl
// 第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/onContextRestore8 節の 2 本のハンドラ。three.js もevent.preventDefault()を呼び、復帰時はinitGLContext()で内部モジュールを丸ごと作り直す。利用者側の BufferGeometry や Texture は CPU 側にデータを持っているので、次の描画で自動的に上げ直される— この章の「CPU 側と GPU 側を分けて持つ」と同じ構造

手元で動かして、壊してみる

この章のコードはsrc/lessons/35-performance/にあります。時間を扱う実験は、値が落ち着くまで数秒待ってから読んでください— GPU 時間も rAF 間隔も 30 標本の移動平均で、条件を変えると標本は捨てられます。

まとめ

これで「速くする」の前に立つべき道具が揃いました。測ってから直す、そして壊れる前提で書く— この 2 つは、どんな環境でも変わりません。 次章第36章「インタラクションと出力 — ポインタ・オーディオ反応・書き出し」は この本の最終章です。ここまでずっと「画面に何をどう出すか」を扱ってきましたが、最後は外から入ってくるもの(ポインタ・キーボード・音)と、外へ出していくもの(画像の 書き出し)を扱います。第15章から使い回してきた軌道カメラの入力まわりも、そこで 1 本の部品にまとまります。