インスタンシング — 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章)。
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 回呼ぶたびに CPU が払っているもの
gl.vertexAttribDivisor— 属性を「頂点ごと」から「インスタンスごと」に切り替える。これが VAO の状態であることdrawArraysInstanced/drawElementsInstancedと、仕様がこれをどう定義しているか- per-instance 属性の設計 —
mat4は location を 4 つ食う。MAX_VERTEX_ATTRIBSという予算の中で何を送るか gl_InstanceID— 属性もバッファも持たずに「何体目か」を知る(第5章の伏線)- インスタンシングが効く条件と、効かない条件。描画順が指定できないという制約
1. 同じ形を何度も描く — ドローコールという単位
素朴なやり方から始めます。1,024 体ぶんのモデル行列を用意し、1 体ずつu_modelに送ってはdrawElementsを呼ぶ。デモ 1 の中身がそのままこれです。
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 側は次の仕事を払っています。
- JavaScript から WebGL 実装への呼び出し— 引数の型と値の検査に加えて、描画命令そのものに課された検査があります。WebGL 2.0 仕様の「Differences Between WebGL and OpenGL ES 3.0」は、
drawElements/drawElementsInstancedが参照するインデックスがMAX_ELEMENT_INDEXを超えていたら、バインドされたバッファの中に収まっていてもINVALID_OPERATIONにすると定め(「Range Checking」)、裏付けの足りない active uniform block を持つプログラムで 描こうとしてもINVALID_OPERATIONにすると定めています(「Active Uniform Block Backing」)。フレームバッファが完備でなければ 描画命令はINVALID_FRAMEBUFFER_OPERATIONです(ES 3.0.6 §4.4.4.4)。不正な操作を素通しさせずエラーとして返すぶん、この検査が厚い - uniform の書き換え—
uniformMatrix4fvも同じく 1 回の API 呼び出しで、16 個の float を検査してプログラムの状態に書き込む - ドライバがコマンドを積む処理— 実際に GPU が動くのはずっとあとで、ここでやっているのはコマンドバッファへの記録
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のぶんだけポインタが動く、あの動きです。インスタンシングは、この「進み方」を属性ごとに 変えられるようにします。
void vertexAttribDivisor(GLuint index, GLuint divisor)OpenGL ES 3.0.6 §2.9 の文言はこうです。
divisorが 0(既定)なら、そのスロットの属性は頂点ごとに 1 つ進むdivisorが 0 でないなら、属性は描かれるプリミティブのdivisorインスタンスごとに 1 つ進むdivisorが 0 でない属性をインスタンス化されている (instanced)と呼ぶ
「何番目の要素が読まれるか」は §2.9.3 に式で書いてあります。インスタンス番号をinstanceとすると、インスタンス化された属性はそのドローコール中のすべての頂点に対して⌊instance ÷ divisor⌋番目の要素を読みます。頂点が何番であっても同じ要素、というのがここの要点です。
hue = (i ÷ 1,024) × 2π × 3)ので、 読まれる要素が 1,024 個から 512 個に減るぶん、リング全体では虹 3 周ぶんだった配色が 1.5 周ぶんに伸びます。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 つ増えただけです。
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 には存在しないが説明のために使う」仮の命令です)。
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 行になります。
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 文字も変えていません。だから絵が完全に一致します。
// 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 つを占めるということです。
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 回です。
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 本 | 4 | 16 float = 64 B | 65,536 B(64.0 KiB) | 6,400,000 B(6.10 MiB) |
| (b) 位置・スケール・色・回転角(デモ 3) | vec4 2 本 | 2 | 8 float = 32 B | 32,768 B(32.0 KiB) | 3,200,000 B(3.05 MiB) |
(c) gl_InstanceID から計算(デモ 4) | なし | 0 | 0 B | 0 B | 0 B |
バイト数は1 体あたりの float 数 × Float32Array.BYTES_PER_ELEMENT(4)× 体数で、KiB / MiB は 1024 で割ったものです。表の「使うスロット」はインスタンス属性のぶんだけで、 メッシュの位置・法線の 2 枠は別に要ります。
(b) を選ぶと、行列を組み立てる仕事が頂点シェーダーに移ります。デモ 3 の頂点シェーダーは、位置・スケール・自転角を受け取って回転行列を自分で作ります。向きについては送らずに済ませているものもあります — 体がリングのどこにいるかは中心座標からatanで求まるので、そのぶんは属性にしていません。
layout(location = 3) in vec4 a_instancePosScale; // xyz = 中心 / w = スケール
layout(location = 4) in vec4 a_instanceColorAngle; // rgb = 色(リニア) / a = 自転角 // リングのどこにいるかは中心座標から分かるので、向きは送らずに 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 は頂点シェーダーの組み込み入力としてこう宣言しています。
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 本もありません。
// 頂点シェーダーの 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 回だけです。
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 本のデモで共用していて、この章の差はすべて頂点シェーダー側にあります。
// 第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);#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;
}#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;
}#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);
}#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);
}#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);
}// 第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);
}// 第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.instanceMatrix | matrixBuffer+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.count | drawElementsInstancedの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のinstanceCount | InstancedMeshを使わずに素のインスタンス描画をするときの体数。既定値はInfinityで、そのときは属性の要素数× meshPerAttributeから決まる |
| — | gl_InstanceIDだけで配置するデモ 4 に相当する three.js の口はない。InstancedBufferGeometry+ShaderMaterialで自分でシェーダーを書けば、GLSL からは同じように読める |
手元で動かして、壊してみる
コードはsrc/lessons/31-instancing/にあります。この章の失敗は「エラーが出ないまま、絵だけが静かに壊れる」という顔をすることが 多いので、症状と原因を先に体験しておくと効きます。
main.tsのgl.vertexAttribDivisor(location, 1);(mat4の配線ループの中)を消す — 属性が頂点ごとに進むようになるので、1 体の中で頂点ごとに別の行列が使われます。形が壊れて、爆発したような塊になります。divisor は「インスタンスごと」の指定であって、インスタンス描画をするだけでは付きません(2 節)- 同じ行の
1を0に変える — 上と同じです。0 は「既定 = 頂点ごと」なので、書かないのと同じ意味になります - デモ 2 の
gl.drawElementsInstanced(...)をgl.drawElements(gl.TRIANGLES, bodyBuffers.indexCount, gl.UNSIGNED_SHORT, 0)に変える — 1 体だけが描かれます。drawElementsは仕様上「instanceを 0 にした 1 インスタンスぶん」なので、1,024 体ぶんのループが消えます。残った 1 体は⌊0 ÷ divisor⌋ = 0番目の行列で描かれます(2 節の aside) - デモ 2 の
INSTANCE_COUNTを0にしてdrawElementsInstancedに渡す(costの側は触らない)— 何も描かれず、コンソールにもエラーが出ません。3 節の等価コードのループが 1 度も回らないだけです 02-instanced.vertのlayout(location = 3)をlayout(location = 1)に変える —a_normalが location 1 にいるので、mat4が 1・2・3・4 を占めて衝突します。GLSL ES 3.00 §4.3.8.1 の「入力または行列入力の要素が同じ location に 2 つ以上割り当てられるのはエラー」に当たり、今度は静かに失敗せず、コンパイルかリンクで止まります02-instanced.vertのa_instanceMatrix[3].xyzをa_instanceMatrix[0].xyzに変える — 色の決め方が「第 4 列(平行移動)」から「第 1 列(回転の 1 列目)」に変わります。 列優先のメモリレイアウトが絵として見える実験です- デモ 3 で「divisor 2」を押す — 体の並びはそのままで、隣り合う 2 体が同じ色・同じ自転角になります。読み出し行の「インスタンスデータ」も 32,768 B から 24,576 B に減ります (位置とスケールの 16,384 B はそのまま、色と自転角が 16,384 B → 8,192 B)
main.tsのsetDivisorのgl.bindVertexArray(attributesVao);をgl.bindVertexArray(null);に書き換えて「divisor 2」を押す — divisor は既定の VAO(番号 0)のほうに書かれるので、attributesVao側の divisor は 1 のままです。JavaScript は divisor 2 のつもりで色と自転角を先頭 512 要素しか送らなくなるのに、GPU は 1,024 要素ぶん読み続けるので、後半 512 体の自転だけが止まります(色と自転角は同じ属性ですが、色は要素番号だけから決めていて時間で変わらないので、 止まっても見た目には現れません。位置とスケールは別の属性なので動き続けます)。同時に、divisor 2 で本来起きるはずの配色の変化(虹 3 周 → 1.5 周・2 節の図)が起きません。押して見えるのはこの 2 つ — 後半の自転が止まることと、配色が変わらないこと — だけです。ここでgl.bindVertexArray(attributesVao);を消すだけだと、この実験は成立しません — 描画ループが VAO を張りっぱなしにするので、 押した時点でattributesVaoがバインドされたままになっていて、divisor はちゃんと効いてしまいます(2 節の aside)- コンソールで
document.querySelector('#demo').getContext('webgl2').getParameter(0x8869)を評価する(0x8869がMAX_VERTEX_ATTRIBS)—あなたの環境の枠数が出ます。仕様の最小要求値は 16 なので、16 未満になることはありません。この章のメッシュ 2 枠 +mat44 枠 = 6 枠が、その予算のどれくらいを使っているかを確かめてください(4 節) ID_GRID_COLS/ID_GRID_ROWSを大きくして 100 万体にしてみる — 呼び出し回数は 1 回のままです。どこで破綻するかを見ておくと、 「呼び出し回数を減らしても頂点の仕事は残る」が体感できます。ID_SCALEも一緒に小さくしないと重なって団子になりますcreateSphere(0.5, 4, 2)をcreateTorus(0.12, 0.05, 12, 8)(デモ 1〜3 と同じ形)に変える — 1 体 48 インデックスが 576 インデックスになるので、流れる頂点が 12 倍の 5,760 万になります。 デモ 4 だけ形を粗くしてある理由がすぐ分かりますRING_SEGMENTS/RING_TUBESを増やして、その積であるINSTANCE_COUNT(デモ 1〜3 が共有しています)を 4,096・16,384 にしてみる — デモ 1 と 2 で読み出し行の rAF 間隔がどこから離れていくかを見てください。どこで離れるかは環境で違います。離れないあいだに言えるのは「その体数でも、まだフレームの予算に収まっている」までです — rAF 間隔は表示のリフレッシュ間隔で頭打ちになるので、同じ値が並んでいることから 固定費の比重は読めません(1 節の pitfall)
まとめ
- ドローコールは 1 回ごとに CPU の固定費を払う。1,024 体を素朴に描くと
uniformMatrix4fv1,024 回 +drawElements1,024 回 = 2,048 回。インスタンシングなら 3 回(バインド・更新・描画) - 減るのは呼び出し回数だけ。頂点の仕事も、アプリが WebGL に渡すバイト数も変わらない。この章の読み出し行がデモ 1 と 2 で同じ値を出しているのは、そこを取り違えないため
gl.vertexAttribDivisor(index, divisor)で属性の進み方を変える。0 = 頂点ごと / 1 = 1 インスタンスに 1 つ / n = n インスタンスで 1 つ。読まれる要素は⌊instance ÷ divisor⌋(ES 3.0.6 §2.9.3)。divisor は VAO の状態(Table 6.2)で、インスタンス化された属性はdrawArrays/drawElementsでは進まない(WebGL 2.0 仕様)drawArraysInstanced/drawElementsInstancedは、仕様上「1 インスタンスぶんの描画をinstanceCount回ループしたもの」。instanceCountが 0 なら何も描かれず、エラーも出ないmat4の属性はlocation を 4 つ食う(GLSL ES 3.00 §4.3.4 / ES 3.0.6 §2.12.5)。 CPU 側はvertexAttribPointerとvertexAttribDivisorを 4 回ずつ。予算はMAX_VERTEX_ATTRIBSで、最小保証は 16(Table 6.31)gl_InstanceID(highp int)を使えば、属性もバッファも 0 本で配置できる。第6章のgl_VertexIDと同じ「番号から値を計算する」発想。代わりにデータ駆動ではなくなる- 効くのは同じ形・同じマテリアルが、多数・小さくあるとき。形が違う・体数が 少ない・1 体が重い、のいずれかなら削る余地がない。描画順は指定できないので、 半透明は加算合成へ逃がす(第30章 4 節)
次章第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 の中だけで動かします。