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

Transform Feedback — GPU パーティクルと GPGPU

第29章では、状態をテクスチャに持たせました。第30章では、パーティクルを CPU で動かして、その位置を毎フレームbufferSubDataで送りました(第30章 6 節)。この章はその 2 つの合流点です — 状態をバッファに持たせ、頂点シェーダーで更新して、その出力をバッファへ書き戻す。 毎フレームの転送はゼロになります。

この仕掛けをトランスフォームフィードバック (transform feedback)と呼びます。名前のとおり「変換した結果を送り返す」もので、頂点シェーダーのout変数の値を、ラスタライズへ流す代わりに(あるいは流すのと同時に)バッファへ捕まえます。 描画のための機能ではなく、GPU に計算をさせるための機能です。GPU を描画以外の計算に使うことを一般にGPGPU (general-purpose computing on GPUs)と呼びます。WebGL2 で「状態を GPU 上に置いたまま更新し続ける」道は、大きく 2 本です — 第29章のテクスチャ方式と、この章のバッファ方式。7 節で 2 本を並べて比べます。

この章のデモ 3 本。ドラッグで回転、ホイールで寄り引きできます。「最小の transform feedback」は箱の中で跳ね返るだけの 64 個の点、「重力と寿命」は 5 万粒子の噴水、「流れ場」は 320 か所の噴き出し口から出た 30 万粒子がsinとcosで書いた流れに沿って筋を引きます。3 本とも違うのは更新パスの頂点シェーダー 1 本だけで、描き方は共通です。読み出し行に、状態バッファのバイト数と「毎フレームのバッファ転送 0 バイト」が出ます。

この章で学ぶこと:

1. 状態の置き場を、格子から並びへ — 第29章との対比

第29章の状態はテクスチャでした。テクスチャは格子です。セルの数は解像度で 決まり、どのセルも画面に貼り付いたまま動きません。動くのは「セルに入っている値」だけ。 ライフゲームの生死も、リアクション・ディフュージョンの濃度も、格子の上を波のように伝わって いくのであって、セル自身が移動するわけではありません。

この章の状態はバッファです。バッファはただの並びです。i 番目の粒子の位置はバッファの i 番目に入っていますが、その位置が空間のどこを指していてもよい。 「i 番目」は空間の場所と何の関係もなく、ただの背番号です。だから粒子は自由に動けます。

代わりに失うものがあります。隣が分からない。格子なら「隣のセル」は座標を 1 ずらすだけでした(第29章 4 節のtexelFetch)。並びには空間的な隣という概念がなく、i + 1 番の粒子は空間のどこに いるか分かりません。しかも頂点シェーダーは、自分に割り当てられた頂点の属性しか読めません。 つまり他の粒子の状態はそもそも見えない。この章の題材が「互いに独立に動く 粒子」に限られるのは、そのためです。

ここでは「置き場が違う」ことだけを押さえてください — 第29章はテクスチャ 2 枚をフラグメントシェーダーで更新し、この章はバッファ 2 本を頂点シェーダーで更新します。どちらを選ぶかの基準まで 含めた比較表は 7 節にまとめてあります(同じ表を 2 か所に置くと、片方だけ直したときに食い違うので、 ここでは出しません)。

第1章はこう書いていました — 各要素は互いの結果に依存できない。第29章はこの 制約を「パスを 2 回に分ける」ことで越えました(第29章 1 節)。この章の越え方は違います。そもそも互いの結果に依存しない題材だけを扱うのです。粒子は隣を知らなくても 動けるので、制約に触らない。transform feedback は「依存できるようにする道具」ではなく、依存しない計算を GPU の中に閉じ込めるための道具です。

閉じ込めると何が得か。第30章 6 節では、CPU で粒子を動かして、その結果を毎フレームbufferSubDataで送っていました。粒子を増やすと転送量がそのまま比例して増える形です。この章はその転送を消します。状態は最初から GPU 側のバッファにあって、更新も GPU がやり、描画もそのバッファを読むだけ。毎フレーム CPU から GPU へ送るのは、行列といくつかの uniform だけになります。具体的な数字は 6 節で数えます。

2. transformFeedbackVaryings はリンクの前に指定する

捕まえたい変数は、名前で指定します。指定するのはgl.transformFeedbackVaryings(program, names, bufferMode)。ここに WebGL2 の API の中でもかなり癖のある約束があります —この呼び出しはlinkProgramの前でなければならない。OpenGL ES 3.0.6 §2.15 が「捕まえる出力変数の集合はプログラムがリンクされたときに決まる (The set of output variables captured is determined when a program is linked.)」と書いているためです。§2.12.8 も念を押しています — 「TransformFeedbackVaryingsが設定した状態は、その program が次にリンクされるまでプログラムの実行に何の 影響も与えない」。リンク後に呼んでも、次のリンクまで効きません。

これが、この章でsrc/lib/shader.tsのlinkProgramをそのまま使えない理由です。あの関数はattachShaderを 2 回呼んだら、すぐlinkProgramを呼びます。差し込む隙間がありません。第23・24・29章と同じ判断で、共通ヘルパーにモードを 足すのではなく、章ローカルにもう 1 本書きます。要点はattach → varyings → link という順序ひとつだけなので、丸ごと隠さずに並べます。

src/lessons/32-transform-feedback/main.ts(抜粋)
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  // ここがこの章の入口。捕まえる変数の集合はリンク時に決まる(ES 3.0.6 §2.15)ので、
  // linkProgram の「前」に呼ばないと何も捕まらない
  gl.transformFeedbackVaryings(program, varyings, gl.INTERLEAVED_ATTRIBS);
  gl.linkProgram(program);

compileShaderは共通のsrc/lib/shader.tsのものをそのまま使い、新しく書いたのはこの順序の部分だけです。エラーチェックの形もlinkProgramと同じにしてあります。

渡す名前は、頂点シェーダーのout変数の名前です。この章は 3 本捕まえます。

src/lessons/32-transform-feedback/main.ts(抜粋)
// 更新パスの頂点シェーダーが出力する変数。名前も順番も
// .vert の out 宣言と 1 対 1 で対応していること(本文 2 節)
const CAPTURED_VARYINGS = ['v_position', 'v_velocity', 'v_age'];
src/lessons/32-transform-feedback/01-minimal.update.vert(抜粋)
// transformFeedbackVaryings に渡す名前・順番と、ここの宣言が一致していること。
// INTERLEAVED_ATTRIBS なので、この順にそのまま 1 本のバッファへ書かれる(本文 3 節)
out vec3 v_position;
out vec3 v_velocity;
out float v_age;

名前が合っていないとリンクが失敗します。ES 3.0.6 §2.12.8 が失敗条件を挙げていて、「varyings配列で指定された名前が頂点シェーダーの出力として宣言されていない」「配列の 2 つの要素が同じ出力変数を指している」が、その最初の 2 つです。名前を打ち間違えたら黙って無視されるのではなく、リンクエラーとして出てきます。 リンクが通ったあとで実際に何が何番で捕まるのかはgetTransformFeedbackVarying(program, index, …)で問い合わせられます(この章のデモでは使っていません)。

3. Transform Feedback オブジェクトと bindBufferBase

捕まえる変数が決まったら、次はどこへ書くかです。書き先のバッファは、 テクスチャユニットのような番号付きのバインドポイントに付けます。 その番号付きバインドポイントの束を持っているのがtransform feedback オブジェクトです。

既定のオブジェクトのままでも動きますが、この章は名前付きのオブジェクトを 1 個作って 使う方針で統一します。理由は 1 つ — 出力バッファのバインドをオブジェクトごと外せるからです。5 節で見るとおり、出力先に付いたままのバッファは他の用途に使えなくなるので、 「バインドを丸ごと切り離すスイッチ」が手元にあると話が単純になります。

src/lessons/32-transform-feedback/main.ts(抜粋)
  // 既定のオブジェクト(null)でも動くが、名前付きのものを 1 個作って使う。
  // 出力バッファのバインドを「オブジェクトごと外す」ことができるからで、
  // これが本文 5 節の二重バインドを避ける手になる
  const transformFeedback = gl.createTransformFeedback();

INTERLEAVED_ATTRIBS と SEPARATE_ATTRIBS

transformFeedbackVaryingsの第 3 引数が、書き方のモードです。ES 3.0.6 §2.15.2 の言い方で:

INTERLEAVED_ATTRIBS(この章)index 0 のバッファ 1 本位置 0速度 0年齢 0位置 1速度 1年齢 1…1 粒子 = 7 float = 28 バイト。これが頂点属性の stride にそのままなるSEPARATE_ATTRIBSindex 0位置 0位置 1…index 1速度 0速度 1…index 2年齢 0年齢 1…変数 1 本につきバッファ 1 本。番号は varyings に渡した順
2 つのモードの違いは「バッファを何本使うか」。この章はINTERLEAVED_ATTRIBSです。書き出された 1 本のバッファを、そのまま stride 付きの頂点属性として読み返せるので (第13章のインターリーブと同じ形)、次のフレームの入力にそのまま使えます。

どちらを選ぶかで、上限の当たり方も変わります。ES 3.0.6 の実装依存の値の状態テーブル (Table 6.34「Implementation Dependent Transform Feedback Limits」)が定める最小要求値(実装がこれ以上を保証しなければならない値)と、 参考としてある 1 つの環境での実測値を並べます。実測の列はこの環境の値でしかないので、手元で確かめるときはgl.getParameterを呼んでください。

定数ES 3.0.6 の最小要求値(Table 6.34)ある環境の実測値意味(Table 6.34 の説明)
MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS64128interleaved モードで 1 本のバッファへ書ける成分の数
MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS44捕まえられる別々の属性(出力)の本数
MAX_TRANSFORM_FEEDBACK_SEPARATE_COMPONENTS44separate モードで 1 本の属性(出力)が持てる成分の数

実測値は Chrome /RENDERER = ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)でgl.getParameterを読んだもの(2026-08-07)。3 つとも実装依存の値で、環境が変われば変わります。

数字の効き方が対照的です。SEPARATE_ATTRIBSは仕様の最小要求値でも、上の実測環境でも「4 本 × 4 成分」でした(この 1 環境では最小要求値のままだった、というだけです。他の環境で増えているかどうかは確かめていません)。 つまり分離モードでは1 つの変数につきvec41 本ぶんまでしか保証されないので、vec4を超える変数を分離モードで捕まえる設計は移植性がありません。一方INTERLEAVED_ATTRIBSは 1 本のバッファに合計 64 成分の保証があり(実測環境では 128)、こちらも「合計 64 成分まで」で頭打ちになる点はセットで覚えておく必要があります。

この章の状態は 位置vec3+ 速度vec3+ 年齢float= 7 成分です。どちらのモードでも収まりはしますが、interleaved を選びました。根拠は上の表の 2 行です — MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTSの行が「合計 7 ≤ 64」で余裕を持って通るのに対し、MAX_TRANSFORM_FEEDBACK_SEPARATE_COMPONENTSの行は「1 本あたり 4 成分」なので、vec3が 3 ≤ 4 のぎりぎりです。向きをクォータニオンvec4で持てばちょうど上限、mat4(16 成分)を 1 本で捕まえるのは最小要求値の範囲では そもそも不可能、という窮屈さがあります。しかもバッファが 3 本に増え、その 3 本ぶんの ping-pong と VAO の配線を書くことになります。

プリミティブモードの縛り

beginTransformFeedbackに渡すprimitiveModeはTRIANGLES・LINES・POINTSのいずれかで、ES 3.0.6 §2.15.2 が「primitiveModeは、transform feedback が有効かつ一時停止していないあいだにレンダリングできるプリミティブの 型を制限する」と定めています。具体的な条文は 2 つあって、どちらも守らないとINVALID_OPERATIONです。

2 つ目は見落としやすいところです。インデックス描画は transform feedback と組み合わせられない(第13章でdrawElementsを覚えたばかりなので、なおさら)。更新パスは必ずdrawArraysになります。この章は粒子 1 個 = 頂点 1 個なのでPOINTSで揃えていますが、それは題材の都合であって、三角形のメッシュを変形させて書き出すならTRIANGLESで挟むことになります。

なお、実際に何プリミティブぶん書き込まれたかはTRANSFORM_FEEDBACK_PRIMITIVES_WRITTENのクエリで数えられます(ES 3.0.6 §2.16)。バッファが足りなくて途中で切れていないかを 確かめるときに使えますが、結果を読むには GPU の完了を待つことになるので、この章のデモでは使っていません。

4. RASTERIZER_DISCARD — 絵を出さないパス

更新パスは計算だけがしたいので、ラスタライズから先は要りません。止めるスイッチがgl.enable(gl.RASTERIZER_DISCARD)です。ES 3.0.6 §3.1「Discarding Primitives Before Rasterization」はこう書いています — 「有効にすると、プリミティブはラスタライズ段の直前、ただし省略可能な transform feedback 段の後で破棄される」。順序がそのまま答えになっています。 捕獲は済ませて、絵にする手前で捨てる。

同じ節に、もう 1 つ大事な一文があります。

フラグメントシェーダーは要るのか

1 回も走らないのに、更新パスにもフラグメントシェーダーのファイルがあります。無駄に見えますが、省けません。ES 3.0.6 §2.12 のLinkProgramの失敗条件に「program が頂点シェーダーとフラグメントシェーダーの両方を含んでいない」が挙がっているためです。プログラムを作る条件であって、描画する条件ではないので、RASTERIZER_DISCARDとは無関係に要ります。

src/lessons/32-transform-feedback/update.frag
#version 300 es

// 第32章 更新パスのフラグメントシェーダー(3 本のデモで共通)。
//
// 中身は空でよい。更新パスは gl.RASTERIZER_DISCARD を有効にして走らせるので、
// プリミティブはラスタライズの直前で捨てられ、このシェーダーは 1 回も起動しない。
// それでもファイルが要るのは、リンクの失敗条件に
// 「プログラムが頂点シェーダーとフラグメントシェーダーの両方を含んでいない」
// が挙がっているため(OpenGL ES 3.0.6 §2.12 の LinkProgram)。本文 4 節。

precision highp float;

out vec4 fragColor;

void main() {
  fragColor = vec4(0.0);
}

ついでに、更新パスの頂点シェーダーがgl_Positionに値を書いているのも同じ「使わないけれど書く」の一種です。GLSL ES 3.00 §12.16 は「頂点シェーダーがgl_Positionに書かないことはエラーではない(振る舞いが未定義になるだけ)」と決着を つけていて、§7.1 も「頂点シェーダーの実行可能コードがgl_Positionに書かない場合、その値は頂点処理段のあと未定義」と書いています。どうせ捨てられるとはいえ、 未定義の値をパイプラインへ流す理由もないので、この章ではvec4(0.0, 0.0, 0.0, 1.0)を書いています。

最後に、2 パスに分ける判断について。RASTERIZER_DISCARDを有効にしなければ、捕獲しながら同時に描くこともできます(§2.15 の「変換された頂点は…バッファオブジェクトに格納されたあとで破棄してもよいし、 クリッピング段へ渡して処理を続けてもよい」がそれです)。1 パスで済むぶん速そうですが、 この章は2 パスに分けます。更新パスの頂点シェーダーは「次の状態」を出力する もので、描画パスの頂点シェーダーは「画面のどこに、どれだけの大きさで置くか」を出力するもので、 書きたいgl_Positionが違うからです。同時にやろうとすると 1 本のシェーダーに 2 つの仕事が同居して、 読みづらくなります。分けたぶんの代償は、GL に流れる頂点が粒子数 × 2 回ぶんになることです(頂点シェーダーが実際に何回起動するかは仕様の決めている量ではありません — 第31章 3 節)。

5. バッファの ping-pong — 入力と出力を同じバッファにできない

第29章 2 節と同じ構図がここにも現れます。読んでいるものへ同時に書くことはできない。テクスチャが 2 枚要ったように、バッファも 2 本要ります。ただし根拠の出どころが違います。第29章は「ES 3.0.6 §4.4.3 では未定義→ WebGL 1.0 仕様がINVALID_OPERATIONと定める → 適合性テストがそのエラーを要求する」という、3 つの文書をまたぐ推論でした。 こちらはWebGL 2.0 仕様に明文があります。

WebGL 2.0 仕様「Preventing undefined behavior with Transform Feedback」の条文はこうです — 「現在バインドされている transform feedback オブジェクトの、番号付きTRANSFORM_FEEDBACK_BUFFERバインドポイントと、WebGL API のそれ以外のバインドポイント(番号なしのTRANSFORM_FEEDBACK_BUFFERバインドポイントを除く)に同時にバインドされているバッファは、使うことができない。そのような二重にバインドされたバッファを使おうとするとINVALID_OPERATIONエラーで失敗する。transform feedback が有効かどうかに関係なくそうなる」。

仕様はその根拠として ES 3.0.6 §2.15.2 を挙げています。あちらの原文は「バッファオブジェクトは transform feedback と他の用途の両方にバインドしたり使ったりすべきではない。具体的には、 バッファオブジェクトが transform feedback のバインドポイントと GL 内の他の場所へ同時にバインドされている場合、そのバッファへの書き込みや 読み出しは未定義の値を生む」。WebGL 側はその未定義をエラーに置き換えた、 という関係です。ここでも「ES 3.0 の未定義を WebGL2 が上書きしている」形になっています。

もう 1 つ、WebGL 2.0 仕様は同じ節で「同じバッファが、有効な transform feedback の 2 つ以上の番号付きバインドポイントにバインドされている場合、beginTransformFeedbackがINVALID_OPERATIONを生成する」とも定めています。仕様の注記によれば、これが起こりうるのはSEPARATE_ATTRIBSモードのときだけで、由来は「D3D11 では 2 つの異なるストリームを同じバッファへ書くことが 許されていない」という実装側の制約です。

VAO も 2 本要る

バッファを 2 本にするだけでは足りません。どのバッファのどこを読むかという 配線は VAO の状態です(第3章・第13章)。読む側のバッファが毎フレーム入れ替わるなら、 その配線を持った VAO も 2 本要ります。

src/lessons/32-transform-feedback/main.ts(抜粋)
    const vao = gl.createVertexArray();
    gl.bindVertexArray(vao);
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.enableVertexAttribArray(0); // a_position
    gl.vertexAttribPointer(0, 3, gl.FLOAT, false, STATE_STRIDE, 0);
    gl.enableVertexAttribArray(1); // a_velocity
    gl.vertexAttribPointer(1, 3, gl.FLOAT, false, STATE_STRIDE, 3 * FLOAT_BYTES);
    gl.enableVertexAttribArray(2); // a_age
    gl.vertexAttribPointer(2, 1, gl.FLOAT, false, STATE_STRIDE, 6 * FLOAT_BYTES);
    // 種は ping-pong に加わらないので、2 本の VAO が同じバッファを指す
    gl.bindBuffer(gl.ARRAY_BUFFER, seedBuffer);
    gl.enableVertexAttribArray(3); // a_seed
    gl.vertexAttribPointer(3, 4, gl.FLOAT, false, SEED_STRIDE, 0);
    gl.bindVertexArray(null);

この繰り返しを 2 回まわして、状態バッファ 2 本とそれを読む VAO 2 本を作ります。stride と offset はFloat32Array.BYTES_PER_ELEMENTから組み立てます(第13章)。種のバッファだけは 2 本の VAO が同じものを指します— 更新パスが書き戻さない固定の値なので、ping-pong に加わる必要がないからです(6 節)。

状態バッファ A状態バッファ Bステップ 0読む書く更新パス(RASTERIZER_DISCARD)VAO A で読むbindBufferBase(…, 0, B)で書くステップ 1書く読む更新パス(RASTERIZER_DISCARD)bindBufferBase(…, 0, A)で書くVAO B で読む描画パスは、そのステップで「書く」側だったほうの VAO で読むその前に transform feedback オブジェクトを外しておくこと(下の pitfall)種のバッファは ping-pong に加わらない。2 本の VAO が同じものを指す
バッファの ping-pong。第29章の図とほぼ同じ形ですが、読む側に VAO が挟まるぶんだけ部品が増えています。データは 1 バイトも動かず、入れ替わるのは「どちらを読み、 どちらへ書くか」だけです。

「仕様が禁じている」と「手元の実装が怒ってくれる」は別のこと

上の pitfall には続きがあります。この章のデモからgl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null)の 1 行を実際にコメントアウトして走らせたところ、エラーは出ませんでした。gl.getError()はNO_ERRORのまま、コンソールに警告も出ず、絵も普通に描かれ続けます。

観測した環境は 1 つだけです —Chrome / ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)、 2026-08-07。同じ二重バインドでも、直前のbindTransformFeedbackの呼び方で結果が変わりました。

描くまでの手順(いずれも二重バインドの状態)この環境での getError()
bind(tf)→bindBufferBase→bind(null)→bind(tf)→drawArraysINVALID_OPERATION
bind(tf)→bindBufferBase→bind(tf)(同じものを 貼り直すだけ)→drawArraysNO_ERROR

bindBufferBaseで二重バインドになったことが、transform feedback オブジェクトを実際に切り替えるまで検証に反映されていないように見えます。適合性テストのほうは 1 回描くたびにbind(tf)→ 描画 →bind(null)を繰り返すので、必ず上の行の経路を通ります。デモから 1 行消しただけの場合は下の行の経路になり、 すり抜けてしまう、という理屈です。

ここで踏んではいけないのは「エラーが出ないなら、やってもいいのだろう」という一歩です。仕様は二重バインドされたバッファの使用そのものを禁じています。エラーが 返ってこなかったのは 1 つの実装の都合であって、二重バインドのまま描くのが正しくなるわけでは ありません。別の実装・別のドライバ・同じ Chrome の別バージョンでINVALID_OPERATIONが返るようになっても、それは仕様どおりの動作です。この手の「未定義」や「禁止」は、手元で怒られないことを確認しても安全にはならない— 第29章のテクスチャの ping-pong と同じ構えで、条文のほうを守ります。

なお、同じ規則を確実にエラーとして見る方法はあります。更新パスのbindBufferBaseに渡すバッファを、読んでいる側と同じものにすることです。手順は下の「壊してみる」に 書きました。

6. 数十万のパーティクルを動かす

1 フレームは 2 段です。まず更新パス。

src/lessons/32-transform-feedback/main.ts(抜粋)
    // --- パス 1: 更新(絵は出ない) ---------------------------------------------
    gl.enable(gl.RASTERIZER_DISCARD);
    gl.useProgram(updatePrograms[demoIndex]);
    gl.uniform1f(updateLocations[demoIndex].dt, dt);
    gl.uniform1f(updateLocations[demoIndex].time, time);

    // 読む側の VAO と、書く側のバッファ。この 2 つが同じだと WebGL は
    // INVALID_OPERATION を返す(本文 5 節)
    gl.bindVertexArray(system.slots[system.read].vao);
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
    gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, system.slots[write].buffer);

    // beginTransformFeedback のモードと drawArrays のモードは一致していること。
    // 一致していないと INVALID_OPERATION(ES 3.0.6 §2.15.2)。
    // drawElements はモードによらず使えないので、更新パスは必ず drawArrays
    gl.beginTransformFeedback(gl.POINTS);
    gl.drawArrays(gl.POINTS, 0, demo.count);
    gl.endTransformFeedback();
src/lessons/32-transform-feedback/main.ts(抜粋)
    // 書き終えたバッファは、次の描画パスで頂点属性として読む。transform feedback の
    // 出力先に付いたままだと二重バインドになって draw が INVALID_OPERATION になるので、
    // オブジェクトごと外す(本文 5 節の pitfall)
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
    // RASTERIZER_DISCARD は状態機械のスイッチ(第1章)。切り忘れると次の描画パスも、
    // それどころか gl.clear まで無視される(ES 3.0.6 §3.1)
    gl.disable(gl.RASTERIZER_DISCARD);

    system.read = write;

あとは役を入れ替えて、書き終えたばかりのバッファを頂点属性としてgl.POINTSで描くだけです。描き方は第30章のものをそのまま使います— 点の描き方(gl_PointSizeとgl_PointCoord)は第30章 5 節、加算合成gl.blendFunc(gl.ONE, gl.ONE)は第30章 2 節のままです。この章のシーンには不透明な物体が無く、加算合成は描画順に 依存しないので、深度テストは切っています(そのあたりの整理は第30章 4 節)。

1 つだけ注意を。加算合成はフラグメントシェーダーより後の固定機能段なので、 実際に足し合わされるのはlinearToSrgbを通したあとの値です(この話の本体は第30章 2 節の pitfall。伝達関数そのものは第27章 2 節)。厳密にリニア空間で足したければ float の FBO へ描いて最後に 1 回だけエンコードすることになり、それは第33章の HDR パイプラインの話になります。この章の絵はその近似だと思ってください。

更新シェーダーの中身

デモ 1 は、この章でいちばん短い更新パスです。位置に速度を足し、箱からはみ出した軸だけ 折り返す。それだけ。

src/lessons/32-transform-feedback/01-minimal.update.vert(抜粋)
  vec3 position = a_position + a_velocity * u_dt;

  // はみ出した軸だけ 1 になるマスク。分岐を書かずに 3 軸まとめて処理する
  vec3 outside = step(vec3(BOUND), abs(position));
  // 壁を鏡にして折り返す: x > B なら 2B - x、x < -B なら -2B - x
  position = mix(position, sign(position) * (2.0 * BOUND) - position, outside);

  v_position = position;
  v_velocity = mix(a_velocity, -a_velocity, outside);
  v_age = a_age + u_dt;

短いですが、これは第29章と同じ状態の積み上げです。位置は前フレームの位置に 増分を足した結果で、速度は壁に当たるたびに符号を変えてきた履歴の結果。第9章のデモ 3 で「速度を変えると軌道がワープする」宿題を出したときに要ると言った、あの積分がこれです。 違うのは、積み上げた値の置き場がテクセルではなくバッファの 28 バイトだということだけです。

乱数の代わりに、CPU が作った種を配る

デモ 2 と 3 は、寿命が尽きた粒子を初期状態へ戻します(リスポーン)。困るのは初期状態をばらつかせる乱数です。シェーダーに状態を持つ乱数生成器は置けず(第1章)、fract(sin(…))のような常套句も避けたい — GLSL ES 3.00 §4.5.1 は、精度が保証される演算の一覧を挙げたうえで 「それらの式として定義されていない組み込み関数の精度は未定義である。 たとえば三角関数やdeterminantがそれにあたる」と書いていて、実装によって別の値が出るからです。といって整数ハッシュを 他の章から複製してくると、この章の主題と関係のない道具が増えます。

この章の答えは粒子ごとの「種」を CPU で作って、固定の属性として配るです。 初期化のときに 1 回だけ 4 つの乱数をバッファへ入れておけば、あとはそこからsinとcosで決定的に方向や速さを組み立てられます(第9章の道具)。GPU の中に乱数を作る必要が そもそも無くなります。なお、こちらのsin/cosの使い方は精度の問題を踏みません — 値をそのまま角度から向きへ直すだけなので、末尾の桁の 実装差はそのまま小さな向きの差にしかならないからです。まずいのはfract(sin(x) * 43758.5)のように、末尾の桁の差を桁違いに増幅して使う書き方のほうです。

src/lessons/32-transform-feedback/02-gravity.update.vert(抜粋)
// 粒子ごとの固定の種。x = 方位角 / y = 円錐内の傾き / z = 速さ / w = 寿命。
// 更新パスでも書き戻さないので、ping-pong の外に固定のバッファ 1 本で置いてある
layout(location = 3) in vec4 a_seed;
src/lessons/32-transform-feedback/02-gravity.update.vert(抜粋)
vec3 spawnVelocity(vec4 seed) {
  float azimuth = seed.x * TAU;
  float tilt = CONE_ANGLE * sqrt(seed.y);
  float speed = mix(SPEED_MIN, SPEED_MAX, seed.z);
  return speed * vec3(sin(tilt) * cos(azimuth), cos(tilt), sin(tilt) * sin(azimuth));
}

種は更新パスが書き戻さないので、ping-pong の外に固定のバッファ 1 本で置きます。2 本の VAO が同じ種のバッファを指すのは、そのためです。同じ種からは毎回同じ初期状態が出るので、 リロードしても噴水の形は変わりません — 乱数の見た目は CPU 側の擬似乱数だけが決めています。

src/lessons/32-transform-feedback/02-gravity.update.vert(抜粋)
  float life = lifeOf(a_seed);
  if (age >= life) {
    age = mod(age, life);
    position = spawnPosition(a_seed);
    velocity = spawnVelocity(a_seed);
  }

modで年齢の余りを持ち越しているのは、寿命をまたいだ端数を捨てないためです。この形にしておくと、 状態バッファの初期値を「位置も速度も 0、年齢だけ大きな乱数」にしておくだけで、最初の 1 ステップで全粒子がこの枝を通り、そのときのmodがそのまま噴出のばらつきになります。CPU 側にリスポーンの式を書き写さずに済むので、同じ計算を 2 か所に書く事故が起きません。

デモ 3 の流れ場も、ノイズを使わずにsinとcosだけで書いています(第9章)。

src/lessons/32-transform-feedback/03-flow.update.vert(抜粋)
vec3 flow(vec3 p, float t) {
  vec3 q = p * FIELD_SCALE + t * FIELD_DRIFT;
  return vec3(sin(q.y) - cos(q.z), sin(q.z) - cos(q.x), sin(q.x) - cos(q.y));
}

この場は、各成分が自分の軸の座標を含んでいません。x成分はq.yとq.zだけ、y成分はq.zとq.xだけ、という具合です。したがって発散(∂vx/∂x + ∂vy/∂y + ∂vz/∂z)は恒等的に 0 で、粒子が 1 点へ吸い込まれて潰れることがありません。

発散 0 の流れは、放っておくと何も見えない

ところが、この「潰れない」という良い性質には裏があります。体積を保つ流れは、一様な分布を一様なまま運ぶ。半径 2.6 の球の中に粒子を一様に撒いて、この場で流し続けても、いつまでも一様なままです。 流れの形を見せてくれるはずの粒子が、ただの一様な球のもやにしかなりません。 実際そうなりました。

数で見るとこうです。半径 2.6 の球を 1 辺 0.2 ワールド単位のセルに切り、球の内側にある 9,328 個のセルに何個の粒子が入るかを数えます(分母がセルの数、分子がそのセルの粒子数、全部で 30 万個)。粒子が一様なら、どのセルもだいたい同じ数になるはずです。場は同じままで、撒き方だけを入れ替えた比較です。

撒き方1 セルあたりの平均ばらつき(変動係数)空のセル
球の中へ一様に撒いてリスポーンさせる31.2 個0.760.6%
噴き出し口を 320 か所に絞る(この章のデモ)31.8 個2.0146.0%

上の行が「一様なもや」です。空のセルがほとんど無く、粒子が空間を埋め尽くしています。 変動係数 0.76 はゼロではありません — 同じセル分けの 9,328 セルへ、シミュレーションを通さずに 30 万個を純粋な一様乱数で置くと 0.21 になるので、その 3.6 倍のむらは乗っています(球の外へ出た粒子を撒き直すリスポーンが作る、 動径方向の偏りです)。それでも噴き出し口版の 2.01 には遠く及ばず、筋と呼べる構造はできていません。

そこで、撒き方のほうを偏らせます。流れが密度の構造を作ってくれないなら、 最初から構造を持った撒き方をするしかない。この章のデモは噴き出し口を 320 か所に絞りました。どの噴き出し口から出るかは種a_seed.zが決めるので、1 つの粒子はいつも同じ 1 か所へ戻ります。同じ噴き出し口から出た粒子は同じ道をたどるので、年齢の違いがそのまま道に沿った位置の違いになり、1 本の筋(流跡線)に並びます。320 本の筋が場の形をなぞる、というのが画面に見えているものです。

src/lessons/32-transform-feedback/03-flow.update.vert(抜粋)
vec3 spawnPosition(vec4 seed) {
  // seed.z がどの噴き出し口かを決める。粒子はいつも同じ 1 か所へ戻る
  float k = min(floor(seed.z * EMITTER_COUNT), EMITTER_COUNT - 1.0);
  // 向きは球面らせん: 極角は k で等分し、方位角は黄金角ずつ回す
  float cosPolar = (k + 0.5) / EMITTER_COUNT * 2.0 - 1.0;
  float sinPolar = sqrt(max(0.0, 1.0 - cosPolar * cosPolar));
  float azimuth = k * GOLDEN_ANGLE;
  // 半径も k で振って、球面ではなく球の中いっぱいに噴き出し口を散らす。
  // fract(k * 黄金比) は 0〜1 を偏りなく埋める並び、3 乗根は球の中で均すため
  float radius = EMITTER_RADIUS * (0.25 + 0.75 * pow(fract(k * GOLDEN_FRACTION), 1.0 / 3.0));
  vec3 center = radius * vec3(sinPolar * cos(azimuth), cosPolar, sinPolar * sin(azimuth));

噴き出し口を完全な点にすると筋が線 1 本になって細すぎるので、EMITTER_SPREADの小さな球にばらしています。ここで種の残り 2 成分(a_seed.x/a_seed.y)を使っている、という配り方です。

もう 1 つ、絵として効いているのが粒子 1 個の暗さです。30 万個を加算合成で 重ねると、1 個の色が明るいままでは筋の芯から順に真っ白へ張り付いて、せっかくの構造が白い塊に なります。この章のデモはmain.tsのcolorFastをリニア値で(0.026, 0.038, 0.045)まで落としてあり、それで光っている画素のうち真っ白に飽和しているのは 1.3%に収まっています。密度を上げるとき(粒子を増やす・噴き出し口を減らす・カメラを寄せる)は、 同じだけ 1 個を暗くしないと絵が壊れます。

以上の数値はすべてdt = 1/60秒で 600 ステップ(10 秒ぶん)進めたあとの状態を数えたものです。飽和の割合は、描画バッファ 694 × 462、カメラ半径 6.8 の 1 枚について「背景より明るい画素」を分母、「3 成分とも 1.0 に達した画素」を分子として数えました。

dt をどう決めるか

src/lessons/32-transform-feedback/main.ts(抜粋)
    // rAF の実測差分を使う。ただし上限で頭打ちにする(本文 6 節)
    const dt = lastTimestamp === 0 ? 0 : Math.min(delta / 1000, MAX_DT);
    lastTimestamp = timestamp;

この章はrAF の実測差分を使い、上限で頭打ちにします(MAX_DT = 1/30秒)。固定ステップにしなかったのは、表示のリフレッシュレートで動きの速さが変わってしまう からです(120 Hz の画面で 2 倍速になる)。逆に、実測差分をそのまま使わないのは、タブが裏に 回っているあいだ rAF が止まり、戻ってきた瞬間に巨大な差分が来るからです。オイラー積分に 大きいdtを入れると粒子が一気に飛び、噴水は空へ抜け、流れ場は球からはみ出して総リスポーンになります。 同じ「復帰直後の巨大な差分」の問題に、第23章のmain.tsは別の手当て(平均から外す)をしていました。あちらは計測の話、こちらは積分の話なので、手当ての形が違います。

数えられる量

この章の見せ場は毎フレームのバッファ転送が 0 バイトになることなので、 そこを数えます。数えているのはアプリがbufferData/bufferSubDataに渡したバイト数で、この章のframe()はそのどちらも 1 回も呼びません(0 回)。行列と uniform は毎フレーム送っているので、「CPU が GPU へ何も渡していない」わけではありません — 消えたのは粒子数に比例する転送のほうです。状態は 1 粒子あたり 位置 3 + 速度 3 + 年齢 1 = 7 float、Float32Array.BYTES_PER_ELEMENTが 4 バイトなので 28 バイト。これが ping-pong で 2 本ぶんです。 比較のために、第30章 6 節の測り方(粒子数 × 1 粒子あたりのバイト数)で「CPU で更新して毎フレーム送るとしたら何バイトになるか」も並べます。この列は実測ではなく、同じ状態を CPU 側に置いた場合の計算値です。毎秒の値は 60 フレーム換算、KiB/MiBは 1024 の冪(第30章 6 節と同じ表記)。なお第30章 6 節が実際に送っていたのは位置 3 + 寿命 1 = 16 B/粒子で、下の「位置だけ送る」列(位置 3 = 12 B/粒子)とは 1 粒子あたりのバイト数が違います。

粒子数状態 1 本状態 2 本(実際に確保する量)種(初期化時に 1 回だけ)CPU 更新なら: 位置だけ送るCPU 更新なら: 状態を全部送る
641,792 B3,584 B1,024 B768 B(45.0 KiB/秒)1,792 B(105.0 KiB/秒)
50,0001,400,000 B(1.3 MiB)2,800,000 B(2.7 MiB)800,000 B(0.8 MiB)600,000 B(34.3 MiB/秒)1,400,000 B(80.1 MiB/秒)
300,0008,400,000 B(8.0 MiB)16,800,000 B(16.0 MiB)4,800,000 B(4.6 MiB)3,600,000 B(206.0 MiB/秒)8,400,000 B(480.7 MiB/秒)

30 万粒子なら、位置だけを送る形でも毎秒 206 MiB、状態をまるごと送るなら毎秒 480.7 MiB を CPU から流し続けることになります。この章はそれが0 バイトです(バッファへ渡した バイト数として。行列と uniform の約 172 B は別に送っています)。代わりに GPU 側で状態バッファ 2 本ぶんの 16.0 MiB を確保しっぱなしにする、という取引になっています (種の 4.6 MiB を足した 20.6 MiB が、読み出し行に出る合計です)。

もう 1 つ、粒子数に比例しない量も数えておきます。1 フレームで呼ぶ WebGL の 関数は 28 回(更新パス 12 回 + 描画パス 16 回)で、粒子が 64 個でも 30 万個でも 同じです。うちドローコールは 2 回、bufferSubDataは 0 回。数え方はmain.tsのframe()の本体に現れるgl.<メソッド>(の個数で、コメント行は除いています。「ドローコールという単位」がなぜコストとして効くのかは第31章 1 節を見てください。

ここで数えているのはバイト数と呼び出し回数だけで、GPU 時間ではありません。読み出し行に出る rAF 間隔も CPU 側の計測です。GPU 時間の測り方(タイマークエリ)と CPU/GPU バウンドの見分けは第35章です。

7. WebGL2 の GPGPU — テクスチャ方式とバッファ方式

第29章とこの章で、WebGL2 で GPGPU をやる 2 本の道が出そろいました。並べて比べます。

テクスチャ方式(第29章)バッファ方式(この章)
状態の置き場テクスチャ 2 枚(FBO 2 つ)バッファ 2 本(VAO も 2 本)
更新するシェーダーフラグメント頂点
要素の数の決まり方描画先の解像度(幅 × 高さ)drawArraysに渡す頂点数
1 要素の居場所格子のマス目に固定どこでもよい。位置そのものが状態
隣を読むタダ。texelFetchの座標をずらすだけ(第29章 4 節)できない。頂点シェーダーは自分の頂点の属性しか読めない
任意の場所を読むできる。座標を計算して読めばよい状態バッファからはできない(WebGL2 にsamplerBufferは無い)。ただし別に用意したテクスチャなら頂点シェーダーからも読める
出力の本数MRT で複数のカラーアタッチメントへ捕まえる変数を複数(3 節)
そのまま描けるかいいえ。結果を絵にする表示パスが要るはい。変換パスは要らない— 書き出したバッファがそのまま頂点属性になる(この章のデモも描画パスは別に持っているが、 それは「更新と描画を分ける」判断のほう・4 節)
向く題材場(拡散・波・ライフゲーム)粒子(互いに独立)

「任意の場所を読む」の行は少し注意が要ります。頂点シェーダーからテクスチャは読めます— ES 3.0.6 の Table 6.31 はMAX_VERTEX_TEXTURE_IMAGE_UNITSの最小要求値を 16 と定めています。読めないのはバッファをランダムアクセスで読むことのほうです。WebGL2 にはバッファをテクスチャとして読む仕組み(TEXTURE_BUFFER)が無く、GLSL ES 3.00 でもsamplerBufferは「将来のために予約された語」の一覧に載っているだけで使えません。つまり「粒子 i が粒子 j を見る」は、この章の道具立てでは書けません。書きたければ状態をテクスチャにも書き出して (= テクスチャ方式へ寄せて)から読むことになります。

WebGL2 に compute shader は無い

ここまで読んで「これは compute shader の代用だ」と思ったなら、そのとおりです。そして代用でしかありません。WebGL 2.0 は OpenGL ES 3.0 に対応する API で、 ES 3.0.6 仕様にも WebGL 2.0 仕様にもcompute shaderという語は 1 度も出てきません。だから GPU に汎用の計算をさせたければ、描画パイプラインの どこかを借りるしかない。フラグメント段を借りたのが第29章、頂点段を借りたのがこの章です。 借り物であることの代償が、上の表のあちこちに出ています — 出力の形が固定されている、 書き先を自由に選べない、隣を読めない。本物の compute shader(任意のバッファへの読み書き、 共有メモリ、ワークグループ)が使えるのは WebGPU で、そちらへの道筋は第36章で触れます。

この章では実装していませんが、transform feedback の出力をインスタンス属性として使う道もあります。書き出した位置のバッファにvertexAttribDivisorを掛ければ(第31章 2 節)、粒子 1 個につき点 1 つではなく、粒子 1 個につきメッシュ 1 体を並べられます。GPU で動かした位置で、GPU で大量描画する、という組み合わせです。

ここまでの GPGPU の系譜

第20章で FBO を手に入れて「描いた結果をテクスチャとして残せる」ようになり、第29章でそれを 2 枚使って前フレームを読む ping-pong を組み、状態を GPU 上に置き続けられるようになりました。 この章はその「状態の置き場」をテクスチャからバッファへ移し、更新するシェーダーをフラグメントから 頂点へ移しました。3 つとも、やっていることの骨格は同じです —GPU が書いたものを、次のパスの GPU が読む。CPU はその段取りを並べるだけで、データそのものには触りません。第1章で「CPU と GPU のあいだの転送は遅い」と言ったことへの、いちばん直接的な答えがこれです。

コード全文

src/lessons/32-transform-feedback/main.ts
// 第32章: Transform Feedback — GPU パーティクルと GPGPU
//
// 1 フレームでやることは 2 パス。
//   ① 更新パス: gl.RASTERIZER_DISCARD + transform feedback。頂点シェーダーの出力を
//      そのままバッファへ書き戻す。絵は 1 ピクセルも出ない
//   ② 描画パス: 書き終えたバッファを頂点属性として読み、gl.POINTS で描く
// 状態バッファは 2 本で、毎フレーム役を入れ替える(ping-pong・本文 5 節)。
// 属性の配線は VAO の状態なので、読む側のバッファが変われば VAO も変わる。だから VAO も 2 本。
//
// 3 本のデモが違うのは「更新パスの頂点シェーダー」だけで、描画パスは共通の 1 本。
// この章の主題が「状態をどう更新するか」だけであることが、ファイル構成にそのまま出ている。

import { mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { compileShader, linkProgram } from '../../lib/shader';
import minimalUpdateSource from './01-minimal.update.vert?raw';
import gravityUpdateSource from './02-gravity.update.vert?raw';
import flowUpdateSource from './03-flow.update.vert?raw';
import colorChunkSource from './color.glsl?raw';
import drawFragmentTemplate from './draw.frag?raw';
import drawVertexSource from './draw.vert?raw';
import updateFragmentSource from './update.frag?raw';

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

// ---------------------------------------------------------------------------
// リンク前に捕まえる変数を指定するための、この章ローカルのリンク関数
// ---------------------------------------------------------------------------

// src/lib/shader.ts の linkProgram は attach したらすぐ linkProgram を呼ぶので、
// transformFeedbackVaryings を差し込む隙間が無い。共通ヘルパーにモードを足すのではなく
// (第23・24・29章と同じ判断)、章ローカルにもう 1 本書く。compileShader は共通のものを使う。
// 要点は「attach → varyings → link」という順序ひとつだけ(本文 2 節)
function linkProgramWithVaryings(
  gl: WebGL2RenderingContext,
  vertexShader: WebGLShader,
  fragmentShader: WebGLShader,
  varyings: string[],
): WebGLProgram {
  const program = gl.createProgram();
  if (!program) {
    throw new Error('プログラムオブジェクトを作成できませんでした');
  }
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  // ここがこの章の入口。捕まえる変数の集合はリンク時に決まる(ES 3.0.6 §2.15)ので、
  // linkProgram の「前」に呼ばないと何も捕まらない
  gl.transformFeedbackVaryings(program, varyings, gl.INTERLEAVED_ATTRIBS);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    const log = gl.getProgramInfoLog(program);
    gl.deleteProgram(program);
    throw new Error(`プログラムのリンクに失敗しました:\n${log ?? '(ログなし)'}`);
  }
  gl.deleteShader(vertexShader);
  gl.deleteShader(fragmentShader);
  return program;
}

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

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

const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
const TARGET: ReadonlyVec3 = vec3.fromValues(0, 0, 0);
// canvas の CSS 背景色と同じ
const BACKGROUND_COLOR: ReadonlyVec3 = vec3.fromValues(0.06, 0.07, 0.09);

const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;

// 1 粒子の状態 = 位置 3 + 速度 3 + 年齢 1 = 7 float。この並びが
// 頂点属性の並びであり、transform feedback が書き出す並びでもある
const STATE_FLOATS = 3 + 3 + 1;
const STATE_STRIDE = STATE_FLOATS * FLOAT_BYTES;
// 粒子ごとの固定の種 = 4 float。更新パスが書き戻さないので ping-pong の外に置く
const SEED_FLOATS = 4;
const SEED_STRIDE = SEED_FLOATS * FLOAT_BYTES;

// 更新パスの頂点シェーダーが出力する変数。名前も順番も
// .vert の out 宣言と 1 対 1 で対応していること(本文 2 節)
const CAPTURED_VARYINGS = ['v_position', 'v_velocity', 'v_age'];

// rAF の実測差分をそのまま使うが、この値で頭打ちにする。タブが裏から戻った直後の
// 巨大な差分をオイラー積分に入れると、粒子が一気に飛ぶ(第23章 main.ts も同じ問題に
// 別の手当てをしている)。本文 6 節
const MAX_DT = 1 / 30;

// ---------------------------------------------------------------------------
// デモ
// ---------------------------------------------------------------------------

interface Demo {
  id: string;
  label: string;
  count: number;
  updateSource: string;
  /** 状態バッファの初期値。種と同じ乱数列から作る */
  initialState: (count: number, random: () => number) => Float32Array;
  /** 点の直径(ワールド単位) */
  particleSize: number;
  /** この速さで u_colorFast になる */
  speedScale: number;
  colorSlow: ReadonlyVec3;
  colorFast: ReadonlyVec3;
  camera: { theta: number; phi: number; radius: number };
  note: string;
}

/** 見た目を毎回同じにするための、ごく小さい線形合同法の擬似乱数(第25章のハッシュとは別物) */
function createRandom(seed: number): () => number {
  let state = seed >>> 0;
  return () => {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  };
}

/**
 * 位置も速度も 0 にして、年齢だけ大きな乱数にする初期状態。
 * 寿命を持つデモ(2・3)はこれで最初のステップに全粒子がリスポーンの枝を通り、
 * そのときの mod がそのままばらつきになる。CPU 側でリスポーンの式を書き写さずに済む
 */
function agedInitialState(count: number, random: () => number): Float32Array {
  const state = new Float32Array(count * STATE_FLOATS);
  for (let i = 0; i < count; i++) {
    state[i * STATE_FLOATS + 6] = random() * 1000;
  }
  return state;
}

const demos: Demo[] = [
  {
    id: 'minimal',
    label: '最小の transform feedback',
    count: 64,
    updateSource: minimalUpdateSource,
    // このデモの更新パスにはリスポーンが無いので、初期位置と初期速度は CPU が置く
    initialState: (count, random) => {
      const state = new Float32Array(count * STATE_FLOATS);
      for (let i = 0; i < count; i++) {
        const at = i * STATE_FLOATS;
        // 箱(半分の大きさ 1.5)の内側。01-minimal.update.vert の BOUND と揃えている
        state[at] = (random() * 2 - 1) * 1.3;
        state[at + 1] = (random() * 2 - 1) * 1.3;
        state[at + 2] = (random() * 2 - 1) * 1.3;
        const azimuth = random() * Math.PI * 2;
        const polar = Math.acos(random() * 2 - 1);
        const speed = 0.5 + random() * 0.9;
        state[at + 3] = speed * Math.sin(polar) * Math.cos(azimuth);
        state[at + 4] = speed * Math.cos(polar);
        state[at + 5] = speed * Math.sin(polar) * Math.sin(azimuth);
      }
      return state;
    },
    particleSize: 0.14,
    speedScale: 1.4,
    colorSlow: vec3.fromValues(0.12, 0.3, 0.85),
    colorFast: vec3.fromValues(1.0, 0.62, 0.22),
    camera: { theta: 0.7, phi: 0.35, radius: 5.0 },
    note: '位置と速度だけを持つ 64 個の点。箱の壁で折り返す',
  },
  {
    id: 'gravity',
    label: '重力と寿命',
    count: 50000,
    updateSource: gravityUpdateSource,
    initialState: agedInitialState,
    particleSize: 0.014,
    speedScale: 3.8,
    colorSlow: vec3.fromValues(0.5, 0.09, 0.02),
    colorFast: vec3.fromValues(1.0, 0.72, 0.3),
    camera: { theta: 0.55, phi: 0.16, radius: 5.4 },
    note: '重力で落ち、床で跳ね、寿命が尽きたら噴き出し口へ戻る',
  },
  {
    id: 'flow',
    label: '流れ場',
    count: 300000,
    updateSource: flowUpdateSource,
    initialState: agedInitialState,
    particleSize: 0.016,
    speedScale: 1.5,
    // 30 万個を加算合成で重ねるので、粒子 1 個の色はうんと暗くする。ここを明るくすると
    // 筋の芯から順に真っ白へ張り付いて、流れの形が読めなくなる(第30章 2 節の加算合成)
    colorSlow: vec3.fromValues(0.004, 0.008, 0.019),
    colorFast: vec3.fromValues(0.026, 0.038, 0.045),
    camera: { theta: 0.9, phi: 0.3, radius: 6.8 },
    note: '320 か所の噴き出し口から出た 30 万個の粒子が、sin と cos だけの流れ場に沿って筋を引く',
  },
];

// ---------------------------------------------------------------------------
// 粒子系(バッファ 2 本 + VAO 2 本 + 種のバッファ 1 本)
// ---------------------------------------------------------------------------

interface StateSlot {
  buffer: WebGLBuffer;
  vao: WebGLVertexArrayObject;
}

interface ParticleSystem {
  demo: Demo;
  seedBuffer: WebGLBuffer;
  slots: [StateSlot, StateSlot];
  /** いま「読む側」になっているスロットの番号 */
  read: 0 | 1;
  stateBytes: number;
  seedBytes: number;
}

function createParticleSystem(gl: WebGL2RenderingContext, demo: Demo): ParticleSystem {
  const random = createRandom(1337);

  // --- 粒子ごとの固定の種。初期化のときに 1 回だけ転送する ---------------------
  const seeds = new Float32Array(demo.count * SEED_FLOATS);
  for (let i = 0; i < seeds.length; i++) {
    seeds[i] = random();
  }
  const seedBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, seedBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, seeds, gl.STATIC_DRAW);

  // --- 状態バッファ 2 本と、そこから読む VAO 2 本 -----------------------------
  const initial = demo.initialState(demo.count, random);
  const slots: StateSlot[] = [];
  for (let i = 0; i < 2; i++) {
    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    // 2 本とも同じ初期値で埋める。DYNAMIC_COPY は「GPU が書いて GPU が読む」の意思表示
    // (ヒントなので絵は変わらない)
    gl.bufferData(gl.ARRAY_BUFFER, initial, gl.DYNAMIC_COPY);

    const vao = gl.createVertexArray();
    gl.bindVertexArray(vao);
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.enableVertexAttribArray(0); // a_position
    gl.vertexAttribPointer(0, 3, gl.FLOAT, false, STATE_STRIDE, 0);
    gl.enableVertexAttribArray(1); // a_velocity
    gl.vertexAttribPointer(1, 3, gl.FLOAT, false, STATE_STRIDE, 3 * FLOAT_BYTES);
    gl.enableVertexAttribArray(2); // a_age
    gl.vertexAttribPointer(2, 1, gl.FLOAT, false, STATE_STRIDE, 6 * FLOAT_BYTES);
    // 種は ping-pong に加わらないので、2 本の VAO が同じバッファを指す
    gl.bindBuffer(gl.ARRAY_BUFFER, seedBuffer);
    gl.enableVertexAttribArray(3); // a_seed
    gl.vertexAttribPointer(3, 4, gl.FLOAT, false, SEED_STRIDE, 0);
    gl.bindVertexArray(null);

    slots.push({ buffer, vao });
  }
  gl.bindBuffer(gl.ARRAY_BUFFER, null);

  return {
    demo,
    seedBuffer,
    slots: [slots[0], slots[1]],
    read: 0,
    stateBytes: initial.byteLength,
    seedBytes: seeds.byteLength,
  };
}

function deleteParticleSystem(gl: WebGL2RenderingContext, system: ParticleSystem): void {
  for (const slot of system.slots) {
    gl.deleteVertexArray(slot.vao);
    gl.deleteBuffer(slot.buffer);
  }
  gl.deleteBuffer(system.seedBuffer);
}

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  // --- プログラム ---------------------------------------------------------------
  // 更新パスはデモごと。描画パスは 1 本で全デモ共通
  const updatePrograms = demos.map((demo) =>
    linkProgramWithVaryings(
      gl,
      compileShader(gl, gl.VERTEX_SHADER, demo.updateSource),
      compileShader(gl, gl.FRAGMENT_SHADER, updateFragmentSource),
      CAPTURED_VARYINGS,
    ),
  );
  const drawProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, drawVertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, resolveIncludes(drawFragmentTemplate)),
  );

  const updateLocations = updatePrograms.map((program) => ({
    dt: gl.getUniformLocation(program, 'u_dt'),
    // 流れ場のデモ以外では使っていないので null になる(第4章)。null への送信は無視される
    time: gl.getUniformLocation(program, 'u_time'),
  }));
  const drawLocations = {
    view: gl.getUniformLocation(drawProgram, 'u_view'),
    projection: gl.getUniformLocation(drawProgram, 'u_projection'),
    particleSize: gl.getUniformLocation(drawProgram, 'u_particleSize'),
    pointScale: gl.getUniformLocation(drawProgram, 'u_pointScale'),
    colorSlow: gl.getUniformLocation(drawProgram, 'u_colorSlow'),
    colorFast: gl.getUniformLocation(drawProgram, 'u_colorFast'),
    speedScale: gl.getUniformLocation(drawProgram, 'u_speedScale'),
  };

  // --- transform feedback オブジェクト -------------------------------------------
  // 既定のオブジェクト(null)でも動くが、名前付きのものを 1 個作って使う。
  // 出力バッファのバインドを「オブジェクトごと外す」ことができるからで、
  // これが本文 5 節の二重バインドを避ける手になる
  const transformFeedback = gl.createTransformFeedback();

  // 深度は使わない。この章のシーンには不透明な物体が無く、加算合成は描画順に
  // 依存しないので、深度テストで並べ替える相手がいない(第30章 4 節)
  gl.disable(gl.DEPTH_TEST);

  let system = createParticleSystem(gl, demos[0]);
  let demoIndex = 0;

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

  const orbit = { theta: 0.7, phi: 0.35, radius: 5.0 };
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 2.5;
  const MAX_RADIUS = 20;

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

  // --- 切替ボタン(第13章の作りをそのまま) ---------------------------------------

  function selectDemo(index: number): void {
    demoIndex = index;
    const demo = demos[index];
    // これから消すバッファが transform feedback の出力先に残らないようにしてから消す。
    // 消えたバッファのバインドが残っていると、次のフレームの二重バインドの判定が
    // どうなるかを仕様で追い切れないので、こちらから外しておく
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
    gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
    // 状態バッファは 30 万粒子で 8.4 MiB × 2 本になるので、切り替えのたびに
    // 古いものを消す。解放の一般論は第35章
    deleteParticleSystem(gl, system);
    system = createParticleSystem(gl, demo);
    // rAF 間隔の標本も捨てる。前のデモのフレームが混ざった平均を出さないため
    resetFrameSamples();
    orbit.theta = demo.camera.theta;
    orbit.phi = demo.camera.phi;
    orbit.radius = demo.camera.radius;
  }

  for (const [index, demo] of demos.entries()) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = demo.label;
    button.setAttribute('aria-pressed', index === 0 ? 'true' : 'false');
    button.addEventListener('click', () => {
      selectDemo(index);
      for (const other of controls.querySelectorAll('button')) {
        other.setAttribute('aria-pressed', 'false');
      }
      button.setAttribute('aria-pressed', 'true');
    });
    controls.append(button);
  }

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

  const eye = vec3.create();
  const view = mat4.create();
  const projection = mat4.create();

  // --- リサイズ -----------------------------------------------------------------

  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;
      // viewport も必ずセットで切り替える。既定値は canvas の初期サイズ(300×150)のままなので、
      // これを忘れると描画バッファの左下 300×150 だけに絵が出る
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

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

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

  // デモを切り替えたら、それまでのフレームの標本は捨てる。捨てないと切り替え直後の
  // 平均に前のデモのフレームが混ざり、読み出し行の数値が「どのデモを測った値か」を
  // 言えなくなる。空のあいだは updateReadout が「—」を出す
  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 demo = demos[demoIndex];
    const ms = frameTimes.length === 0 ? '—' : `${averageFrameMs().toFixed(1)} ms`;
    const mebibytes = (system.stateBytes * 2 + system.seedBytes) / (1024 * 1024);
    readout.textContent =
      `${demo.note} / 粒子 ${demo.count.toLocaleString('en-US')} 個 / ` +
      `状態 ${STATE_FLOATS} float = ${STATE_STRIDE} B/粒子 × 2 本 + 種 ${SEED_STRIDE} B/粒子 ` +
      `= ${mebibytes.toFixed(1)} MiB / 捕まえた変数 ${CAPTURED_VARYINGS.join(', ')} ` +
      `(INTERLEAVED_ATTRIBS)/ 毎フレームのバッファ転送 0 バイト(bufferSubData 0 回。` +
      `行列と uniform は別に送っています)/ ` +
      `ドローコール 2 回(更新 1 + 描画 1)/ ` +
      `rAF 間隔 ${ms}(CPU 側の計測。GPU 時間ではありません・第35章)`;
  }

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

  let lastTimestamp = 0;

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

    const delta = timestamp - lastTimestamp;
    if (lastTimestamp !== 0 && delta < 200) {
      frameTimes.push(delta);
      if (frameTimes.length > FRAME_SAMPLES) frameTimes.shift();
    }
    // rAF の実測差分を使う。ただし上限で頭打ちにする(本文 6 節)
    const dt = lastTimestamp === 0 ? 0 : Math.min(delta / 1000, MAX_DT);
    lastTimestamp = timestamp;

    const demo = demos[demoIndex];
    const write = system.read === 0 ? 1 : 0;

    // --- パス 1: 更新(絵は出ない) ---------------------------------------------
    gl.enable(gl.RASTERIZER_DISCARD);
    gl.useProgram(updatePrograms[demoIndex]);
    gl.uniform1f(updateLocations[demoIndex].dt, dt);
    gl.uniform1f(updateLocations[demoIndex].time, time);

    // 読む側の VAO と、書く側のバッファ。この 2 つが同じだと WebGL は
    // INVALID_OPERATION を返す(本文 5 節)
    gl.bindVertexArray(system.slots[system.read].vao);
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, transformFeedback);
    gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, system.slots[write].buffer);

    // beginTransformFeedback のモードと drawArrays のモードは一致していること。
    // 一致していないと INVALID_OPERATION(ES 3.0.6 §2.15.2)。
    // drawElements はモードによらず使えないので、更新パスは必ず drawArrays
    gl.beginTransformFeedback(gl.POINTS);
    gl.drawArrays(gl.POINTS, 0, demo.count);
    gl.endTransformFeedback();

    // 書き終えたバッファは、次の描画パスで頂点属性として読む。transform feedback の
    // 出力先に付いたままだと二重バインドになって draw が INVALID_OPERATION になるので、
    // オブジェクトごと外す(本文 5 節の pitfall)
    gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null);
    // RASTERIZER_DISCARD は状態機械のスイッチ(第1章)。切り忘れると次の描画パスも、
    // それどころか gl.clear まで無視される(ES 3.0.6 §3.1)
    gl.disable(gl.RASTERIZER_DISCARD);

    system.read = write;

    // --- パス 2: 描画 -------------------------------------------------------------
    gl.clearColor(BACKGROUND_COLOR[0], BACKGROUND_COLOR[1], BACKGROUND_COLOR[2], 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE); // 加算合成(第30章 2 節)

    eye[0] = TARGET[0] + orbit.radius * Math.cos(orbit.phi) * Math.sin(orbit.theta);
    eye[1] = TARGET[1] + orbit.radius * Math.sin(orbit.phi);
    eye[2] = TARGET[2] + orbit.radius * Math.cos(orbit.phi) * Math.cos(orbit.theta);
    mat4.lookAt(view, eye, TARGET, UP);
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

    gl.useProgram(drawProgram);
    gl.uniformMatrix4fv(drawLocations.view, false, view); // transpose は常に false
    gl.uniformMatrix4fv(drawLocations.projection, false, projection);
    gl.uniform1f(drawLocations.particleSize, demo.particleSize);
    // ワールド単位の直径をピクセルへ直す係数。透視射影で高さ h の描画バッファに写すと、
    // カメラから距離 z にある長さ 1 は h / (2 tan(fovy/2)) / z ピクセルになる
    gl.uniform1f(drawLocations.pointScale, gl.drawingBufferHeight / (2 * Math.tan(FOVY / 2)));
    gl.uniform3f(drawLocations.colorSlow, demo.colorSlow[0], demo.colorSlow[1], demo.colorSlow[2]);
    gl.uniform3f(drawLocations.colorFast, demo.colorFast[0], demo.colorFast[1], demo.colorFast[2]);
    gl.uniform1f(drawLocations.speedScale, demo.speedScale);

    gl.bindVertexArray(system.slots[system.read].vao);
    gl.drawArrays(gl.POINTS, 0, demo.count);
    gl.bindVertexArray(null);
    gl.disable(gl.BLEND);

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

  // このページのデモはページと寿命を共にするので、rAF ループの停止もリスナーの解除も
  // していない。GPU リソース解放の一般論は第35章(この章の deleteParticleSystem は
  // 「デモを切り替えるときに古いものを消す」ぶんだけを実践している)
  requestAnimationFrame(frame);
}

// ---------------------------------------------------------------------------
// 要素とコンテキストの取得
// ---------------------------------------------------------------------------

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const controls = document.querySelector<HTMLParagraphElement>('#demo-buttons');
const readout = document.querySelector<HTMLParagraphElement>('#readout');
if (!canvas || !controls || !readout) {
  throw new Error('デモに必要な要素が見つかりません');
}

const gl = canvas.getContext('webgl2');
if (!gl) {
  throw new Error('このブラウザは WebGL2 に対応していません');
}

setup(gl, canvas, controls, readout);
src/lessons/32-transform-feedback/01-minimal.update.vert
#version 300 es

// 第32章 デモ1 更新パス: 箱の中で跳ね返る 64 個の点。
//
// この章でいちばん短い更新パス。やることは 2 つだけ。
//   position += velocity * dt
//   箱の壁からはみ出した軸は、折り返して速度の符号を反転する
// 位置を「積み上げる」ので、前フレームの状態が要る。第29章はそれをテクスチャに
// 持たせたが、ここではバッファに持たせて、頂点シェーダーの出力として書き戻す。

precision highp float;

// 状態バッファの並び。この 3 本の in と、下の 3 本の out が 1 対 1 で対応する
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_velocity;
layout(location = 2) in float a_age;

uniform float u_dt;

// transformFeedbackVaryings に渡す名前・順番と、ここの宣言が一致していること。
// INTERLEAVED_ATTRIBS なので、この順にそのまま 1 本のバッファへ書かれる(本文 3 節)
out vec3 v_position;
out vec3 v_velocity;
out float v_age;

// 箱の半分の大きさ(ワールド単位)
const float BOUND = 1.5;

void main() {
  vec3 position = a_position + a_velocity * u_dt;

  // はみ出した軸だけ 1 になるマスク。分岐を書かずに 3 軸まとめて処理する
  vec3 outside = step(vec3(BOUND), abs(position));
  // 壁を鏡にして折り返す: x > B なら 2B - x、x < -B なら -2B - x
  position = mix(position, sign(position) * (2.0 * BOUND) - position, outside);

  v_position = position;
  v_velocity = mix(a_velocity, -a_velocity, outside);
  v_age = a_age + u_dt;

  // 更新パスの結果は RASTERIZER_DISCARD で捨てられるので、この値は画面に出ない。
  // それでも書いておく — 書かないと頂点処理のあとの gl_Position は未定義になる
  // (GLSL ES 3.00 §7.1。書かないこと自体はエラーではない = 同 §12.16)
  gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
src/lessons/32-transform-feedback/02-gravity.update.vert
#version 300 es

// 第32章 デモ2 更新パス: 重力と寿命(噴水)。
//
// 位置と速度に加えて「年齢」を状態に持つ。寿命が尽きた粒子は初期状態へ戻す
// (リスポーン)。粒子ごとの向きと速さは a_seed から決める — GPU の中に乱数は無いので、
// CPU が初期化時に 1 回だけ作った 4 つの乱数を、固定の属性として読んでいる(本文 6 節)。

precision highp float;

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_velocity;
layout(location = 2) in float a_age;
// 粒子ごとの固定の種。x = 方位角 / y = 円錐内の傾き / z = 速さ / w = 寿命。
// 更新パスでも書き戻さないので、ping-pong の外に固定のバッファ 1 本で置いてある
layout(location = 3) in vec4 a_seed;

uniform float u_dt;

out vec3 v_position;
out vec3 v_velocity;
out float v_age;

const float TAU = 6.283185307179586;

// 書き換えて試すための定数 ----------------------------------------------
const vec3 GRAVITY = vec3(0.0, -3.4, 0.0);
// 床の高さと、跳ね返るときに残る速さの割合
const float FLOOR_Y = -1.15;
const float RESTITUTION = 0.42;
// 噴き出す速さの範囲と、円錐の半角(ラジアン)
const float SPEED_MIN = 2.4;
const float SPEED_MAX = 3.8;
const float CONE_ANGLE = 0.34;
// 噴き出し口の半径と、寿命(秒)の範囲
const float NOZZLE_RADIUS = 0.05;
const float LIFE_MIN = 1.6;
const float LIFE_MAX = 3.4;
// ------------------------------------------------------------------------

float lifeOf(vec4 seed) {
  return mix(LIFE_MIN, LIFE_MAX, seed.w);
}

vec3 spawnPosition(vec4 seed) {
  float azimuth = seed.x * TAU;
  // 面積で均すために sqrt を通す(そのまま使うと中心に寄る)
  float radius = NOZZLE_RADIUS * sqrt(seed.z);
  return vec3(radius * cos(azimuth), FLOOR_Y + 0.02, radius * sin(azimuth));
}

vec3 spawnVelocity(vec4 seed) {
  float azimuth = seed.x * TAU;
  float tilt = CONE_ANGLE * sqrt(seed.y);
  float speed = mix(SPEED_MIN, SPEED_MAX, seed.z);
  return speed * vec3(sin(tilt) * cos(azimuth), cos(tilt), sin(tilt) * sin(azimuth));
}

void main() {
  vec3 position = a_position;
  vec3 velocity = a_velocity + GRAVITY * u_dt;
  float age = a_age + u_dt;

  position += velocity * u_dt;

  // 床で跳ねる。もぐった分を跳ね返した高さに置き直し、縦の速さを減らして反転する
  if (position.y < FLOOR_Y && velocity.y < 0.0) {
    position.y = FLOOR_Y + (FLOOR_Y - position.y) * RESTITUTION;
    velocity.y = -velocity.y * RESTITUTION;
    velocity.xz *= 0.62; // 横方向は摩擦で落とす
  }

  // 寿命が尽きたらリスポーン。mod で余りを持ち越すので、1 ステップぶんずれない。
  // 初期状態は「位置も速度も 0・年齢だけ大きな乱数」なので、最初のステップで
  // 全粒子がここを通り、そのときの mod がそのまま噴出のばらつきになる
  float life = lifeOf(a_seed);
  if (age >= life) {
    age = mod(age, life);
    position = spawnPosition(a_seed);
    velocity = spawnVelocity(a_seed);
  }

  v_position = position;
  v_velocity = velocity;
  v_age = age;

  // 画面には出ないが、未定義の値を残さないために書いておく(01-minimal.update.vert と同じ)
  gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
src/lessons/32-transform-feedback/03-flow.update.vert
#version 300 es

// 第32章 デモ3 更新パス: 解析的な流れ場。
//
// 場そのものは sin と cos の組み合わせだけで書く(第9章)。ノイズは使わない。
// 各成分が「自分の軸の座標」を含まないので、この場の発散は恒等的に 0 —
// つまり粒子が 1 点へ吸い込まれて潰れることがない。
//
// ただし発散 0 は「散らばりも作らない」ということでもある。体積を保つ流れは
// 一様な分布を一様なまま運ぶので、球の中に一様に粒子を撒くと、いつまでたっても
// 一様なもやのままで流れの形が見えない。そこで噴き出し口を EMITTER_COUNT か所に
// 絞ってある。各粒子はいつも自分の 1 か所へ戻るので、同じ噴き出し口から出た
// 粒子たちが流線に沿って 1 本の筋(流跡線)に並ぶ。詳しくは本文 6 節。
//
// 粒子は流れに引きずられるだけで、互いの位置は見ない。テクスチャ方式なら隣の
// セルがタダで読めたが、バッファ方式では他の粒子は見えない(本文 1 節・7 節)。

precision highp float;

layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_velocity;
layout(location = 2) in float a_age;
// 粒子ごとの固定の種。x, y = 噴き出し口まわりのばらつきの向き / z = どの噴き出し口か / w = 寿命
layout(location = 3) in vec4 a_seed;

uniform float u_dt;
uniform float u_time;

out vec3 v_position;
out vec3 v_velocity;
out float v_age;

const float TAU = 6.283185307179586;
// 黄金角(rad)と黄金比の小数部。噴き出し口を球の中へ偏りなく散らすために使う
const float GOLDEN_ANGLE = 2.399963229728653;
const float GOLDEN_FRACTION = 0.6180339887;

// 書き換えて試すための定数 ----------------------------------------------
// 場の細かさ(大きいほど渦が小さい)と、場そのものが流れていく速さ
const float FIELD_SCALE = 2.0;
const float FIELD_DRIFT = 0.12;
// 場の値をワールド単位の速さに直す係数
const float FLOW_SPEED = 0.9;
// 速度が場の値に追いつく速さ(1 / 秒)。大きいほど場に貼り付く
const float DRAG = 2.6;
// この球からはみ出したらリスポーンする
const float RADIUS = 2.6;
// 噴き出し口の数と、それを散らす球の半径、噴き出し口 1 つの太さ
const float EMITTER_COUNT = 320.0;
const float EMITTER_RADIUS = 2.3;
const float EMITTER_SPREAD = 0.035;
// 寿命(秒)の範囲
const float LIFE_MIN = 4.0;
const float LIFE_MAX = 9.0;
// ------------------------------------------------------------------------

vec3 flow(vec3 p, float t) {
  vec3 q = p * FIELD_SCALE + t * FIELD_DRIFT;
  return vec3(sin(q.y) - cos(q.z), sin(q.z) - cos(q.x), sin(q.x) - cos(q.y));
}

vec3 spawnPosition(vec4 seed) {
  // seed.z がどの噴き出し口かを決める。粒子はいつも同じ 1 か所へ戻る
  float k = min(floor(seed.z * EMITTER_COUNT), EMITTER_COUNT - 1.0);
  // 向きは球面らせん: 極角は k で等分し、方位角は黄金角ずつ回す
  float cosPolar = (k + 0.5) / EMITTER_COUNT * 2.0 - 1.0;
  float sinPolar = sqrt(max(0.0, 1.0 - cosPolar * cosPolar));
  float azimuth = k * GOLDEN_ANGLE;
  // 半径も k で振って、球面ではなく球の中いっぱいに噴き出し口を散らす。
  // fract(k * 黄金比) は 0〜1 を偏りなく埋める並び、3 乗根は球の中で均すため
  float radius = EMITTER_RADIUS * (0.25 + 0.75 * pow(fract(k * GOLDEN_FRACTION), 1.0 / 3.0));
  vec3 center = radius * vec3(sinPolar * cos(azimuth), cosPolar, sinPolar * sin(azimuth));

  // 噴き出し口が完全な点だと筋が線 1 本になって細すぎるので、小さな球にばらす
  float jitterAzimuth = seed.x * TAU;
  float jitterCos = seed.y * 2.0 - 1.0;
  float jitterSin = sqrt(max(0.0, 1.0 - jitterCos * jitterCos));
  vec3 jitter = vec3(jitterSin * cos(jitterAzimuth), jitterCos, jitterSin * sin(jitterAzimuth));
  return center + EMITTER_SPREAD * jitter;
}

void main() {
  vec3 position = a_position;
  vec3 velocity = a_velocity;
  float age = a_age + u_dt;

  // 速度を場の値へ寄せる。min(1.0, ...) は dt が大きいときの行き過ぎ止め
  vec3 target = flow(position, u_time) * FLOW_SPEED;
  velocity += (target - velocity) * min(1.0, DRAG * u_dt);
  position += velocity * u_dt;

  float life = mix(LIFE_MIN, LIFE_MAX, a_seed.w);
  if (age >= life || length(position) > RADIUS) {
    age = mod(age, life);
    position = spawnPosition(a_seed);
    velocity = vec3(0.0);
  }

  v_position = position;
  v_velocity = velocity;
  v_age = age;

  // 画面には出ないが、未定義の値を残さないために書いておく(01-minimal.update.vert と同じ)
  gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
src/lessons/32-transform-feedback/draw.vert
#version 300 es

// 第32章 描画パスの頂点シェーダー(3 本のデモで共通)。
//
// 更新パスが書き終えたばかりの状態バッファを、そのまま頂点属性として読む。
// この章で 3 本のデモが違うのは「更新パスだけ」で、描き方は 1 本で足りる。
// 点の描き方(gl.POINTS と gl_PointSize)は第30章 5 節のまま。

precision highp float;

// 状態バッファの並び。0〜2 は更新パスが毎フレーム書き換える。
// 3 は初期化のときに 1 回だけ書く固定の値で、ここでは読まない
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_velocity;

uniform mat4 u_view;
uniform mat4 u_projection;
// 点の直径(ワールド単位)と、それを描画バッファのピクセルへ直す係数。
// u_pointScale は CPU 側で height / (2 * tan(fovy / 2)) を入れてある
uniform float u_particleSize;
uniform float u_pointScale;
// 速さ 0 のときの色と、u_speedScale の速さに達したときの色。どちらもリニア値
uniform vec3 u_colorSlow;
uniform vec3 u_colorFast;
uniform float u_speedScale;

out vec3 v_color;

void main() {
  // 粒子の位置はもうワールド座標なので、モデル行列は無い
  vec4 viewPosition = u_view * vec4(a_position, 1.0);
  gl_Position = u_projection * viewPosition;

  // 遠いほど小さく。透視射影が長さを 1 / (カメラからの奥行き) に縮めるのに合わせる
  gl_PointSize = u_particleSize * u_pointScale / max(0.05, -viewPosition.z);

  float speed = length(a_velocity) / u_speedScale;
  v_color = mix(u_colorSlow, u_colorFast, clamp(speed, 0.0, 1.0));
}
src/lessons/32-transform-feedback/draw.frag
#version 300 es

// 第32章 描画パスのフラグメントシェーダー(3 本のデモで共通)。
// 点を丸く落として、リニアで組み立てた色を sRGB へエンコードして出す。

precision highp float;

#include "color.glsl"

in vec3 v_color;

out vec4 fragColor;

void main() {
  // gl_PointCoord は点の中を 0〜1 で走る座標(第30章 5 節)。
  // 中心からの距離でふちを落として、四角い点を丸く見せる
  vec2 d = gl_PointCoord * 2.0 - 1.0;
  float falloff = max(0.0, 1.0 - dot(d, d));
  vec3 color = v_color * falloff * falloff;

  // 加算合成はフラグメントシェーダーより後の固定機能段なので、実際に足し合わされるのは
  // ここでエンコードしたあとの値になる。厳密にリニアで足すには float の FBO へ描いて
  // 最後に 1 回だけエンコードする形が要る(第33章)。本文 6 節
  fragColor = vec4(linearToSrgb(color), 1.0);
}
src/lessons/32-transform-feedback/color.glsl
// 第32章 出力の最後の 1 行のための色変換。
// 中身は第27章の src/lessons/27-color-and-palette/color.glsl からの複製(内容は同一)で、
// srgbToLinear / linearToSrgb の 2 関数だけを持ってきている。
// 片方を直したらもう片方も直すこと(第25→26章の noise.glsl と同じ流儀。理由は
// docs/plan.md の抽象化タイムライン: 読者が壊して遊ぶ対象なので章をまたいだ結合を作らない)。
//
// この章で使うのは linearToSrgb のほう。パーティクルの色はリニア値として組み立て、
// 画面へ出す直前にここでエンコードする。変換の体系そのものは第27章 2 節。

/**
 * sRGB エンコード値 → リニア値。
 * 出典: OpenGL ES 3.0.6 §3.8.16 式 (3.26)。GPU が sRGB テクスチャを読むときの変換そのもの。
 * 境界 (0.04045) では 2 つの式の差が 2.3e-9 しかないので、等号がどちら側かは問題にならない。
 */
vec3 srgbToLinear(vec3 c) {
  vec3 lo = c / 12.92;
  vec3 hi = pow((c + 0.055) / 1.055, vec3(2.4));
  return mix(lo, hi, step(vec3(0.04045), c));
}

/**
 * リニア値 → sRGB エンコード値。画面へ出す最後の 1 行はこれになる。
 * 出典: OpenGL ES 3.0.6 §4.1.8 式 (4.1)。仕様は指数を 0.41666 と書いているが、
 * これは 1/2.4 = 0.4166666… を打ち切った値で、8 ビット出力での差は最大 1 段。1/2.4 を使う。
 * 仕様の式は 0 以下と 1 以上を切り落とすので、clamp がその 2 本の枝にあたる。
 */
vec3 linearToSrgb(vec3 c) {
  c = clamp(c, 0.0, 1.0);
  vec3 lo = c * 12.92;
  vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
  return mix(lo, hi, step(vec3(0.0031308), c));
}

three.js との対応

この章の内容に対応する公開 API は three.js にありません。r185 のsrc/renderers/WebGLRenderer.jsにはTransformFeedbackという文字列が 1 つも出てきません(npm のthree@0.185.1を展開して検索。2026-08-07 時点)。ただし使っている場所はあります— WebGPURenderer の WebGL フォールバックsrc/renderers/webgl-fallback/WebGLBackend.jsが、TSL の compute を WebGL2 上で実現するために transform feedback を使っています。attachShader→ transformFeedbackVaryings(…, gl.SEPARATE_ATTRIBS)→ linkProgramの順序も、RASTERIZER_DISCARDを有効にしてbeginTransformFeedback(gl.POINTS)で挟むのも、2 本のバッファをswitchBuffers()で入れ替えるのも、この章とほぼ同じ形です。

three.jsこの章
WebGLRendererの公開 API には transform feedback が無い生の WebGL2 API を直接呼ぶ(この章のすべて)
GPUComputationRenderer(examples/jsm の補助クラス)これはテクスチャ方式。変数は「計算要素ごとに 4 つの float を持つ RGBA float テクスチャ」で、変数ごとにWebGLRenderTargetを 2 つ持って ping-pong する。第29章のfeedback.tsに対応するもので、この章の transform feedback とは別物
WebGPURenderer+ TSL のFn(…).compute(count)WebGPU が使えれば本物の compute shader。使えないときはWebGLBackendが transform feedback へ落とす(上の段落)。この章はその「落とした先」を手で書いている
THREE.Points+PointsMaterialgl.POINTS+draw.vert/draw.frag。点の描き方そのものは第30章 5 節
BufferAttributeのneedsUpdate = trueで毎フレーム送り直す送らない。状態バッファは GPU 側にあり、CPU からの転送は初期化のとき 1 回だけ(6 節)

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

この章のコードはsrc/lessons/32-transform-feedback/にあります。デモを切り替えると状態が初期化されるので、最初から見たくなったら ボタンを押し直してください。

まとめ

ここまでの 3 章で「たくさんのものを、速く描く」道具が揃いました。ブレンド(第30章)、 インスタンシング(第31章)、そして GPU 上での状態更新(この章)。次章第33章「ポストプロセス — ブルーム・トーンマップ・FXAA」は、視点が変わります — ここまではずっと何をどう描くかの話でしたが、次は描き終わった絵を加工する話です。第20章で作った FBO をチェーンにつなぎ、この章の加算合成で飽和した明るい部分をにじませて光らせるブルーム、 MSAA が効かない場面(第2章・第23章の伏線)のための FXAA を扱います。上で「厳密にリニアで足すには float の FBO が要る」と書いた話も、そこで回収します。