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

インスタンシング — 1 ドローコールで大量描画

前章のパーティクルは点でした。点はいつでも画面に正対した正方形で、形として回転もシルエットも持てません(第30章 5 節)。向きのある形を大量に出したければ、 三角形を描くしかない。ところが第13章から ずっとやってきた描き方は「1 つの物体につき、モデル行列を送ってdrawElementsを 1 回」でした。物体が 1,024 個あれば、その組を 1,024 回繰り返すことになります。

この章の道具はインスタンシング (instancing)です。同じ頂点データを使い回して N 体ぶんの描画を1 回のドローコールで済ませ、1 体ずつ違うデータ(位置・色・向き)はper-instance 属性として渡す。three.js のInstancedMeshの中身がこれです。

先に釘を刺しておきます。この章で減るのは CPU 側の呼び出し回数だけで、GPU がやる頂点の仕事は 1 つも減りません。1,024 体を描くなら、頂点シェーダーは 1,024 体ぶん走ります。読み出し行に出るのも 「何体か」「何回呼んだか」「何バイト渡したか」「頂点をいくつ流したか」という数えられる量と、CPU 側で測った rAF の間隔だけで、 GPU 時間は測りません(その測り方は第35章)。

大きなリングの表面に、小さなトーラスを 64 × 16 = 1,024 体並べています(ドラッグで回り込み、 ホイールで寄れます。第15章の軌道カメラの簡略版)。「1. 素朴なループ」と「2. インスタンシング」は絵が完全に同じです — どちらも同じ 1,024 個の行列(同じFloat32Array)を読んでいるからで、違うのは 読み出し行の「体を描く WebGL 呼び出し」の数だけです。「3. per-instance 属性」はmat4をやめてvec4を 2 本にした版で、divisorを 2 にすると隣り合う 2 体が色と自転角を共有します。「4. gl_InstanceID だけ」はインスタンス属性を 1 本も持たず、10 万体を通し番号だけから配置します。読み出し行の 「体を描く WebGL 呼び出し」はデモごとの描画部分で実際に数えた回数で、体数に よらないフレームごとの呼び出し(useProgram・ビューと射影の送信・光の向き・bindVertexArrayの 5 回、毎フレームのgl.clear、デモ 4 の時間の uniform 1 回など)は含めていません。所要時間はCPU 側で測った rAF の間隔で、GPU の処理時間ではありません(第35章)。

この章で学ぶこと:

1. 同じ形を何度も描く — ドローコールという単位

素朴なやり方から始めます。1,024 体ぶんのモデル行列を用意し、1 体ずつu_modelに送ってはdrawElementsを呼ぶ。デモ 1 の中身がそのままこれです。

src/lessons/31-instancing/main.ts(抜粋)
for (let i = 0; i < INSTANCE_COUNT; i++) {
  // srcOffset / srcLength は WebGL2 で足された引数。Float32Array の一部だけを
  // 送れるので、毎フレーム 1,024 個の subarray を作らずに済む
  gl.uniformMatrix4fv(naiveLocations.model, false, instanceMatrices, i * 16, 16);
  calls++;
  gl.drawElements(gl.TRIANGLES, bodyBuffers.indexCount, gl.UNSIGNED_SHORT, 0);
  calls++;
}

ドローコール (draw call)とは、drawArrays/drawElementsのような「いま設定されている状態で描け」という 1 回の命令のことです。この 1 回に、CPU 側は次の仕事を払っています。

1 回あたりは小さくても、回数がそのまま掛かります。1,024 体ならuniformMatrix4fvが 1,024 回、drawElementsが 1,024 回で、合わせて1 フレームに 2,048 回の WebGL 呼び出しです。60 fps なら毎秒 12 万回を超えます。

インスタンシングにすると、この 2,048 回が3 回になります(バッファのバインド 1 回 + データ更新 1 回 + 描画 1 回)。読み出し行でデモ 1 と 2 を往復して確かめてください。

2. vertexAttribDivisor — 属性を「インスタンスごと」に進める

第3章から使ってきた頂点属性は、頂点が 1 つ進むたびに 1 要素進むものでした。vertexAttribPointerに渡したstrideのぶんだけポインタが動く、あの動きです。インスタンシングは、この「進み方」を属性ごとに 変えられるようにします。

WebGL 2.0 の API
void vertexAttribDivisor(GLuint index, GLuint divisor)

OpenGL ES 3.0.6 §2.9 の文言はこうです。

「何番目の要素が読まれるか」は §2.9.3 に式で書いてあります。インスタンス番号をinstanceとすると、インスタンス化された属性はそのドローコール中のすべての頂点に対して⌊instance ÷ divisor⌋番目の要素を読みます。頂点が何番であっても同じ要素、というのがここの要点です。

divisor = 1(1 体に 1 つ)divisor = 2(2 体で 1 つ)頂点属性(divisor = 0)は毎頂点進むインスタンス 0v0v1v2v3→i0(divisor 1)インスタンス 1v0v1v2v3→i1(divisor 1)インスタンス 2v0v1v2v3→i2(divisor 1)読む要素 = ⌊instance ÷ divisor⌋インスタンス 0→i0インスタンス 1→i0インスタンス 2→i1インスタンス 3→i1⌊0÷2⌋ = ⌊1÷2⌋ = 0、⌊2÷2⌋ = ⌊3÷2⌋ = 1→ 隣り合う 2 体が同じ要素を共有するバッファに必要な要素数は ⌈体数 ÷ divisor⌉
デモ 3 の「divisor 1 / 2」ボタンは、色と自転角の属性だけを右の状態に切り替えます。 位置とスケールの属性は divisor 1 のままなので、体の並びは変わらず、色と自転角が隣どうしで共有されます。色相はバッファの要素番号から決めている(hue = (i ÷ 1,024) × 2π × 3)ので、 読まれる要素が 1,024 個から 512 個に減るぶん、リング全体では虹 3 周ぶんだった配色が 1.5 周ぶんに伸びます。
src/lessons/31-instancing/main.ts(抜粋)
function setDivisor(value: number): void {
  divisor = value;
  // divisor は VAO の状態(ES 3.0.6 Table 6.2)なので、書き換えるには VAO をバインドする
  gl.bindVertexArray(attributesVao);
  gl.vertexAttribDivisor(COLOR_ANGLE_LOCATION, divisor);
  gl.bindVertexArray(null);
}

3. drawArraysInstanced と drawElementsInstanced

描画の側は、既存の 2 つの関数に引数が 1 つ増えただけです。

WebGL 2.0 の API
void drawArraysInstanced(GLenum mode, GLint first, GLsizei count, GLsizei instanceCount)
void drawElementsInstanced(GLenum mode, GLsizei count, GLenum type, GLintptr offset, GLsizei instanceCount)

仕様は、この 2 つを「1 インスタンスぶんを描く命令を、ループで回したもの」として 定義しています。ES 3.0.6 §2.9.3 がDrawArraysInstancedの効果として書いているのは、次のコードです(DrawArraysOneInstanceは「GL には存在しないが説明のために使う」仮の命令です)。

OpenGL ES 3.0.6 §2.9.3 が示す等価なコード
if (mode, count, or instanceCount is invalid)
    generate appropriate error
else {
    for (i = 0; i < instanceCount; i++) {
        DrawArraysOneInstance(mode, first, count, i);
    }
}

ここから 2 つのことが読み取れます。第一に、ループ変数iがそのまま「インスタンス番号」で、これが頂点シェーダーでgl_InstanceIDとして読める値になります(5 節)。第二に、instanceCountが 0 なら、このループは 1 度も回りません— つまり何も描かれず、エラーにもなりません。0 を無効な値とする記述は ES 3.0.6 §2.9.3 にも WebGL 2.0 仕様にも見当たりませんでした (countのほうは「0 なら要素は 1 つも転送されない」と明示されています)。

デモ 2 の描画部分はこれだけです。デモ 1 の 2,048 回の中身が、この 3 行になります。

src/lessons/31-instancing/main.ts(抜粋)
gl.bindBuffer(gl.ARRAY_BUFFER, matrixBuffer);
calls++;
gl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceMatrices);
calls++;
gl.drawElementsInstanced(
  gl.TRIANGLES,
  bodyBuffers.indexCount,
  gl.UNSIGNED_SHORT,
  0,
  INSTANCE_COUNT,
);
calls++;

そして、シェーダー側の差はこれだけです。uniform mat4 u_modelがin mat4 a_instanceMatrixに変わっただけで、式は 1 文字も変えていません。だから絵が完全に一致します。

src/lessons/31-instancing/01-naive.vert と 02-instanced.vert(抜粋・並べ替え。コメントは解説用)
// 01-naive.vert
uniform mat4 u_model; // ← 1 体ごとに送り直す
...
vec4 worldPosition = u_model * vec4(a_position, 1.0);
v_normal = mat3(u_model) * a_normal;
v_color = instanceTint(u_model[3].xyz);

// 02-instanced.vert
layout(location = 3) in mat4 a_instanceMatrix; // ← 1 回ぶんの描画に 1,024 個ぜんぶ渡す
...
vec4 worldPosition = a_instanceMatrix * vec4(a_position, 1.0);
v_normal = mat3(a_instanceMatrix) * a_normal;
v_color = instanceTint(a_instanceMatrix[3].xyz);

4. per-instance 属性の設計 — 行列は 4 スロット、上限は MAX_VERTEX_ATTRIBS

1 体ずつ違う値を渡すのが per-instance 属性です。何を渡すかは自由ですが、属性のスロットには予算があります。

まず、mat4の属性はスロットを 4 つ使います。GLSL ES 3.00 §4.3.4 は「行列の入力は複数の location を使う。使う location の数は行列の列数に等しい」と定めていて、OpenGL ES 3.0.6 §2.12.5 はさらに具体的に、「mat4として宣言された属性変数の列は、汎用属性iからi + 3の(x, y, z, w)成分から取られる」と書いています。layout(location = 3) in mat4 a_instanceMatrix;なら 3・4・5・6 の 4 つを占めるということです。

src/lessons/31-instancing/02-instanced.vert(抜粋)
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;

// mat4 の属性は location を 4 つ使う。3 を書くと 3・4・5・6 を占め、
// 列が 1 つずつのスロットに入る(ES 3.0.6 §2.12.5 / GLSL ES 3.00 §4.3.4。本文 4 節)。
// CPU 側では vertexAttribPointer と vertexAttribDivisor を 4 回ずつ呼ぶ
layout(location = 3) in mat4 a_instanceMatrix;

CPU 側もそれに合わせて、1 列ずつ 4 回配線します。vertexAttribPointerは最大 4 成分までしか受け付けないので、mat4を 1 回で渡す方法はありません。vertexAttribDivisorもスロットごとの状態なので、こちらも 4 回です。

src/lessons/31-instancing/main.ts(抜粋)
gl.bindBuffer(gl.ARRAY_BUFFER, matrixBuffer);
gl.bufferData(gl.ARRAY_BUFFER, instanceMatrices.byteLength, gl.DYNAMIC_DRAW);
// mat4 の属性は 4 つの location に分かれるので、列ごとに 4 回配線する(本文 4 節)。
// divisor もスロットごとの状態なので、まとめて 1 回ではなく 4 回呼ぶ
for (let column = 0; column < 4; column++) {
  const location = INSTANCE_MATRIX_LOCATION + column;
  gl.enableVertexAttribArray(location);
  gl.vertexAttribPointer(
    location,
    4,
    gl.FLOAT,
    false,
    16 * FLOAT_BYTES,
    column * 4 * FLOAT_BYTES,
  );
  gl.vertexAttribDivisor(location, 1); // 1 インスタンスにつき 1 つ進む
}

予算の上限はMAX_VERTEX_ATTRIBSです。OpenGL ES 3.0.6 の Table 6.31「Implementation Dependent Vertex Shader Limits」は、この値の最小要求値を 16と定めています(GLSL 側の組み込み定数もconst mediump int gl_MaxVertexAttribs = 16;)。仕様が保証するのはここまでなので、16 枠までならどの WebGL2 環境でも必ず使えます(ただしgl_InstanceID/gl_VertexIDを使うなら、そのぶんも数に入ります — 下の aside)。

実際の値はgl.getParameter(gl.MAX_VERTEX_ATTRIBS)で取れます。この環境(Chrome /ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)、2026-08-07 に確認)で読んだ値は16で、最小要求値ちょうどでした。実測値が最小要求値ぴったりになる実装もある、ということです。ただしこれは 1 環境の観測でしかありません。多いか少ないかを言えるだけの標本は取っていないので、設計するときは実測値ではなく最小要求値の 16 を予算とみなすのが安全です。

この章のメッシュが使っているのは位置と法線の 2 枠だけです(形は第14章のcreateTorus/createSphereをそのまま使っていて、UV は使わないので配線していません)。 仮に位置・法線・UV の 3 枠を使うメッシュにmat4を 1 つ足すと 7 枠。もう 1 つ足すと 11 枠で、16 枠の予算はかなり圧迫されます。

そこで設計の選択肢が出てきます。この章のデモは 3 つとも実装してあるので、比べられます。

案送るもの使うスロット1 体あたり1,024 体10 万体
(a) mat4 を丸ごと(デモ 2)mat4 1 本416 float = 64 B65,536 B(64.0 KiB)6,400,000 B(6.10 MiB)
(b) 位置・スケール・色・回転角(デモ 3)vec4 2 本28 float = 32 B32,768 B(32.0 KiB)3,200,000 B(3.05 MiB)
(c) gl_InstanceID から計算(デモ 4)なし00 B0 B0 B

バイト数は1 体あたりの float 数 × Float32Array.BYTES_PER_ELEMENT(4)× 体数で、KiB / MiB は 1024 で割ったものです。表の「使うスロット」はインスタンス属性のぶんだけで、 メッシュの位置・法線の 2 枠は別に要ります。

(b) を選ぶと、行列を組み立てる仕事が頂点シェーダーに移ります。デモ 3 の頂点シェーダーは、位置・スケール・自転角を受け取って回転行列を自分で作ります。向きについては送らずに済ませているものもあります — 体がリングのどこにいるかは中心座標からatanで求まるので、そのぶんは属性にしていません。

src/lessons/31-instancing/03-attributes.vert(抜粋)
layout(location = 3) in vec4 a_instancePosScale; // xyz = 中心 / w = スケール
layout(location = 4) in vec4 a_instanceColorAngle; // rgb = 色(リニア) / a = 自転角
src/lessons/31-instancing/03-attributes.vert(抜粋)
  // リングのどこにいるかは中心座標から分かるので、向きは送らずに atan で求める。
  // 「送らずに計算できるものは送らない」がこの節の設計方針
  float phi = atan(center.z, center.x);

どちらが良いかは一概には決まりません。(a) はどんな変換でも表現できるのが強みで、 せん断も非一様スケールも親子階層の合成結果も、行列 1 つに畳めば渡せます。(b) は表現できる変換を絞った代わりに、スロットとバイト数を半分にしたもので、絞りかたはシーンしだいです。この章のリングのように「位置・一様スケール・1 軸の自転」で 足りるなら (b) で十分ですし、足りないなら (a) に戻すことになります。

5. gl_InstanceID — 属性を持たずに何体目かを知る

第5章の組み込み変数の表に、「インスタンス描画での『何体目か』(第31章)」と書いて先送りにした 変数がありました。gl_InstanceIDです。GLSL ES 3.00 §7.1 は頂点シェーダーの組み込み入力としてこう宣言しています。

GLSL ES 3.00 §7.1(仕様の宣言)
in highp int gl_VertexID;
in highp int gl_InstanceID;

値は 3 節の等価コードのループ変数そのもので、0 からinstanceCount - 1まで進みます。仕様は「現在のプリミティブがインスタンス描画から来たものでなければ、gl_InstanceIDの値は 0」とも書いているので、非インスタンス版のdrawElementsで描いても未定義値にはなりません。

gl_VertexIDが隣に並んでいるのは偶然ではありません。第6章で全画面三角形を作ったとき、頂点バッファを 1 本も持たずにgl_VertexIDから 3 頂点の座標を作りました。「番号から値を計算すれば、データを持たなくてよい」— あれのインスタンス版がこれです。

デモ 4 は、この方針を最後まで押します。インスタンス属性もバッファも 1 本もありません。

src/lessons/31-instancing/04-instance-id.vert(抜粋)
  // 頂点シェーダーの int の既定精度は highp(GLSL ES 3.00 §4.5.4)。
  // gl_InstanceID 自身も highp int で宣言されている(§7.1)ので、10 万までは何も心配がない。
  // 割り算と剰余で 1 本の通し番号を 2 次元の番地に開く
  int column = gl_InstanceID % u_grid.x;
  int row = gl_InstanceID / u_grid.x;

  // 0〜1 に正規化してから角度にする。float への変換は 2²⁴ までは誤差なし
  float u = (float(column) + 0.5) / float(u_grid.x);
  float v = (float(row) + 0.5) / float(u_grid.y);

通し番号をu_grid.xで割って余りを取り、2 次元の番地に開いてから、リングの表面のパラメータ(u, v)に写しています。u_gridを uniform にしているのは、格子のサイズを JavaScript 側の 1 か所 (ID_GRID_COLS/ID_GRID_ROWS)だけで決めるためです — シェーダーにconst int COLS = 500;と書いてしまうと、同じ数が 2 か所に増えます。

描画は、この 1 回だけです。

src/lessons/31-instancing/main.ts(抜粋)
gl.drawElementsInstanced(
  gl.TRIANGLES,
  grainBuffers.indexCount,
  gl.UNSIGNED_SHORT,
  0,
  ID_INSTANCE_COUNT,
);

500 × 200 = 100,000 体。転送は 0 バイト、体を描く WebGL 呼び出しは 1 回です。1 体を 48 インデックスまで削ってあるので、流れる頂点は48 × 100,000 = 4,800,000— 1,024 体のデモの 8 倍強です。ここは減らせません。呼び出し回数を 1 回に できても、頂点の仕事はまるごと残る、というのがこの章の最初からの話です。

6. インスタンシングが効くとき、効かないとき

ここまでの整理です。インスタンシングが削るのはドローコールの回数という固定費だけなので、効くかどうかは「その固定費が支配的か」で決まります。

条件効くか理由
同じ形・同じマテリアルが多数効くそもそも 1 回にまとめられるのがこの条件のときだけ
1 体が小さい(画面を数ピクセルしか覆わない)効くフラグメント側が軽いので、1 体あたりのコストに占める呼び出しの固定費の割合が大きい
1 体の頂点数が非常に多い効きにくい体数が少なくても頂点の仕事が支配的。ドローコールの固定費は最初から埋もれている
体数が数個〜数十個効きにくい削れる回数がそもそも少ない。per-instance バッファを持つ手間のほうが高くつく
形が 1 体ずつ違う使えないインスタンシングは同じ頂点データの使い回しが前提。別々の形は別々の ドローコールになる

描画順を指定できない

インスタンスはgl_InstanceIDの順に描かれます。つまりバッファに並んでいる順で、途中で並べ替えることは できません。不透明な物体なら深度テストが順序を吸収してくれるので問題になりませんが、 半透明を混ぜた瞬間に効いてきます — 半透明を正しく重ねるには奥から手前へ描く必要があり、 その順序はカメラの位置で毎フレーム変わるからです。

やるとしたら、インスタンス属性の中身を CPU 側で並べ替えて送り直すことになります。 1,024 体ぶんのソートを毎フレーム回すわけで、削ったはずの CPU の仕事が別の形で戻ってきます。加算合成なら順序を気にしなくてよいので、パーティクルのような使い方では そちらへ逃がすのが定石です(第30章 4 節)。

毎フレーム更新すると、転送が残る

この章のデモ 1〜3 は、1,024 体ぶんのデータを毎フレーム CPU で作り直して送っています。 デモ 2 なら 64 KiB をbufferSubData1 回で。呼び出しが 2,048 回から 3 回に減っても、作る仕事と送る仕事は 1 バイトも減っていません。 第30章 6 節で CPU 側でパーティクルを動かしたときと、まったく同じ形の問題です。

体数を増やせば、この部分が主役になります。10 万体をmat4で更新するなら 1 フレームに 6.10 MiB。60 fps なら毎秒 366 MiB です。デモ 4 がgl_InstanceIDで逃げられたのは配置が規則的だったからで、1 体ずつ独立に動く物体ではそうはいきません。

この転送を消す道具が次章の Transform Feedback です。頂点シェーダーの出力をそのままバッファに書き戻せるので、状態の更新に CPU がいっさい関わらなくなります。書き戻したバッファをそのまま インスタンス属性として使うこともできます(2 節のvertexAttribDivisorを、更新後のバッファに対して設定するだけです)。ただしこの章でも第32章でも、その組み合わせは実装しません— 第32章のデモは点で描くので、インスタンシングは出てきません。

コード全文

シェーダーは 4 本の頂点シェーダー + 1 本のフラグメントシェーダー + 2 本のチャンクです。 フラグメントシェーダーは 4 本のデモで共用していて、この章の差はすべて頂点シェーダー側にあります。

src/lessons/31-instancing/main.ts
// 第31章: インスタンシング — 1 ドローコールで大量描画
//
// まったく同じ絵を 2 通りの手順で描き、切り替えて比べる。
//
//   デモ 1 素朴なループ    : 1 体ごとに uniformMatrix4fv + drawElements(1,024 体で 2,048 回)
//   デモ 2 インスタンシング: 同じ 1,024 個の行列をインスタンス属性で渡し、
//                            drawElementsInstanced 1 回で描く
//   デモ 3 per-instance 属性: mat4 をやめて vec4 2 本にし、divisor も切り替えられるようにする
//   デモ 4 gl_InstanceID    : 属性を 1 本も持たず、10 万体を「何体目か」だけから配置する
//
// デモ 1 と 2 は同じ Float32Array を読むので、絵は完全に一致する。
// 変わるのは CPU 側の呼び出し回数だけで、頂点シェーダーが処理する頂点の数は変わらない。
// GPU 時間は測らない(タイマークエリと CPU/GPU バウンドの見分けは第35章)。

import { mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { createSphere, createTorus, type Geometry, interleave } from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import naiveVertexTemplate from './01-naive.vert?raw';
import instancedVertexTemplate from './02-instanced.vert?raw';
import attributesVertexTemplate from './03-attributes.vert?raw';
import instanceIdVertexTemplate from './04-instance-id.vert?raw';
import colorChunkSource from './color.glsl?raw';
import instanceChunkSource from './instance.glsl?raw';
import shadeFragmentTemplate from './shade.frag?raw';

// ---------------------------------------------------------------------------
// 極小のインクルード(第23章と同じ)
// ---------------------------------------------------------------------------

// 置換文字列を関数で渡しているのは、String.replace が `$&` などを特別扱いするため
function resolveIncludes(source: string): string {
  return source
    .replace('#include "color.glsl"', () => colorChunkSource.trim())
    .replace('#include "instance.glsl"', () => instanceChunkSource.trim());
}

// ---------------------------------------------------------------------------
// 定数
// ---------------------------------------------------------------------------

// カメラ定数(第3部の標準): fovy 45°・near 0.1・far 100
const FOVY = (45 * Math.PI) / 180;
const NEAR = 0.1;
const FAR = 100;

const TAU = Math.PI * 2;
const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;

// デモ 1〜3 の配置: 大きなリングの表面に小さなトーラスを並べる
const RING_SEGMENTS = 64; // リングを 1 周する個数
const RING_TUBES = 16; // 管の断面を 1 周する個数
const INSTANCE_COUNT = RING_SEGMENTS * RING_TUBES; // 1,024 体
const MAJOR_RADIUS = 4.6;
const TUBE_RADIUS = 1.9;

// デモ 4 の配置: 同じ形のリングを、属性なしで 100 倍近い密度にする
const ID_GRID_COLS = 500;
const ID_GRID_ROWS = 200;
const ID_INSTANCE_COUNT = ID_GRID_COLS * ID_GRID_ROWS; // 100,000 体
const ID_SCALE = 0.055;

// 属性のスロット割り当て。0 = 位置 / 1 = 法線 は第3部の共通(第14章)。
// 2 は UV の指定席なので空けたまま、インスタンス属性は 3 から始める(本文 4 節)
const INSTANCE_MATRIX_LOCATION = 3; // mat4 なので 3・4・5・6 を占める
const POS_SCALE_LOCATION = 3;
const COLOR_ANGLE_LOCATION = 4;

// 面から光源へ向かう単位ベクトル(第17章の約束)
const LIGHT_DIRECTION: ReadonlyVec3 = vec3.normalize(
  vec3.create(),
  vec3.fromValues(0.42, 0.78, 0.46),
);

// ---------------------------------------------------------------------------
// ジオメトリのバッファ(第13〜14章の手順そのまま)
// ---------------------------------------------------------------------------

interface GeometryBuffers {
  vbo: WebGLBuffer;
  ibo: WebGLBuffer;
  /** drawElements に渡すインデックスの個数 */
  indexCount: number;
}

function createGeometryBuffers(gl: WebGL2RenderingContext, geometry: Geometry): GeometryBuffers {
  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);

  gl.bindBuffer(gl.ARRAY_BUFFER, null);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

  return {
    vbo,
    ibo,
    indexCount: geometry.indices.length,
  };
}

/** いまバインドされている VAO へ、頂点ごとの属性(位置・法線)と IBO を配線する */
function bindMeshAttributes(gl: WebGL2RenderingContext, buffers: GeometryBuffers): void {
  const stride = 8 * FLOAT_BYTES; // interleave() は [位置 3, 法線 3, UV 2](第14章)
  gl.bindBuffer(gl.ARRAY_BUFFER, buffers.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(location 2)はこの章では使わないので有効にしない。
  // 属性の枠は MAX_VERTEX_ATTRIBS までしかないので、使わないものは配線しない(本文 4 節)
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buffers.ibo);
}

// ---------------------------------------------------------------------------
// デモの型
// ---------------------------------------------------------------------------

interface OptionButton {
  label: string;
  isActive: () => boolean;
  select: () => void;
}

interface Demo {
  id: string;
  label: string;
  /** 1 フレーム描く。ここで発行した WebGL 呼び出しは calls に数える */
  draw: (time: number) => void;
  /** 読み出し行の材料 */
  cost: () => { count: number; calls: number; breakdown: string; bytes: number; vertices: number };
  options?: () => OptionButton[];
}

// ---------------------------------------------------------------------------
// デモ本体
// ---------------------------------------------------------------------------

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  demoControls: HTMLParagraphElement,
  optionControls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  // --- プログラム 4 本(フラグメントシェーダーは 4 本とも同じ) ------------------

  const fragmentSource = resolveIncludes(shadeFragmentTemplate);
  const buildProgram = (vertexTemplate: string): WebGLProgram =>
    linkProgram(
      gl,
      compileShader(gl, gl.VERTEX_SHADER, resolveIncludes(vertexTemplate)),
      compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
    );

  const naiveProgram = buildProgram(naiveVertexTemplate);
  const instancedProgram = buildProgram(instancedVertexTemplate);
  const attributesProgram = buildProgram(attributesVertexTemplate);
  const instanceIdProgram = buildProgram(instanceIdVertexTemplate);

  const at = (program: WebGLProgram, name: string): WebGLUniformLocation | null =>
    gl.getUniformLocation(program, name);

  const naiveLocations = {
    model: at(naiveProgram, 'u_model'),
    view: at(naiveProgram, 'u_view'),
    projection: at(naiveProgram, 'u_projection'),
    lightDirection: at(naiveProgram, 'u_lightDirection'),
  };
  const instancedLocations = {
    view: at(instancedProgram, 'u_view'),
    projection: at(instancedProgram, 'u_projection'),
    lightDirection: at(instancedProgram, 'u_lightDirection'),
  };
  const attributesLocations = {
    view: at(attributesProgram, 'u_view'),
    projection: at(attributesProgram, 'u_projection'),
    lightDirection: at(attributesProgram, 'u_lightDirection'),
  };
  const instanceIdLocations = {
    view: at(instanceIdProgram, 'u_view'),
    projection: at(instanceIdProgram, 'u_projection'),
    lightDirection: at(instanceIdProgram, 'u_lightDirection'),
    grid: at(instanceIdProgram, 'u_grid'),
    ringRadius: at(instanceIdProgram, 'u_ringRadius'),
    tubeRadius: at(instanceIdProgram, 'u_tubeRadius'),
    scale: at(instanceIdProgram, 'u_scale'),
    time: at(instanceIdProgram, 'u_time'),
  };

  // --- ジオメトリ 2 種(第14章のヘルパー。手書きはしない) ----------------------

  // デモ 1〜3 の 1 体: 117 頂点・576 インデックス
  const bodyBuffers = createGeometryBuffers(gl, createTorus(0.12, 0.05, 12, 8));
  // デモ 4 の 1 体: 10 万体ぶん動かすので、思い切って粗くする(15 頂点・48 インデックス)
  const grainBuffers = createGeometryBuffers(gl, createSphere(0.5, 4, 2));

  // --- インスタンスのデータ -----------------------------------------------------

  // デモ 1 と 2 が共有する 1,024 個のモデル行列。デモ 1 は 16 個ずつ uniform で送り、
  // デモ 2 はこの配列を丸ごと 1 回でバッファへ流す。同じ数値なので絵は必ず一致する
  const instanceMatrices = new Float32Array(INSTANCE_COUNT * 16);
  // デモ 3 の per-instance 属性: xyz = 中心 / w = スケール、rgb = 色 / a = 自転角
  const instancePosScale = new Float32Array(INSTANCE_COUNT * 4);
  const instanceColorAngle = new Float32Array(INSTANCE_COUNT * 4);

  const scratchMatrix = mat4.create();
  const scratchVector = vec3.create();
  const scratchScale = vec3.create();

  /** i 体目のリング上の位置と姿勢のパラメータ(デモ 1〜3 で共通) */
  function instanceParams(
    index: number,
    time: number,
  ): {
    phi: number;
    psi: number;
    center: vec3;
    scale: number;
    spin: number;
  } {
    const column = index % RING_SEGMENTS;
    const row = Math.floor(index / RING_SEGMENTS);
    const phi = ((column + 0.5) / RING_SEGMENTS) * TAU;
    const psi = ((row + 0.5) / RING_TUBES) * TAU;

    const tube = TUBE_RADIUS * (1 + 0.14 * Math.sin(time * 0.9 + phi * 4 + psi * 2));
    const ring = MAJOR_RADIUS + tube * Math.cos(psi);
    vec3.set(scratchVector, ring * Math.cos(phi), tube * Math.sin(psi), ring * Math.sin(phi));

    return {
      phi,
      psi,
      center: scratchVector,
      scale: 1 + 0.28 * Math.sin(time * 1.3 + phi * 2 - psi),
      spin: time * 1.1 + phi * 3 + psi,
    };
  }

  /** デモ 1・2 が使うモデル行列を毎フレーム作り直す */
  function writeInstanceMatrices(time: number): void {
    for (let i = 0; i < INSTANCE_COUNT; i++) {
      const { phi, center, scale, spin } = instanceParams(i, time);
      mat4.identity(scratchMatrix);
      mat4.translate(scratchMatrix, scratchMatrix, center);
      mat4.rotateY(scratchMatrix, scratchMatrix, -phi); // リングに沿わせる
      mat4.rotateX(scratchMatrix, scratchMatrix, spin); // 自転
      vec3.set(scratchScale, scale, scale, scale);
      mat4.scale(scratchMatrix, scratchMatrix, scratchScale);
      instanceMatrices.set(scratchMatrix, i * 16);
    }
  }

  /** デモ 3 が使う 2 本の per-instance 属性を毎フレーム作り直す */
  function writeInstanceAttributes(time: number, colorAngleElements: number): void {
    for (let i = 0; i < INSTANCE_COUNT; i++) {
      const { center, scale } = instanceParams(i, time);
      instancePosScale[i * 4] = center[0];
      instancePosScale[i * 4 + 1] = center[1];
      instancePosScale[i * 4 + 2] = center[2];
      instancePosScale[i * 4 + 3] = scale;
    }
    // 色と自転角は「読まれるぶん」だけ書けばよい。divisor 2 のときは先頭の半分しか読まれない
    for (let i = 0; i < colorAngleElements; i++) {
      // 通し番号から決める色。デモ 1・2 の instanceTint(中心座標から決める)とは別物で、
      // 「色をデータとして持つ」ことを見せるためにわざと違う決め方にしてある
      const hue = (i / INSTANCE_COUNT) * TAU * 3;
      instanceColorAngle[i * 4] = 0.16 + 0.62 * Math.cos(hue);
      instanceColorAngle[i * 4 + 1] = 0.16 + 0.62 * Math.cos(hue + TAU / 3);
      instanceColorAngle[i * 4 + 2] = 0.16 + 0.62 * Math.cos(hue + (TAU * 2) / 3);
      instanceColorAngle[i * 4 + 3] = instanceParams(i, time).spin;
    }
  }

  // --- VAO 4 本 -----------------------------------------------------------------

  // デモ 1: インスタンス属性なし。第13章までとまったく同じ配線
  const naiveVao = gl.createVertexArray();
  gl.bindVertexArray(naiveVao);
  bindMeshAttributes(gl, bodyBuffers);

  // デモ 2: mat4 のインスタンス属性を 1 本
  const matrixBuffer = gl.createBuffer();
  const instancedVao = gl.createVertexArray();
  gl.bindVertexArray(instancedVao);
  bindMeshAttributes(gl, bodyBuffers);
  gl.bindBuffer(gl.ARRAY_BUFFER, matrixBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, instanceMatrices.byteLength, gl.DYNAMIC_DRAW);
  // mat4 の属性は 4 つの location に分かれるので、列ごとに 4 回配線する(本文 4 節)。
  // divisor もスロットごとの状態なので、まとめて 1 回ではなく 4 回呼ぶ
  for (let column = 0; column < 4; column++) {
    const location = INSTANCE_MATRIX_LOCATION + column;
    gl.enableVertexAttribArray(location);
    gl.vertexAttribPointer(
      location,
      4,
      gl.FLOAT,
      false,
      16 * FLOAT_BYTES,
      column * 4 * FLOAT_BYTES,
    );
    gl.vertexAttribDivisor(location, 1); // 1 インスタンスにつき 1 つ進む
  }

  // デモ 3: vec4 のインスタンス属性を 2 本
  const posScaleBuffer = gl.createBuffer();
  const colorAngleBuffer = gl.createBuffer();
  const attributesVao = gl.createVertexArray();
  gl.bindVertexArray(attributesVao);
  bindMeshAttributes(gl, bodyBuffers);
  gl.bindBuffer(gl.ARRAY_BUFFER, posScaleBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, instancePosScale.byteLength, gl.DYNAMIC_DRAW);
  gl.enableVertexAttribArray(POS_SCALE_LOCATION);
  gl.vertexAttribPointer(POS_SCALE_LOCATION, 4, gl.FLOAT, false, 0, 0);
  gl.vertexAttribDivisor(POS_SCALE_LOCATION, 1);
  gl.bindBuffer(gl.ARRAY_BUFFER, colorAngleBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, instanceColorAngle.byteLength, gl.DYNAMIC_DRAW);
  gl.enableVertexAttribArray(COLOR_ANGLE_LOCATION);
  gl.vertexAttribPointer(COLOR_ANGLE_LOCATION, 4, gl.FLOAT, false, 0, 0);
  gl.vertexAttribDivisor(COLOR_ANGLE_LOCATION, 1); // ボタンで 2 に変えられる

  // デモ 4: インスタンス属性なし。バッファも 1 本も作らない
  const instanceIdVao = gl.createVertexArray();
  gl.bindVertexArray(instanceIdVao);
  bindMeshAttributes(gl, grainBuffers);

  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ARRAY_BUFFER, null);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

  // デモ 4 の配置パラメータは動かないので、初期化時に 1 回だけ送る。
  // 格子のサイズを uniform にしてあるのは、同じ数がシェーダーと JavaScript の
  // 2 か所に増えるのを避けるため(本文 5 節)
  gl.useProgram(instanceIdProgram);
  gl.uniform2i(instanceIdLocations.grid, ID_GRID_COLS, ID_GRID_ROWS);
  gl.uniform1f(instanceIdLocations.ringRadius, MAJOR_RADIUS);
  gl.uniform1f(instanceIdLocations.tubeRadius, TUBE_RADIUS);
  gl.uniform1f(instanceIdLocations.scale, ID_SCALE);

  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.CULL_FACE); // geometry.ts の三角形は外から見て CCW(第14章)
  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  // --- 軌道カメラ(第15章の簡略版) ---------------------------------------------

  const orbit = { theta: 0.7, phi: 0.5, radius: 18 };
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 8;
  const MAX_RADIUS = 36;

  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 },
  );

  // --- 毎フレーム使い回す入れ物 -------------------------------------------------

  const eye = vec3.create();
  const view = mat4.create();
  const projection = mat4.create();
  const TARGET: ReadonlyVec3 = vec3.fromValues(0, 0, 0);
  const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);

  /** 4 本のデモに共通の uniform。この 5 回はどのデモでも同じなので、呼び出し回数には数えない */
  function useSceneProgram(
    program: WebGLProgram,
    locations: {
      view: WebGLUniformLocation | null;
      projection: WebGLUniformLocation | null;
      lightDirection: WebGLUniformLocation | null;
    },
    vao: WebGLVertexArrayObject,
  ): void {
    gl.useProgram(program);
    gl.uniformMatrix4fv(locations.view, false, view);
    gl.uniformMatrix4fv(locations.projection, false, projection);
    gl.uniform3f(
      locations.lightDirection,
      LIGHT_DIRECTION[0],
      LIGHT_DIRECTION[1],
      LIGHT_DIRECTION[2],
    );
    gl.bindVertexArray(vao);
  }

  // --- デモ 4 本 -----------------------------------------------------------------

  let calls = 0; // 体を描くための WebGL 呼び出しの実測(draw の中で 1 回ずつ足す)
  let divisor = 1; // デモ 3 のオプション

  const naiveDemo: Demo = {
    id: 'naive',
    label: '1. 素朴なループ',
    draw(time) {
      writeInstanceMatrices(time);
      useSceneProgram(naiveProgram, naiveLocations, naiveVao);
      calls = 0;
      for (let i = 0; i < INSTANCE_COUNT; i++) {
        // srcOffset / srcLength は WebGL2 で足された引数。Float32Array の一部だけを
        // 送れるので、毎フレーム 1,024 個の subarray を作らずに済む
        gl.uniformMatrix4fv(naiveLocations.model, false, instanceMatrices, i * 16, 16);
        calls++;
        gl.drawElements(gl.TRIANGLES, bodyBuffers.indexCount, gl.UNSIGNED_SHORT, 0);
        calls++;
      }
    },
    cost: () => ({
      count: INSTANCE_COUNT,
      calls,
      breakdown: `uniformMatrix4fv ${INSTANCE_COUNT.toLocaleString('en-US')} + drawElements ${INSTANCE_COUNT.toLocaleString('en-US')}`,
      // uniform で送っても、アプリが WebGL に渡すのは行列 1,024 個ぶんで変わらない。
      // ドライバがそこから実際に GPU へ何バイト流すかは数えられないので扱わない(第35章)
      bytes: INSTANCE_COUNT * 16 * FLOAT_BYTES,
      vertices: bodyBuffers.indexCount * INSTANCE_COUNT,
    }),
  };

  const instancedDemo: Demo = {
    id: 'instanced',
    label: '2. インスタンシング',
    draw(time) {
      writeInstanceMatrices(time); // デモ 1 とまったく同じ数値
      useSceneProgram(instancedProgram, instancedLocations, instancedVao);
      calls = 0;
      gl.bindBuffer(gl.ARRAY_BUFFER, matrixBuffer);
      calls++;
      gl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceMatrices);
      calls++;
      gl.drawElementsInstanced(
        gl.TRIANGLES,
        bodyBuffers.indexCount,
        gl.UNSIGNED_SHORT,
        0,
        INSTANCE_COUNT,
      );
      calls++;
    },
    cost: () => ({
      count: INSTANCE_COUNT,
      calls,
      breakdown: 'bindBuffer 1 + bufferSubData 1 + drawElementsInstanced 1',
      bytes: INSTANCE_COUNT * 16 * FLOAT_BYTES,
      vertices: bodyBuffers.indexCount * INSTANCE_COUNT,
    }),
  };

  /** divisor のとき、色と自転角のバッファが実際に読まれる要素数 */
  function colorAngleElements(): number {
    return Math.ceil(INSTANCE_COUNT / divisor);
  }

  const attributesDemo: Demo = {
    id: 'attributes',
    label: '3. per-instance 属性',
    draw(time) {
      const elements = colorAngleElements();
      writeInstanceAttributes(time, elements);
      useSceneProgram(attributesProgram, attributesLocations, attributesVao);
      calls = 0;
      gl.bindBuffer(gl.ARRAY_BUFFER, posScaleBuffer);
      calls++;
      gl.bufferSubData(gl.ARRAY_BUFFER, 0, instancePosScale);
      calls++;
      gl.bindBuffer(gl.ARRAY_BUFFER, colorAngleBuffer);
      calls++;
      // 読まれるぶんだけ送る。divisor 2 なら先頭の半分で足りる
      gl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceColorAngle, 0, elements * 4);
      calls++;
      gl.drawElementsInstanced(
        gl.TRIANGLES,
        bodyBuffers.indexCount,
        gl.UNSIGNED_SHORT,
        0,
        INSTANCE_COUNT,
      );
      calls++;
    },
    cost: () => ({
      count: INSTANCE_COUNT,
      calls,
      breakdown: 'bindBuffer 2 + bufferSubData 2 + drawElementsInstanced 1',
      bytes: (INSTANCE_COUNT + colorAngleElements()) * 4 * FLOAT_BYTES,
      vertices: bodyBuffers.indexCount * INSTANCE_COUNT,
    }),
    options: () => [
      {
        label: 'divisor 1',
        isActive: () => divisor === 1,
        select: () => setDivisor(1),
      },
      {
        label: 'divisor 2',
        isActive: () => divisor === 2,
        select: () => setDivisor(2),
      },
    ],
  };

  function setDivisor(value: number): void {
    divisor = value;
    // divisor は VAO の状態(ES 3.0.6 Table 6.2)なので、書き換えるには VAO をバインドする
    gl.bindVertexArray(attributesVao);
    gl.vertexAttribDivisor(COLOR_ANGLE_LOCATION, divisor);
    gl.bindVertexArray(null);
  }

  const instanceIdDemo: Demo = {
    id: 'instance-id',
    label: '4. gl_InstanceID だけ',
    draw(time) {
      useSceneProgram(instanceIdProgram, instanceIdLocations, instanceIdVao);
      // 格子のサイズ・半径・スケールは初期化時に 1 回送ってある。毎フレーム変わるのは時間だけで、
      // これは体数によらない「シーンの uniform」なので、下の呼び出し回数には数えない
      gl.uniform1f(instanceIdLocations.time, time);
      calls = 0;
      gl.drawElementsInstanced(
        gl.TRIANGLES,
        grainBuffers.indexCount,
        gl.UNSIGNED_SHORT,
        0,
        ID_INSTANCE_COUNT,
      );
      calls++;
    },
    cost: () => ({
      count: ID_INSTANCE_COUNT,
      calls,
      breakdown: 'drawElementsInstanced 1',
      bytes: 0,
      vertices: grainBuffers.indexCount * ID_INSTANCE_COUNT,
    }),
  };

  const demos: readonly Demo[] = [naiveDemo, instancedDemo, attributesDemo, instanceIdDemo];
  let current = demos[0];

  // --- 切替ボタン(第13章と同じ作り) --------------------------------------------

  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;
      // 直前のデモのフレームを平均に混ぜない。捨てないと、切り替えた直後に表示される
      // rAF 間隔は 2 つのデモの混合になる(サンプルは 30 フレームぶんの移動平均)
      resetFrameSamples();
      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();
        for (const other of optionControls.querySelectorAll('button')) {
          other.setAttribute('aria-pressed', 'false');
        }
        button.setAttribute('aria-pressed', 'true');
      });
      optionControls.append(button);
    }
  }
  buildOptionButtons();

  // --- リサイズ(第13章と同じ) ---------------------------------------------------

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

  // --- 読み出し行 ---------------------------------------------------------------

  const FRAME_SAMPLES = 30;
  const frameTimes: number[] = [];
  let lastTimestamp = 0;
  let lastReadoutAt = 0;

  /** デモを切り替えたときに、前のデモのフレーム間隔を捨てる */
  function resetFrameSamples(): void {
    frameTimes.length = 0;
  }

  function averageFrameMs(): number {
    if (frameTimes.length === 0) return 0;
    let total = 0;
    for (const value of frameTimes) total += value;
    return total / frameTimes.length;
  }

  function updateReadout(): void {
    const cost = current.cost();
    const n = (value: number): string => value.toLocaleString('en-US');
    const ms = frameTimes.length === 0 ? '—' : `${averageFrameMs().toFixed(1)} ms`;
    readout.textContent =
      `${current.label} / ${n(cost.count)} 体 / ` +
      `体を描く WebGL 呼び出し ${n(cost.calls)} 回(${cost.breakdown})/ ` +
      `頂点 ${n(cost.vertices / cost.count)} × ${n(cost.count)} = ${n(cost.vertices)} / ` +
      `インスタンスデータ ${n(cost.bytes)} B/フレーム / ` +
      `rAF 間隔 ${ms}(CPU 側の計測。GPU 時間ではありません・第35章)`;
  }

  // --- 描画ループ ---------------------------------------------------------------

  function frame(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();
    const time = timestamp / 1000;

    // rAF の間隔を測る。タブが裏に回っているあいだ rAF は止まるので、復帰直後の
    // 巨大な差分は平均に混ぜない(計測ではなく、単に呼ばれなかっただけなので)
    const delta = timestamp - lastTimestamp;
    if (lastTimestamp !== 0 && delta < 200) {
      frameTimes.push(delta);
      if (frameTimes.length > FRAME_SAMPLES) frameTimes.shift();
    }
    lastTimestamp = timestamp;

    // カメラ(第15章)
    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);
    // アスペクト比は描画バッファの実サイズ(viewport と同じ値)から毎フレーム計算する
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    current.draw(time);

    if (timestamp - lastReadoutAt > 250) {
      updateReadout();
      lastReadoutAt = timestamp;
    }
    requestAnimationFrame(frame);
  }

  // このページのデモはページと寿命を共にするので、rAF ループの停止もリスナーの解除も
  // していない。GPU リソース解放の一般論は第35章
  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/31-instancing/01-naive.vert
#version 300 es

// デモ 1: 素朴なループ。
// 1 体ぶんのモデル行列を uniform で送り、drawElements を 1 回。それを体の数だけ繰り返す。
// 第13〜23章でずっとやってきた描き方そのもので、シェーダーには新しいものが何もない。

// 第3部の共通の属性配置(第14章から): 0 = 位置, 1 = 法線, 2 = UV。
// この章のメッシュは UV を使わないので、有効にしているのは 0 と 1 の 2 枠だけ(本文 4 節)
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;

uniform mat4 u_model; // ← 1 体ごとに送り直す。この 1 行がこの章の出発点
uniform mat4 u_view;
uniform mat4 u_projection;

out vec3 v_normal;
out vec3 v_color;

#include "instance.glsl"

void main() {
  vec4 worldPosition = u_model * vec4(a_position, 1.0);

  // このデモのモデル行列は回転と一様スケールだけなので、左上 3×3 で法線を回して
  // フラグメント側で正規化すれば足りる(法線行列の正式版は第17章)
  v_normal = mat3(u_model) * a_normal;

  // 列優先なので u_model[3] が第 4 列 = 平行移動。その xyz がこの体の中心
  v_color = instanceTint(u_model[3].xyz);

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/31-instancing/02-instanced.vert
#version 300 es

// デモ 2: インスタンシング。
// 01-naive.vert との差は「u_model という uniform が a_instanceMatrix という属性になった」
// だけで、行列の中身も計算も 1 文字も変えていない。だから絵はデモ 1 と完全に同じになる。

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;

// mat4 の属性は location を 4 つ使う。3 を書くと 3・4・5・6 を占め、
// 列が 1 つずつのスロットに入る(ES 3.0.6 §2.12.5 / GLSL ES 3.00 §4.3.4。本文 4 節)。
// CPU 側では vertexAttribPointer と vertexAttribDivisor を 4 回ずつ呼ぶ
layout(location = 3) in mat4 a_instanceMatrix;

uniform mat4 u_view;
uniform mat4 u_projection;

out vec3 v_normal;
out vec3 v_color;

#include "instance.glsl"

void main() {
  vec4 worldPosition = a_instanceMatrix * vec4(a_position, 1.0);

  v_normal = mat3(a_instanceMatrix) * a_normal;
  v_color = instanceTint(a_instanceMatrix[3].xyz);

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/31-instancing/03-attributes.vert
#version 300 es

// デモ 3: per-instance 属性。
// mat4 を丸ごと送るのをやめ、「位置 + スケール」と「色 + 回転角」の vec4 を 2 本だけ送る。
// 使う location は 4 つではなく 2 つ、1 体あたりのバイト数は 64 から 32 に減る(本文 4 節)。
// 減ったぶんの仕事は、この頂点シェーダーが行列を組み立てて肩代わりしている。

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;

layout(location = 3) in vec4 a_instancePosScale; // xyz = 中心 / w = スケール
layout(location = 4) in vec4 a_instanceColorAngle; // rgb = 色(リニア) / a = 自転角

uniform mat4 u_view;
uniform mat4 u_projection;

out vec3 v_normal;
out vec3 v_color;

// このデモは instance.glsl の関数を使わない(色もデータとして受け取るため)ので、
// #include は書いていない

void main() {
  vec3 center = a_instancePosScale.xyz;
  float scale = a_instancePosScale.w;
  float spin = a_instanceColorAngle.a;

  // リングのどこにいるかは中心座標から分かるので、向きは送らずに atan で求める。
  // 「送らずに計算できるものは送らない」がこの節の設計方針
  float phi = atan(center.z, center.x);
  float cp = cos(phi);
  float sp = sin(phi);
  // 列優先。第 1 列がリングの外向き、第 3 列がリングの接線方向になる
  mat3 ring = mat3(vec3(cp, 0.0, sp), vec3(0.0, 1.0, 0.0), vec3(-sp, 0.0, cp));

  float cs = cos(spin);
  float ss = sin(spin);
  mat3 selfSpin = mat3(vec3(1.0, 0.0, 0.0), vec3(0.0, cs, ss), vec3(0.0, -ss, cs));

  mat3 rotation = ring * selfSpin;
  vec3 world = center + rotation * (a_position * scale);

  v_normal = rotation * a_normal;
  v_color = a_instanceColorAngle.rgb; // 色は計算せず、データとして受け取る

  gl_Position = u_projection * u_view * vec4(world, 1.0);
}
src/lessons/31-instancing/04-instance-id.vert
#version 300 es

// デモ 4: gl_InstanceID だけ。
// インスタンス属性は 1 本もない。バッファも転送もゼロで、配置は「何体目か」から計算する。
// 第6章の全画面三角形が gl_VertexID から頂点を作ったのと同じ発想の、インスタンス版(本文 5 節)。

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;

uniform mat4 u_view;
uniform mat4 u_projection;
uniform ivec2 u_grid; // (リング 1 周の個数, 管の断面 1 周の個数)。instanceCount = x * y
uniform float u_ringRadius;
uniform float u_tubeRadius;
uniform float u_scale;
uniform float u_time;

out vec3 v_normal;
out vec3 v_color;

#include "instance.glsl"

void main() {
  // 頂点シェーダーの int の既定精度は highp(GLSL ES 3.00 §4.5.4)。
  // gl_InstanceID 自身も highp int で宣言されている(§7.1)ので、10 万までは何も心配がない。
  // 割り算と剰余で 1 本の通し番号を 2 次元の番地に開く
  int column = gl_InstanceID % u_grid.x;
  int row = gl_InstanceID / u_grid.x;

  // 0〜1 に正規化してから角度にする。float への変換は 2²⁴ までは誤差なし
  float u = (float(column) + 0.5) / float(u_grid.x);
  float v = (float(row) + 0.5) / float(u_grid.y);
  float phi = u * TAU; // リングを 1 周
  float psi = v * TAU; // 管の断面を 1 周

  float tube = u_tubeRadius * (1.0 + 0.14 * sin(u_time * 0.9 + phi * 4.0 + psi * 2.0));
  float ring = u_ringRadius + tube * cos(psi);
  vec3 center = vec3(ring * cos(phi), tube * sin(psi), ring * sin(phi));

  // 管の表面の外向き。これを第 3 軸にして 1 体ぶんの回転を作る
  vec3 outward = vec3(cos(psi) * cos(phi), sin(psi), cos(psi) * sin(phi));
  mat3 rotation = basisFromDirection(outward);

  vec3 world = center + rotation * (a_position * u_scale);

  v_normal = rotation * a_normal;
  v_color = instanceTint(center);

  gl_Position = u_projection * u_view * vec4(world, 1.0);
}
src/lessons/31-instancing/shade.frag
#version 300 es

// 4 本のデモが共用するフラグメントシェーダー。
// この章の主題は「頂点をどう配るか」なので、フラグメント側は 4 本とも同じにしてある。
// 陰影は第17章の Lambert 拡散だけ(再解説はしない)。

precision highp float;

in vec3 v_normal; // ワールド空間の法線(第17章の約束)
in vec3 v_color; // インスタンスごとの基本色(リニア値)

// 面から光源へ向かう単位ベクトル(第17章の約束。光が進む向きではない)
uniform vec3 u_lightDirection;

out vec4 fragColor;

#include "color.glsl"

void main() {
  vec3 N = normalize(v_normal); // 補間で縮んだ法線を戻す(第17章)
  vec3 L = u_lightDirection;

  // 隙間が真っ黒にならない程度の環境光(第17章の定数の環境光と同じ扱い)
  const vec3 ambient = vec3(0.19, 0.21, 0.26);
  const vec3 lightColor = vec3(1.0, 0.97, 0.92);

  vec3 linear = v_color * (ambient + lightColor * max(dot(N, L), 0.0));

  // リニア空間で計算し、画面へ出す直前の 1 か所だけでエンコードする(第27章 2 節)
  fragColor = vec4(linearToSrgb(linear), 1.0);
}
src/lessons/31-instancing/instance.glsl
// 第31章 4 本のデモが共有する小さなチャンク。
// 章ローカルに置いてある理由は第23章の pbr.glsl・第24章の sdf.glsl と同じで、
// 読者が壊して遊ぶ対象を章のディレクトリの中で完結させるため(docs/plan.md の抽象化タイムライン)。
// 配り方は第23章の #include 方式(main.ts の resolveIncludes)。

const float TAU = 6.283185307179586;

/**
 * インスタンスの中心座標から色を決めるパレット。
 * 位置だけから決まるので、モデル行列を uniform で送るデモ 1 と、
 * まったく同じ行列をインスタンス属性で送るデモ 2 とで、必ず同じ色になる。
 * 返す値は「単位のない 0〜1」= リニア値として扱うと決めている(第27章 3 節)。
 */
vec3 instanceTint(vec3 center) {
  float t = atan(center.z, center.x) / TAU + 0.5; // リングを 1 周して 0→1
  float h = center.y * 0.35;
  return 0.16 + 0.62 * cos(TAU * (vec3(0.0, 0.33, 0.67) + t + h));
}

/**
 * 単位ベクトル d を第 3 軸とする正規直交基底。
 * cross を 2 回でカメラの基底を作ったのと同じ手順で、
 * インスタンスを「外向き」に立てるための回転行列にする。
 */
mat3 basisFromDirection(vec3 d) {
  // d と平行な参照ベクトルを選ぶと cross がゼロになるので、成分の大きさで避ける
  vec3 reference = abs(d.y) > 0.99 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0);
  vec3 tangent = normalize(cross(reference, d));
  vec3 bitangent = cross(d, tangent);
  return mat3(tangent, bitangent, d);
}
src/lessons/31-instancing/color.glsl
// 第31章 出力の最後の 1 行のための色変換。
// 中身は第27章の src/lessons/27-color-and-palette/color.glsl からの複製(内容は同一)で、
// srgbToLinear / linearToSrgb の 2 関数だけを持ってきている。
// 片方を直したらもう片方も直すこと(理由は docs/plan.md の抽象化タイムライン:
// 読者が壊して遊ぶ対象なので章をまたいだ結合を作らない)。
//
// この章で使うのは linearToSrgb だけ。インスタンスの色もライティングもリニア空間で
// 計算し、画面へ出す直前の 1 か所だけでエンコードする。変換の体系そのものは第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 のインスタンシングは、この章でやったことをほぼそのまま包んだものです。WebGLProgramがFLOAT_MAT4型の属性を見つけたらlocationSizeを 4 にし、WebGLBindingStatesがその数だけvertexAttribDivisorを呼ぶ、という作りです(r185 のソース)。

three.jsこの章
new THREE.InstancedMesh(geometry, material, count)デモ 2 の一式。countがinstanceCountに相当する
mesh.instanceMatrixmatrixBuffer+a_instanceMatrix。three.js ではnew InstancedBufferAttribute(new Float32Array(count * 16), 16)として作られ、シェーダー側にはattribute mat4 instanceMatrix;が自動で足される
mesh.setMatrixAt(i, matrix)instanceMatrices.set(scratchMatrix, i * 16)。three.js のドキュメントコメントは「setMatrixAtでデータを変えたらinstanceMatrix.needsUpdateを true にしなければならない」と書いている。この章のbufferSubDataを呼ぶかどうかのフラグにあたる
mesh.countdrawElementsInstancedのinstanceCount引数。確保した数より小さくすれば、先頭のぶんだけが描かれる
mesh.setColorAt(i, color) / mesh.instanceColorデモ 3 のa_instanceColorAngleの色成分。instanceColorは既定ではnullで、setColorAtを最初に呼んだときにInstancedBufferAttribute(itemSize 3)が作られる
new THREE.InstancedBufferAttribute(array, itemSize, normalized, meshPerAttribute)vertexAttribPointer+vertexAttribDivisor。第 4 引数のmeshPerAttributeがそのまま divisor で、ソースのコメントも「1 なら 1 インスタンスに 1 つ、2 なら連続する 2 インスタンスに 1 つ」と説明している
THREE.InstancedBufferGeometryのinstanceCountInstancedMeshを使わずに素のインスタンス描画をするときの体数。既定値はInfinityで、そのときは属性の要素数× meshPerAttributeから決まる
—gl_InstanceIDだけで配置するデモ 4 に相当する three.js の口はない。InstancedBufferGeometry+ShaderMaterialで自分でシェーダーを書けば、GLSL からは同じように読める

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

コードはsrc/lessons/31-instancing/にあります。この章の失敗は「エラーが出ないまま、絵だけが静かに壊れる」という顔をすることが 多いので、症状と原因を先に体験しておくと効きます。

まとめ

次章第32章「Transform Feedback — GPU パーティクルと GPGPU」では、この章に 残った宿題を片付けます。インスタンスの状態を毎フレーム CPU で作り直して送り直す形は、第30章 6 節で CPU パーティクルを動かしたときとまったく同じ問題を抱えたままでした。呼び出し回数を 2,048 回から 3 回に減らしても、その 3 回のうち 2 回は「作って送る」ための呼び出しです。頂点シェーダーの出力をそのままバッファへ書き戻せれば、CPU は何もしなくてよくなります。transformFeedbackVaryingsというリンク前の指定、RASTERIZER_DISCARDという「絵を出さないパス」、そして第29章 2 節のテクスチャ ping-pong に対応するバッファの ping-pong を組み合わせて、数十万のパーティクルを GPU の中だけで動かします。