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

ブレンディングとパーティクル — 加算合成と深度の兼ね合い

第4部の 6 章は、画面いっぱいの三角形 1 枚に貼ったフラグメントシェーダーの世界でした。1 つのピクセルは 1 フレームに 1 回だけ塗られ、その値がそのまま画面に出ました。だから「すでに そこにあった色」を気にする必要が、そもそもありませんでした。

この章から同じピクセルが何度も塗られる世界に戻ります。半透明の板が 3 枚重なれば 3 回、光の粒が数千個ばらまかれれば、密なところでは何十回も塗られます。そのとき、 フラグメントシェーダーが出した色をすでにある色とどう混ぜるかを決める段が要る。 それがブレンディング (blending)です。第3章のパイプライン図に「後の章で描き足す」と 書き、第13章 7 節では点線のまま残しておいた、最後の 1 マスがこれです。

第29章のまとめは、この章をこう予告しました — 格子を捨てて、たくさんの点をばらばらの場所に 描いて重ねる。その「点」を担うのがgl.POINTSとgl_PointSize(第5章の伏線)で、点を大量にばらまいて動かす表現をパーティクル (particle)と呼びます。混ぜ方・深度との兼ね合い・点の描き方。この 3 つを順に片づけて、最後にパイプライン図を 完成させます。

この章のデモ 4 本(ドラッグで回り込み、ホイールで寄れます。第15章の軌道カメラの簡略版)。上段が デモの切り替え、下段がそのデモのオプションです。「ブレンド式」は同じ 3 枚の板を、混ぜ方だけ変えて重ねます。「半透明と深度」はdepthMaskと描画順を 1 つずつ変えていく 4 段階。「点を描く」はgl.POINTSの格子で、読み出し行にこの環境のALIASED_POINT_SIZE_RANGEが出ます。「CPU パーティクル」は毎フレームbufferSubDataで送る噴水で、読み出し行の転送バイト数が主役です。読み出し行の所要時間はCPU 側で測った rAF の間隔で、GPU の処理時間ではありません(第35章)。

この章で学ぶこと:

1. 重ねて描く — ブレンド段はパイプラインのどこにいるか

ブレンドの定義は、OpenGL ES 3.0.6 §4.1.7 の 1 文がそのまま答えになっています。「ブレンドは、 入ってくるソースフラグメントの R, G, B, A の値を、そのフラグメントの (xw, yw) の位置でフレームバッファに格納されているデスティネーションの R, G, B, A の値と組み合わせる」。

ソース (source)がフラグメントシェーダーの出力、デスティネーション (destination)がフレームバッファにすでにある値です。この 2 つの名前は、以降のすべての式に出てきます。

フラグメントシェーダーの出力= ソース (source)いまフレームバッファにある値= デスティネーション (destination)ブレンド段固定機能フレームバッファへ書き戻す書き戻した値が、次に同じピクセルへ来るフラグメントのデスティネーションになる
ブレンドは「読んで・混ぜて・書く」の 1 セットです。同じピクセルに次のフラグメントが来ると、 さっき書いた値が今度はデスティネーションになります。だから描いた順が結果に効きうる— これが 4 節の主題になります。

位置はフラグメントシェーダーの後です。深度テスト(第13章)と同じ「フラグメント ごとの操作」の列に並んでいて、順番は深度テストのほうが先。負けたフラグメントはブレンドまで たどり着きません。§4.1.7 が「ブレンドが無効なら次の操作へ進む」と定め、§4.1.7.4 が「初期状態 ではブレンドは無効」としています — つまり既定ではソースの色がそのままフレームバッファへ 行きます。ここまで書いてきた章がすべてそうでした。

有効にするのは、深度テストやカリングとまったく同じ、状態機械のスイッチです(第1章)。デモの ボタンはシェーダーを差し替えているのではなく、この掛け替えをしているだけです。

src/lessons/30-blending-particles/main.ts(抜粋)
  function applyBlend(preset: BlendPreset): void {
    if (preset.func === null) {
      gl.disable(gl.BLEND);
      return;
    }
    gl.enable(gl.BLEND);
    gl.blendEquation(gl.FUNC_ADD);
    gl.blendFunc(preset.func[0], preset.func[1]);
  }

なぜブレンドはプログラマブルでないのか

シェーダーは自由に書けるのに、その後ろのブレンドはblendFuncで選べる式の組み合わせしか許されません。理由は「読んで・混ぜて・書く」が、同じ番地に対する競合になるからです。1 回のドローコールで走る何十万個のフラグメントには実行順序の保証がなく(第1章)、 同じピクセルへ複数のフラグメントが同時に到達しえます。もしこの読み書きを任意のシェーダーコード で書けてしまうと、「誰が先に読んだか」で結果が変わってしまう。

段を固定機能に閉じ込め、しかも式の形をあらかじめ限定しておけば、ハードウェアは ピクセル単位で読み書きを直列化する回路を用意できます。プリミティブの投入順という決まった順序で 結果を積み上げられるのは、この段が固定だからです。

2. blendFunc と blendEquation — 混ぜ方を式で決める

混ぜ方は、次の 3 つの状態で決まります。

ES 3.0.6 の Table 4.1 が、式のすべてです。R 成分だけ書くと(G, B も同形。A は A 用の係数を使う):

blendEquation のモードRGB 成分の式(R の例)
FUNC_ADD(初期値)R = Rs × Sr + Rd × Dr
FUNC_SUBTRACTR = Rs × Sr − Rd × Dr
FUNC_REVERSE_SUBTRACTR = Rd × Dr − Rs × Sr
MINR = min(Rs, Rd) — 係数は使われません
MAXR = max(Rs, Rd) — 係数は使われません

添字の s がソース、d がデスティネーション、S と D が係数です。MIN/MAXは式の中に係数が現れないので、blendFuncを何に設定しても結果が変わりません。この 2 つは ES 3.0.6 の Table 4.1 に載っており、WebGL 2.0 仕様の定数一覧にもMIN = 0x8007/MAX = 0x8008があるので、WebGL2 では拡張なしで使えます。

係数(S と D)に指定できるものが Table 4.2 です。RGB とアルファで別の値が使われる行があるので、 2 列に分けてあります。

blendFunc に渡す定数RGB の係数 (Sr, Sg, Sb) または D 側アルファの係数 Sa / Da
ZERO(0, 0, 0)0
ONE(1, 1, 1)1
SRC_COLOR(Rs, Gs, Bs)As
ONE_MINUS_SRC_COLOR(1, 1, 1) − (Rs, Gs, Bs)1 − As
DST_COLOR(Rd, Gd, Bd)Ad
ONE_MINUS_DST_COLOR(1, 1, 1) − (Rd, Gd, Bd)1 − Ad
SRC_ALPHA(As, As, As)As
ONE_MINUS_SRC_ALPHA(1, 1, 1) − (As, As, As)1 − As
DST_ALPHA(Ad, Ad, Ad)Ad
ONE_MINUS_DST_ALPHA(1, 1, 1) − (Ad, Ad, Ad)1 − Ad
CONSTANT_COLOR(Rc, Gc, Bc)Ac
ONE_MINUS_CONSTANT_COLOR(1, 1, 1) − (Rc, Gc, Bc)1 − Ac
CONSTANT_ALPHA(Ac, Ac, Ac)Ac
ONE_MINUS_CONSTANT_ALPHA(1, 1, 1) − (Ac, Ac, Ac)1 − Ac
SRC_ALPHA_SATURATE(f, f, f)、f = min(As, 1 − Ad)1

添字の c は blendColor(r, g, b, a) で設定する定数色です。API はこの 5 本(4 系統)です。

初期値は blendEquation が FUNC_ADD、blendFuncがソース側 ONE・デスティネーション側 ZERO、定数色が (0, 0, 0, 0) です(§4.1.7.4)。つまり有効にしただけでは、結果は「ソースがそのまま上書き」のまま変わりません。gl.enable(gl.BLEND)とgl.blendFunc(...)は必ずセットだと思っておくと、「有効にしたのに何も起きない」で悩まずに済みます。

代表的な組み合わせ

実務で使うのは、ほとんどが次の 4 通りです(blendEquationはFUNC_ADDのまま)。デモ 1 のオプションは、この 4 つに「ブレンドなし」を足した 5 つです(並び順だけ変えてあります — 乗算済みアルファを通常アルファの隣に置いて、絵が変わらないことを ボタン 1 つで見比べられるようにしました)。

呼び名設定結果(a = ソースのアルファ)使いどころ
通常アルファblendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)src × a + dst × (1 − a)ガラス・煙。「向こうが透けて見える」
加算blendFunc(SRC_ALPHA, ONE) または blendFunc(ONE, ONE)src × a + dst(あるいは src + dst)光・火花・炎。重ねるほど明るくなる
乗算blendFunc(DST_COLOR, ZERO)src × dst影・汚し。重ねるほど暗くなる
乗算済みアルファblendFunc(ONE, ONE_MINUS_SRC_ALPHA)src + dst × (1 − a)3 節
src/lessons/30-blending-particles/main.ts(抜粋)
  const blendPresets: readonly BlendPreset[] = [
    { label: 'ブレンドなし', func: null, premultiply: false },
    { label: '通常アルファ', func: [gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA], premultiply: false },
    { label: '乗算済みアルファ', func: [gl.ONE, gl.ONE_MINUS_SRC_ALPHA], premultiply: true },
    { label: '加算', func: [gl.SRC_ALPHA, gl.ONE], premultiply: false },
    { label: '乗算', func: [gl.DST_COLOR, gl.ZERO], premultiply: false },
  ];

デモ 1 を「乗算」にすると、板を重ねたところがほとんど黒に沈みます。壊れているのではなく、掛け算とはそういうものです。背景板の色はどれも 1 未満なので、掛けるたびに小さく なります。乗算が「汚し」や「焼き込み」に使われるのは、明るくなることが原理的にないからです。

3. アルファ乗算済み (premultiplied alpha) — 第2章 2 節の属性の正体

半透明の色の持ち方には 2 通りあります。同じ絵を表す 2 つの表現、と言ったほうが正確です。

通常アルファの合成式に、それぞれを入れてみます。非乗算済みなら

result = src × a + dst × (1 − a)← blendFunc(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)

乗算済みなら、src にはすでに a が掛かっています。だからソース側の係数は 1 でよい。

result = src + dst × (1 − a)← blendFunc(ONE, ONE_MINUS_SRC_ALPHA)

2 つの式は同じ結果を出します。デモ 1 で「通常アルファ」と「乗算済みアルファ」を切り替えても 絵が変わらないのは、そのためです。シェーダー側で 1 か所だけ違います。

src/lessons/30-blending-particles/01-blend.frag(抜粋)
  // 出口で 1 回だけ sRGB へエンコードする(第27章 2 節)。
  // ここから先のブレンド段は、このエンコード値の上で足し引きする(本文 2 節の落とし穴)
  vec3 encoded = linearToSrgb(linear);

  fragColor = vec4(u_premultiply == 1 ? encoded * alpha : encoded, alpha);

乗算済みのほうが式が 1 つで済む

見た目が同じなら、わざわざ掛けてから出す意味はどこにあるのか。通常アルファと加算を、同じ 1 つの設定で書き分けられることです。blendFunc(ONE, ONE_MINUS_SRC_ALPHA)のまま、シェーダーが出す RGB はそのままに、アルファだけを変えると、

ここは読み方が要ります。乗算済みで持つ (RGB, A) は、2 つの独立な量として 読み替えられます — RGB は「このフラグメントが足す光」、A は「背後をどれだけ隠すか」。非乗算済みの (r, g, b, a) を素直に (r·a, g·a, b·a, a) へ変換すると、この 2 つは連動して 動くので、a を 0 にすれば RGB も (0, 0, 0) になって何も足されません。上の a = 0 が加算になるのは、あえて連動を外して、A = 0 のまま RGB を残したときです。「光は足すが、背後は隠さない」— 加算合成の意味そのものです。blendFunc(ONE, ONE_MINUS_SRC_ALPHA)という 1 つの設定が、その両方を同じ式で書けるところが利点だ、ということになります。

煙(通常アルファ)と火花(加算)が 1 つのパーティクルシステムに混ざっていても、ブレンドの状態を切り替えずに 1 回のドローコールで描けるわけです。粒ごとに アルファを 0〜1 で振れば、その中間も連続的に作れます。乗算済みが「1 つの設定で足りる」と 言われるのは、この性質を指しています。

もう 1 つ、乗算済みには「フィルタリングの結果が正しい」という利点もあります。非乗算済みの テクスチャを線形補間すると、透明な部分(a = 0)の意味のない RGB が混ざり込んで縁に色がにじみます。 ただしこの章のデモはテクスチャを使わないので、実物は出てきません。

第2章 2 節の premultipliedAlpha は、別の段の話

第2章 2 節のコンテキスト属性の表にpremultipliedAlpha(既定true)がありました。読者が最も混同するのがここなので、はっきり分けておきます。

canvas の中のブレンド(この章)canvas とページの合成(第2章 2 節の属性)
いつ起きるかドローコールのたび、フラグメントごとフレームを描き終えて、ブラウザが画面を合成するとき
誰がやるかGPU のブレンド段(ES 3.0.6 §4.1.7)ブラウザのコンポジタ
何を混ぜるかソース と デスティネーション描画バッファ全体 と、canvas の下にあるページの内容
設定するものblendFunc / blendEquation(いつでも変えられる)コンテキスト属性 premultipliedAlpha / alpha(取得時に 1 回だけ)

premultipliedAlpha: trueは「描画バッファに入っている色を、乗算済みとして扱ってページと合成する」という意味です。だからdocs/plan.mdの規約が「半透明のクリア色・出力色を例に使うときはRGB ≤ Aを守る」と定めているのです。第1章にも書いたとおり、アルファを超えた色の 合成結果は仕様上未定義で、環境によって色が変わります。素直に乗算済みへ変換した値なら r·a ≤ a、つまり必ず RGB ≤ A に収まります。ページとの合成は、描画バッファの中身がその範囲にあることを前提にしている、ということです。 逆に言えば、さきほどの加算合成のように意図的に RGB > A を出した値を そのまま画面へ残すと、この前提を踏み外します。

この章のデモは、そこを避けて通っています。コンテキストをalpha: falseで取っているので、描画バッファにアルファ成分そのものがありません。

src/lessons/30-blending-particles/main.ts(抜粋)
const gl = canvas.getContext('webgl2', { alpha: false });

canvas は不透明として合成されるので、ページとの合成というもう一段のブレンドが最初から 存在しません。この章で見えている混ざり方は、すべて canvas の中のブレンド段のものです。 アルファ成分が無いと、デスティネーション側のアルファAdはどうなるのか — §4.1.7.4 の最後の 1 文が答えを持っています。「カラーバッファに A の値が無ければ、 Adは 1 とみなされる」。

4. 半透明と深度テスト — depthMask と描画順

深度テスト(第13章)と半透明は、素直には両立しません。言っていることが違うからです。

問題は「テスト」ではなく「書き込み」のほうにあります。半透明の板を描いたとき、 その板の深度値をバッファに書いてしまうと、あとから描く奥の板がテストに負けて消えます。向こうが透けて見えるはずなのに、何も無かったことになる。デモ 2 の「書き込みあり・手前→奥」 がそれです。板が重なったところは、手前の 1 枚だけが残ります(3 枚は中心をずらしてあるので、重なっていない部分はそのまま見えます)。

直し方は、テストは残したまま書き込みだけを切ることです。書き込みのオン・オフは 深度テストとは別の状態で、ES 3.0.6 では §4.2.2「Fine Control of Buffer Updates」という別の節に います。「すべてのフラグメントごとの操作が行われた後の、各論理フレームバッファ へのビットの書き込みはマスクできる」という位置づけです。

半透明で欲しいのは前者です。奥の不透明な壁に隠れてほしいので、テストは残す。だから定石は2 パスになります。

  1. 不透明を先に描く(深度テストあり・深度書き込みあり)
  2. 半透明を後で描く(深度テストあり・深度書き込みなし・奥から手前へ)
src/lessons/30-blending-particles/main.ts(抜粋)
        // ① 不透明な背景板を先に、深度書き込みありで描く
        gl.enable(gl.DEPTH_TEST);
        gl.depthMask(true);
        gl.disable(gl.BLEND);
        useOpaque();
        mat4.fromTranslation(model, [0, 0, -2.6]);
        drawOpaque(backdropMesh, model, vec3.fromValues(0.16, 0.17, 0.2), true);

        // ② 半透明の板を後から、深度書き込みなし・奥から手前へ(本文 4 節)
        gl.depthMask(false);
        applyBlend(preset);
        usePanels(preset.premultiply);
        sortPanels(true);
        for (const index of panelOrder) {
          drawPanel(panels[index], time);
        }

なぜ奥から手前か

通常アルファの式 result = src × a + dst × (1 − a) を見ると、a が掛かる相手と (1 − a) が掛かる相手が、描く順で入れ替わります。だから結果は順序に依存します。数値で確かめます。デスティネーションが 0.06(暗い背景)、 そこへ A(色 0.9・a = 0.5)と B(色 0.25・a = 0.5)の 2 枚を重ねる場合(1 枚描くごとに RGBA8 の コードへ丸め、最後のコードどうしを比べています)。B の色に 0.25 を選んだのは、どちらの順でも 途中の値が丸めの同点(.5 ちょうど)にならないようにするためです — 同点だと、 後で見るように仕様が丸め方を決めていないぶん、値が 1 コード段ぶれます:

差は 41 コード段です。同じ 2 枚なのに、順番を変えただけで見える色が変わる。 正しいのは、物理的に奥にあるほうを先に描いたほうです。半透明のガラスを 2 枚 重ねて向こうを見るとき、光は奥から手前へ順に減衰していくので、その順に計算するのが実際に 起きていることの模型になります。デモ 2 の「書き込みなし・手前→奥」と「書き込みなし・奥→手前」を 見比べると、板の重なりの色が入れ替わるのが分かります。

ソートは CPU の仕事です。オブジェクトの中心をビュー空間へ写して z で並べ替える、というのが 素朴な実装で、この章もそうしています。

src/lessons/30-blending-particles/main.ts(抜粋)
  function sortPanels(farToNear: boolean): void {
    for (let i = 0; i < panels.length; i++) {
      const c = panels[i].center;
      // ビュー空間の z(カメラ前方が負)。小さいほど遠い
      panelViewDepth[i] = view[2] * c[0] + view[6] * c[1] + view[10] * c[2] + view[14];
    }
    panelOrder.sort((a, b) =>
      farToNear ? panelViewDepth[a] - panelViewDepth[b] : panelViewDepth[b] - panelViewDepth[a],
    );
  }

板 3 枚なら誤差ですが、コストは枚数 N に対して N log N で増えます。しかもカメラが動くたびに並べ替え直しなので、毎フレーム走ります。さらに、オブジェクト単位のソートは交差する板や凹んだ形では原理的に正しくなりません— 正しくやるならフラグメント単位で並べる必要があり、それは WebGL2 の素の機能では手が出ない 領域です。半透明の描画順が「実務でいちばん面倒な問題」と言われるのは、この積み重ねです。

加算合成は順序に依存しない

ところが加算(blendFunc(ONE, ONE)や blendFunc(SRC_ALPHA, ONE))は事情がまったく違います。式がresult = src + dstで、デスティネーションに何も掛からないからです。3 枚を足す計算は (A + B) + C でも A + (B + C) でも同じ値になり、並べ替える理由が消えます。デモ 2 の「加算・並べ替えなし」は、 文字どおりソートを 1 回もしていません。それでもカメラをどう回しても絵が破綻しません。

ただし、ここは慎重に確かめる価値があります。実数の加算なら結合則が成り立ちますが、実際の ブレンドはRGBA8 に丸めながら足しているからです。1 枚足すたびに 8 ビットへ 量子化されるなら、順序で答えが変わってもおかしくない。

結論から書くと、変わりません。理由は式を追えば分かります。

このモデルを実際に回して確かめました。ソース値をランダムに選んで全通りの並べ替えを試し、最終的な 8 ビットコードが割れるかを数えたものです。

条件並べ替えで最終コードが割れた試行
4 枚 / src ∈ [0, 0.05) 一様(薄い粒)・全 24 通り0 / 200,000
4 枚 / src ∈ [0, 0.4) 一様(濃い粒)・全 24 通り0 / 200,000
6 枚 / src ∈ [0, 0.05) 一様・全 720 通り0 / 200,000
6 枚 / src ∈ [0, 0.4) 一様・全 720 通り0 / 200,000
参考: 通常アルファで 4 枚 / 色・アルファとも [0, 1) 一様・全 24 通り199,986 / 200,000(最大の開き 245 コード段 — これは 20 万試行の極値なので、乱数列を変えて測り直すと数コード段ぶれます)

どの行も、開始デスティネーションはコード 0〜39 の一様乱数(暗い背景)で、1 枚描くごとに 8 ビットのコードへ丸めています。「割れた」とは、その試行で全通りの並べ替えを試したときに最終コードが 1 つに揃わなかったことで、「開き」はその最大値と最小値の差(どちらも丸めたあとのコード)です。加算は 1 件も割れず、通常アルファは99.99%の試行で割れました。「加算なら並べ替えなくてよい・通常 アルファは並べ替えが要る」という対比が、丸めを入れても保たれていることが確かめられます。

加算のもう 1 つの利点は、深度書き込みを切っても困らないことです。通常アルファ なら「奥から手前へ」という前提が崩れた瞬間に色が変わりますが、加算にはその前提がありません。depthMask(false)にして、ソートもせず、そのまま数万個ばらまける。だからパーティクルは加算合成と相性がよいのです。デモ 4 が実際にそうしています。

src/lessons/30-blending-particles/main.ts(抜粋)
        gl.depthMask(option === 0);
        gl.enable(gl.BLEND);
        gl.blendEquation(gl.FUNC_ADD);
        gl.blendFunc(gl.SRC_ALPHA, additive ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA);

第23章 9 節で、ディファードレンダリングの制約として「不透明はディファード、半透明はフォワードで 描き足す」と書き、「ブレンドの設定そのもの(blendFunc・加算合成・描画順)は第30章で 扱います」と送りました。その「描き足す」の中身が、いまの 2 パスです。ライティングを済ませた 結果の上に、深度書き込みを切った半透明を、奥から手前へ重ねる。

5. 点を描く — gl.POINTS と gl_PointSize / gl_PointCoord

パーティクルの 1 粒を、四角形 2 枚(4 頂点 + 6 インデックス)で作るのは無駄が大きい。WebGL には1 頂点で 1 つの正方形を描くプリミティブがあります。gl.drawArrays(gl.POINTS, 0, n)です。粒子は 1 頂点で済むので、何万個でも 1 回のドローコールで描けます(ドローコールという 単位のコストは第31章 1 節)。

src/lessons/30-blending-particles/main.ts(抜粋)
        gl.bindVertexArray(latticeVao);
        // 1 頂点 = 1 点。格子ぜんぶが 1 回のドローコールで出る
        gl.drawArrays(gl.POINTS, 0, latticeCount);

大きさは gl_PointSize で決める。書かないと未定義

ES 3.0.6 §3.4 は、点を「頂点を中心とする正方形の形をしたフラグメントの集合を生成することで 描かれる」と定義しています。その正方形の一辺 = 点の大きさは頂点シェーダーが書く gl_PointSize(ウィンドウ座標 = ピクセル単位)から取られ、実装依存の範囲へ clamp されます。そして、 見落とすと痛い一文が続きます。「gl_PointSizeに書かれた値が 0 以下の場合、あるいは何も書かれなかった場合、 点の大きさは未定義である」。

「未定義」なので、たまたま 1 ピクセルで描かれる環境もあれば、何も出ない環境もありえます。gl.POINTSを使うなら、頂点シェーダーのどの経路を通ってもgl_PointSizeに正の値が入るように書いてください。第16章 7 節や第29章で見た「ES 3.0 の未定義を WebGL 2.0 仕様が上書きしている」パターンにも当たりません — WebGL 2.0 仕様にこれを上書きする規定は 見当たりませんでした。手元の環境でgl_PointSizeに 0 と −3 を書いてみたときは、どちらも何も描かれませんでした。ただしこれは 1 実装の観測にすぎず、「0 以下なら描かれない」と一般化してはいけません。仕様が言っているのは、あくまで「未定義」です。

対応する範囲はALIASED_POINT_SIZE_RANGEで問い合わせられます。ここに落とし穴があって、§3.4 が要求しているのは「対応する最大の点の大きさは、少なくとも 1 でなければならない」だけです。仕様上は「1 ピクセルの点しか描けない」実装が合法だということです。実際のデスクトップ GPU はもっと大きな値を返しますが、移植性を気にするなら問い合わせるべき値です。デモ 3 の読み出し行に、あなたの環境で実際に返る値を出してあります。

参考までに、この原稿を書いたときに手元の Chrome(RENDERER = ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version))で測った値は[1, 511]でした。これは 1 環境の値で、移植可能な数値ではありません。同じ環境でgl_PointSizeに 600 や 5000 を書いてreadPixelsで赤い画素を数えたところ、どれも 511 のときと同じ 260,100 画素になり、上限へ clamp されることも確かめられました(700×700 の canvas に 1 点だけ描き、R > 128 の画素を数えたもの)。 260,100 が 5112ではなく5102なのは、点の中心を窓座標 350.0(ピクセルの角)に置いたからです。一辺 511 の正方形 94.5〜605.5 に中心が入るピクセルは 95〜604 の 510 列しかありません。この節の後半の aside「『一辺 1 の点』が 1 画素になるとは限らない」と同じ理屈です。

遠近も自分で付けます。透視射影ではクリップ座標の w がカメラからの距離に比例するので(第12章)、 それで割れば「遠いほど小さい」になります。

src/lessons/30-blending-particles/03-points.vert(抜粋)
  vec4 clip = u_projection * u_view * u_model * vec4(a_position, 1.0);
  gl_Position = clip;

  // 透視射影では clip.w がカメラからの距離に比例する(第12章)。
  // 割れば「遠いほど小さい」= 立体としてもっともらしい大きさになる。
  // gl_PointSize に何も書かないと点の大きさは未定義なので(本文 5 節)、
  // どちらの枝でも必ず書く
  gl_PointSize = u_attenuate == 1 ? u_pointSize * REFERENCE_DISTANCE / clip.w : u_pointSize;

点の中を gl_PointCoord で塗り分ける

ラスタライズの規則も §3.4.1 に書いてあります。「点のラスタライズは、点の (xw, yw) を中心とし、一辺が現在の点の大きさに等しい正方形の内側に中心があるフレームバッファのピクセルごとに、フラグメントを 1 つ生成する」。三角形のときと同じ「ピクセルの 中心が入っているか」判定です。

生成されたフラグメントは、すべて同じ頂点の値を受け取ります。varying の補間は 起きません(1 頂点しかないので当然です)。その代わりに、点の中のどこにいるかを教えてくれる 組み込み変数がgl_PointCoordです。第5章の組み込み変数の表に「POINTS 描画時の、点の中での 0〜1 の位置」として載せてありますが、肝心の向きは書いてありませんでした。ES 3.0.6 §3.4.1 の定義はこうです。「s は点を横切って左から右へ0 から 1 まで変化し、t は点を横切って上から下へ0 から 1 まで変化する」。

円に切り抜くには、中心からの距離をsmoothstepに通します(第8章の道具)。ここで 1 つ、向きの話があります。「中心が 1・縁が 0」にしたくてsmoothstep(大, 小, d)と引数を入れ替えたくなりますが、GLSL ES 3.00 §8.3 は「edge0 >= edge1のとき結果は未定義」と定めています。反転したいときは第8章以来ずっと使ってきた1.0 - smoothstep(小, 大, d)の形にしてください。この節がgl_PointSizeについて言っているのと同じ種類の「未定義」です。

src/lessons/30-blending-particles/03-points.frag(抜粋)
  // gl_PointCoord は点の中での 0〜1。s は左から右へ、t は上から下へ増える。
  // t が gl_FragCoord.y と逆向きであることに注意(本文 5 節)
  vec2 p = gl_PointCoord - 0.5;

  if (u_round == 1) {
    float d = length(p) * 2.0;
    // 向きの反転は引数の入れ替えではなく 1.0 - で書く。smoothstep は
    // edge0 >= edge1 のとき結果が未定義(GLSL ES 3.00 §8.3)
    float mask = 1.0 - smoothstep(1.0 - EDGE, 1.0, d);
    if (mask <= 0.0) {
      discard; // 円の外は捨てる。正方形のフラグメントは作られている(本文 5 節)
    }
    fragColor = vec4(linearToSrgb(v_color * mask), 1.0);
    return;
  }

縁のぼかし幅は gl_PointCoord 上の固定の割合(EDGE)にしていて、fwidth(第8章)は使っていません。fwidthを使えばぼかし幅を見かけ 1 ピクセルに揃えられますが、この章は点の大きさが点ごとに変わるので、 そうすると小さい点ほど「点全体に対して」縁が太くぼけることになります。割合で 決めておけば、どの点も同じ縁の見え方に揃います。

点だけに効くクリップ規則

もう 1 つ、点には固有の規則があります。ES 3.0.6 §2.18 の 1 文です。「考慮しているプリミティブが点である場合、それがニアとファーのクリップ面の内側にあればクリッピングは それを変更せずに通し、そうでなければ破棄する」。

三角形なら x と y の側面でも切られますが、点は前後だけです。だから中心が画面の 外へ出た大きな点も、破棄されずにラスタライズされ、画面にかかっている部分は描かれます。逆に 言うと、中心がニアクリップ面より手前へ来た瞬間に、点は丸ごと消えます。大きな点が カメラに近づいたときに、画面を覆う手前でふっと消えるのはこのためです。

仕様がそう定めているうえに、手元の環境でもそのとおりになりました。200×200 の canvas にgl_PointSize = 64の点を 1 つ描き、readPixelsで赤い画素を数えたものです。「幾何から予想される値」は、§3.4.1 のラスタライズ規則(中心が 正方形の内側にあるピクセル)から手で数えた値です。

点の中心 (NDC)窓座標赤い画素の実測幾何から予想される値
(0, 0) 画面中央(100, 100)409664 × 64 = 4096
(−1.1, 0) 左端の外x = −101408見えるのは x = 0〜21 の 22 列 × 64 行 = 1408
(−2.0, 0) 完全に外x = −1000範囲 −132〜−68 は全部画面外
(0, 1.2) 上端の外y = 220768見えるのは y = 188〜199 の 12 行 × 64 列 = 768

4 例とも一致しました。中心が左右上下の外にあっても点は捨てられず、はみ出しているぶんだけが 無くなる。「見えている部分が描かれる」という言い方が、画素数の水準で正しいと いうことです。ここも 1 環境での確認なので、仕様の側(§2.18 と §3.4.1)が根拠で、実測はその 裏取りだと思ってください。

ポイントスプライトの限界

gl.POINTSで描いた点はポイントスプライト (point sprite)とも呼ばれます。安上がりですが、できないこともはっきりしています。

向きのある形を大量に出したいなら、別の道具が要ります。それが次章のインスタンシングです。

6. パーティクルを CPU で動かす — bufferSubData と転送量

デモ 4 は、粒子の状態を JavaScript の Float32Array に持ち、毎フレーム更新して GPU へ送っています。いちばん素朴で、いちばん分かりやすいやり方です。

バッファは gl.DYNAMIC_DRAW で作ります。領域は最大粒子数ぶん最初に確保しておき、 以降は gl.bufferSubData で中身だけを上書きします。

src/lessons/30-blending-particles/main.ts(抜粋)
  // 中身は毎フレーム書き換えるので DYNAMIC_DRAW。ただしこれは「ヒント」でしかない(本文 6 節)。
  // 領域は最大粒子数ぶん確保しておき、以降は bufferSubData で上書きする
  gl.bufferData(gl.ARRAY_BUFFER, particleData.byteLength, gl.DYNAMIC_DRAW);
  const PARTICLE_STRIDE = FLOATS_PER_PARTICLE * Float32Array.BYTES_PER_ELEMENT;
  gl.enableVertexAttribArray(0); // a_position
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, PARTICLE_STRIDE, 0);
  gl.enableVertexAttribArray(1); // a_life
  gl.vertexAttribPointer(
    1,
    1,
    gl.FLOAT,
    false,
    PARTICLE_STRIDE,
    3 * Float32Array.BYTES_PER_ELEMENT,
  );

毎フレームの仕事は 3 段階です。CPU で全粒子を更新し、更新した範囲を送り、加算合成で描く。

src/lessons/30-blending-particles/main.ts(抜粋)
        // ① CPU で全粒子を更新する。粒子数に比例した仕事(本文 6 節)
        updateParticles(deltaSeconds);

        // ② 更新した範囲だけを GPU へ送る
        gl.bindVertexArray(particleVao);
        gl.bindBuffer(gl.ARRAY_BUFFER, particleVbo);
        const upload = particleData.subarray(0, particleCount * FLOATS_PER_PARTICLE);
        gl.bufferSubData(gl.ARRAY_BUFFER, 0, upload);
        uploadedBytes = upload.byteLength;
src/lessons/30-blending-particles/main.ts(抜粋)
  function updateParticles(deltaSeconds: number): void {
    const decay = deltaSeconds / PARTICLE_LIFE_SPAN;
    for (let i = 0; i < particleCount; i++) {
      const base = i * FLOATS_PER_PARTICLE;
      const life = particleData[base + 3] - decay;
      if (life <= 0) {
        respawnParticle(i, 1);
        continue;
      }
      particleVelocities[i * 3 + 1] -= PARTICLE_GRAVITY * deltaSeconds;
      particleData[base] += particleVelocities[i * 3] * deltaSeconds;
      particleData[base + 1] += particleVelocities[i * 3 + 1] * deltaSeconds;
      particleData[base + 2] += particleVelocities[i * 3 + 2] * deltaSeconds;
      particleData[base + 3] = life;
    }
  }

数えられる量: 1 フレームあたりの転送バイト数

この節の主役は、GPU が速いか遅いかではなく数えられる量です(コストの語り方は 第26章で決めた流儀。GPU 時間とその測り方は第35章)。

GPU へ送っているのは、粒子 1 個につき位置 3 float + 寿命 1 float = 4 floatだけです。速度は CPU 側の配列にしか無く、GPU は知りません。Float32Arrayの 1 要素は 4 バイトなので、1 粒子 = 16 バイト。あとは掛け算です。

粒子数1 フレームの転送量(粒子数 × 4 float × 4 B)60 フレーム/秒なら
2,00032,000 B(31.3 KiB)1.8 MiB/秒
8,000128,000 B(125.0 KiB)7.3 MiB/秒
32,000512,000 B(500.0 KiB)29.3 MiB/秒

読み出し行に出ているのはこの計算を実際に走らせた値で、bufferSubDataに渡したsubarrayのbyteLengthをそのまま表示しています。ドローコールは何粒子でも 1 回、bufferSubDataも 1 回。それでも粒子数に正比例して増える量が 2 つ残ります。

粒子を 32,000 から 320,000 に増やせば、どちらも 10 倍になります。そしてこの 2 つは、どちらも「状態が CPU 側にある」ことから来ています。位置と速度を GPU のバッファに置いたまま、GPU 自身に更新させられれば、転送も CPU のループも丸ごと消える。それが第32章の Transform Feedback です。

7. パイプライン図にブレンド段を足す

第3章で最初に描いたパイプライン図の図注に、「実際にはフラグメントシェーダーの後に深度テストや ブレンドといった段もあり、後の章で図に描き足していきます」と書きました。第13章 7 節で深度テストを 足し、ブレンドは点線のまま「第30章で追記」と残してありました。約束を果たします。

頂点データ(VBO + IBO)CPU から転送頂点シェーダー頂点ごとに実行ラスタライザカリングはここフラグメントシェーダーピクセルごとに実行深度テスト負けたら捨てる第13章で追加ブレンドソースとデスティネーションこの章で追加フレームバッファ色 + 深度= 自分が GLSL で書く GPU プログラム。ほかは固定機能(状態で挙動を選ぶ)
完成版のパイプライン図。第3章 → 第13章 → この章の 3 回で描き足してきました。点線の段はもう ありません。深度テストとブレンドはどちらもフラグメントシェーダーの後で、 この順に並びます。

ただし、この図はこの本で扱う段だけを並べたものです。ES 3.0.6 §4.1 「Per-Fragment Operations」が定めている実際の並びは、次のとおりです(仕様の節番号順 = 実行順)。

§段この本での扱い
4.1.1ピクセル所有権テストウィンドウシステムの管轄。FBO へ描くときは常に通る。扱わない
4.1.2シザーテスト第2章 6 節(gl.scissor)
4.1.3マルチサンプルのフラグメント操作SAMPLE_ALPHA_TO_COVERAGE など。扱わない
4.1.4ステンシルテスト扱わない(第2章 2 節のコンテキスト属性 stencil は既定 false)
4.1.5深度バッファテスト第13章
4.1.6オクルージョンクエリ深度テストを通ったフラグメント(サンプル)が 1 つでもあったかを真偽値で返す仕組み。ES 3.0 / WebGL2 のターゲットはANY_SAMPLES_PASSEDとANY_SAMPLES_PASSED_CONSERVATIVEの 2 つで、件数は取れない。扱わない
4.1.7ブレンドこの章
4.1.8sRGB 変換描画先の色符号化が SRGB のときだけ働く自動変換。この本はlinearToSrgbを自分で書く流儀(第27章 2 節)
4.1.9ディザ2 つの表現可能な値から選ぶ固定機能。初期状態で有効。第27章 8 節で自分で掛けたディザとは別物
4.1.10マルチサンプル時の追加処理SAMPLE_BUFFERSが 1 のとき、ステンシル・深度・ブレンド・ディザをピクセルのサンプルごとに行う (1 節の aside で引いた条文)。扱わない(デモはantialiasを既定のまま画面へ描くだけ)
4.2.2書き込みマスクcolorMask/ depthMask(4 節)/ stencilMask。 「すべてのフラグメントごとの操作の後」に効く

図に描いた「深度テスト → ブレンド → フレームバッファ」は、この表の骨格を抜き出したものです。 もう 1 つ、第13章 7 節の補足も生きています — 実際の GPU は条件が揃えば深度テストを シェーダーの前へ前倒しします(early-Z)。ただしブレンドは前倒しできません。ソースの色が要るからで、色を出すにはフラグメントシェーダーを走らせるしかありません。 半透明を大量に重ねると重くなるのは、この段が最後まで残るせいでもあります。

コード全文

CPU 側は第13章と同じ「メッシュ + 軌道カメラ + 切替ボタン」の構成に、デモごとの オプションボタンを足した形です。ブレンドと深度の状態はデモの draw の中で毎フレーム明示的に設定しています— 状態機械なので、前のデモが残した設定が効き続けるのを避けるためです。読み出し行のblendStateText/depthStateTextは、その結果をgl.getParameterで実際に読み返して表示しています。

src/lessons/30-blending-particles/main.ts
// 第30章: ブレンディングとパーティクル — 加算合成と深度の兼ね合い
//
// 1 つの canvas で 4 本のデモを切り替える。
//
//   1. ブレンド式    — 半透明の板 3 枚を、blendFunc の組み合わせを変えながら重ねる
//   2. 半透明と深度  — 不透明なトーラス + 半透明の板。depthMask と描画順の効き方
//   3. 点を描く      — gl.POINTS と gl_PointSize / gl_PointCoord
//   4. CPU パーティクル — Float32Array を毎フレーム bufferSubData で送り、加算合成で描く
//
// ブレンドも深度書き込みも「状態」なので(第1章の状態機械)、切り替えは
// gl.enable / gl.blendFunc / gl.depthMask の掛け替えだけで済む。シェーダーは差し替えない。
//
// 色の扱い: どのシェーダーも色をリニア空間で組み立て、出口で linearToSrgb を 1 回だけ通す
// (第27章の約束)。したがってフレームバッファに入るのは sRGB エンコード値で、
// ブレンド段はその格納値の上で足し引きする。この選択の意味は解説ページの 2 節を参照。

import { mat3, mat4, type ReadonlyMat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { createPlane, createTorus, type Geometry, interleave } from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import blendFragmentTemplate from './01-blend.frag?raw';
import blendVertexSource from './01-blend.vert?raw';
import opaqueFragmentTemplate from './02-opaque.frag?raw';
import opaqueVertexSource from './02-opaque.vert?raw';
import pointsFragmentTemplate from './03-points.frag?raw';
import pointsVertexSource from './03-points.vert?raw';
import particlesFragmentTemplate from './04-particles.frag?raw';
import particlesVertexSource from './04-particles.vert?raw';
import colorChunkSource from './color.glsl?raw';

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

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

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

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

const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
// canvas の CSS 背景色と同じ。これは「画面に出る sRGB 値」なので、そのまま clearColor へ渡す
const BACKGROUND_COLOR: ReadonlyVec3 = vec3.fromValues(0.06, 0.07, 0.09);
// 面から光源へ向かう単位ベクトル・ワールド空間(第17章の約束)
const LIGHT_DIRECTION: ReadonlyVec3 = vec3.normalize(
  vec3.create(),
  vec3.fromValues(0.35, 0.7, 0.6),
);

// デモ 3 の格子
const LATTICE_SIDE = 9; // 9³ = 729 点
const LATTICE_EXTENT = 2.2;
// 頂点シェーダーへ渡す基準の大きさ(ピクセル)。読み出し行にも同じ変数から出す
const LATTICE_POINT_SIZE = 11;
const PARTICLE_POINT_SIZE = 15;

// デモ 4 のパーティクル
const PARTICLE_COUNTS = [2000, 8000, 32000] as const;
const MAX_PARTICLES = PARTICLE_COUNTS[PARTICLE_COUNTS.length - 1];
// 1 粒子ぶんの属性 = 位置 3 float + 寿命 1 float。この 4 が転送量の主役(本文 6 節)
const FLOATS_PER_PARTICLE = 4;
const PARTICLE_GRAVITY = 5.2;
const PARTICLE_LIFE_SPAN = 2.6; // 秒
const PARTICLE_SPEED = 4.6;
// タブ復帰などで巨大な dt が来たときに粒子を吹き飛ばさないための上限(秒)
const MAX_DELTA = 1 / 20;

// ---------------------------------------------------------------------------
// GL の定数名を引くための表(読み出し行に「そのままの定数名」を出すため)
// ---------------------------------------------------------------------------

const BLEND_FACTOR_NAMES = [
  'ZERO',
  'ONE',
  'SRC_COLOR',
  'ONE_MINUS_SRC_COLOR',
  'DST_COLOR',
  'ONE_MINUS_DST_COLOR',
  'SRC_ALPHA',
  'ONE_MINUS_SRC_ALPHA',
  'DST_ALPHA',
  'ONE_MINUS_DST_ALPHA',
  'CONSTANT_COLOR',
  'ONE_MINUS_CONSTANT_COLOR',
  'CONSTANT_ALPHA',
  'ONE_MINUS_CONSTANT_ALPHA',
  'SRC_ALPHA_SATURATE',
] as const;

const BLEND_EQUATION_NAMES = [
  'FUNC_ADD',
  'FUNC_SUBTRACT',
  'FUNC_REVERSE_SUBTRACT',
  'MIN',
  'MAX',
] as const;

/** GLenum の値から定数名を引く表を作る。gl から実際の値を読むので、書き間違いが起きない */
function buildEnumNames(gl: WebGL2RenderingContext, names: readonly string[]): Map<GLenum, string> {
  const table = new Map<GLenum, string>();
  for (const name of names) {
    const value = (gl as unknown as Record<string, number | undefined>)[name];
    if (typeof value === 'number') {
      table.set(value, name);
    }
  }
  return table;
}

// ---------------------------------------------------------------------------
// メッシュ(第13〜15章の手順そのまま)
// ---------------------------------------------------------------------------

interface Mesh {
  vao: WebGLVertexArrayObject;
  indexCount: number;
}

function createMesh(gl: WebGL2RenderingContext, geometry: Geometry): Mesh {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
  const stride = 8 * FLOAT_BYTES; // 1 頂点 = 位置 3 + 法線 3 + UV 2
  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, interleave(geometry), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0); // a_position
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
  gl.enableVertexAttribArray(1); // a_normal
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * FLOAT_BYTES);
  gl.enableVertexAttribArray(2); // a_uv
  gl.vertexAttribPointer(2, 2, gl.FLOAT, false, stride, 6 * FLOAT_BYTES);

  const ibo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);

  gl.bindVertexArray(null);
  return { vao, indexCount: geometry.indices.length };
}

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

// ---------------------------------------------------------------------------
// 半透明の板(デモ 1・2 で共用)
// ---------------------------------------------------------------------------

interface Panel {
  /** 板の中心(ワールド) */
  center: vec3;
  /** Y 軸まわりの傾き */
  yaw: number;
  color: ReadonlyVec3;
  alpha: number;
}

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  demoControls: HTMLParagraphElement,
  optionControls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  const factorNames = buildEnumNames(gl, BLEND_FACTOR_NAMES);
  const equationNames = buildEnumNames(gl, BLEND_EQUATION_NAMES);

  // --- プログラム 4 本 -------------------------------------------------------

  const link = (vertexSource: string, fragmentTemplate: string): WebGLProgram =>
    linkProgram(
      gl,
      compileShader(gl, gl.VERTEX_SHADER, vertexSource),
      compileShader(gl, gl.FRAGMENT_SHADER, resolveIncludes(fragmentTemplate)),
    );

  const blendProgram = link(blendVertexSource, blendFragmentTemplate);
  const opaqueProgram = link(opaqueVertexSource, opaqueFragmentTemplate);
  const pointsProgram = link(pointsVertexSource, pointsFragmentTemplate);
  const particlesProgram = link(particlesVertexSource, particlesFragmentTemplate);

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

  const blendLocations = {
    model: at(blendProgram, 'u_model'),
    view: at(blendProgram, 'u_view'),
    projection: at(blendProgram, 'u_projection'),
    color: at(blendProgram, 'u_color'),
    alpha: at(blendProgram, 'u_alpha'),
    premultiply: at(blendProgram, 'u_premultiply'),
  };
  const opaqueLocations = {
    model: at(opaqueProgram, 'u_model'),
    view: at(opaqueProgram, 'u_view'),
    projection: at(opaqueProgram, 'u_projection'),
    normalMatrix: at(opaqueProgram, 'u_normalMatrix'),
    color: at(opaqueProgram, 'u_color'),
    lightDirection: at(opaqueProgram, 'u_lightDirection'),
    checker: at(opaqueProgram, 'u_checker'),
  };
  const pointsLocations = {
    model: at(pointsProgram, 'u_model'),
    view: at(pointsProgram, 'u_view'),
    projection: at(pointsProgram, 'u_projection'),
    pointSize: at(pointsProgram, 'u_pointSize'),
    attenuate: at(pointsProgram, 'u_attenuate'),
    round: at(pointsProgram, 'u_round'),
  };
  const particlesLocations = {
    view: at(particlesProgram, 'u_view'),
    projection: at(particlesProgram, 'u_projection'),
    pointSize: at(particlesProgram, 'u_pointSize'),
  };

  // --- メッシュ ---------------------------------------------------------------

  const panelMesh = createMesh(gl, createPlane(2.6, 2.6));
  const backdropMesh = createMesh(gl, createPlane(11, 7));
  const torusMesh = createMesh(gl, createTorus(1.15, 0.42, 64, 28));

  const panels: Panel[] = [
    {
      center: vec3.fromValues(-0.85, 0.35, -0.9),
      yaw: 0.32,
      color: vec3.fromValues(0.72, 0.13, 0.16),
      alpha: 0.55,
    },
    {
      center: vec3.fromValues(0.0, -0.2, 0.0),
      yaw: -0.18,
      color: vec3.fromValues(0.1, 0.5, 0.28),
      alpha: 0.55,
    },
    {
      center: vec3.fromValues(0.85, 0.3, 0.9),
      yaw: 0.24,
      color: vec3.fromValues(0.13, 0.3, 0.72),
      alpha: 0.55,
    },
  ];

  // --- デモ 3: 点の格子 -------------------------------------------------------

  const latticeCount = LATTICE_SIDE ** 3;
  const latticePositions = new Float32Array(latticeCount * 3);
  {
    let i = 0;
    for (let z = 0; z < LATTICE_SIDE; z++) {
      for (let y = 0; y < LATTICE_SIDE; y++) {
        for (let x = 0; x < LATTICE_SIDE; x++) {
          const t = (v: number): number => ((v / (LATTICE_SIDE - 1)) * 2 - 1) * LATTICE_EXTENT;
          latticePositions[i++] = t(x);
          latticePositions[i++] = t(y);
          latticePositions[i++] = t(z);
        }
      }
    }
  }
  const latticeVao = gl.createVertexArray();
  gl.bindVertexArray(latticeVao);
  const latticeVbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, latticeVbo);
  gl.bufferData(gl.ARRAY_BUFFER, latticePositions, gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
  gl.bindVertexArray(null);

  // この環境が実際に対応している点の大きさ。仕様が要求する最大値の下限は 1 しかない(本文 5 節)
  const pointSizeRange = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) as Float32Array;

  // --- デモ 4: パーティクル ---------------------------------------------------

  // GPU へ送る配列。1 粒子 = [x, y, z, life] のインターリーブ(第13章)
  const particleData = new Float32Array(MAX_PARTICLES * FLOATS_PER_PARTICLE);
  // 速度は CPU 側だけで持つ。GPU は知らなくてよい
  const particleVelocities = new Float32Array(MAX_PARTICLES * 3);
  const particleRandom = createRandom(30303030);

  /** 粒子 i を噴出口へ戻す。life は 1(生まれたて)から 0 へ減っていく */
  function respawnParticle(index: number, life: number): void {
    const base = index * FLOATS_PER_PARTICLE;
    particleData[base] = (particleRandom() - 0.5) * 0.18;
    particleData[base + 1] = 0;
    particleData[base + 2] = (particleRandom() - 0.5) * 0.18;
    particleData[base + 3] = life;

    // 上向きの円錐にばらす(第10章の極座標)
    const azimuth = particleRandom() * Math.PI * 2;
    const spread = 0.16 + particleRandom() * 0.42;
    const speed = PARTICLE_SPEED * (0.62 + particleRandom() * 0.38);
    particleVelocities[index * 3] = Math.cos(azimuth) * spread * speed;
    particleVelocities[index * 3 + 1] = speed;
    particleVelocities[index * 3 + 2] = Math.sin(azimuth) * spread * speed;
  }

  for (let i = 0; i < MAX_PARTICLES; i++) {
    // 最初から寿命をばらしておかないと、全粒子が同じタイミングで一斉に消える
    respawnParticle(i, particleRandom());
  }

  const particleVao = gl.createVertexArray();
  gl.bindVertexArray(particleVao);
  const particleVbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, particleVbo);
  // 中身は毎フレーム書き換えるので DYNAMIC_DRAW。ただしこれは「ヒント」でしかない(本文 6 節)。
  // 領域は最大粒子数ぶん確保しておき、以降は bufferSubData で上書きする
  gl.bufferData(gl.ARRAY_BUFFER, particleData.byteLength, gl.DYNAMIC_DRAW);
  const PARTICLE_STRIDE = FLOATS_PER_PARTICLE * Float32Array.BYTES_PER_ELEMENT;
  gl.enableVertexAttribArray(0); // a_position
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, PARTICLE_STRIDE, 0);
  gl.enableVertexAttribArray(1); // a_life
  gl.vertexAttribPointer(
    1,
    1,
    gl.FLOAT,
    false,
    PARTICLE_STRIDE,
    3 * Float32Array.BYTES_PER_ELEMENT,
  );
  gl.bindVertexArray(null);

  let particleCount: number = PARTICLE_COUNTS[1];
  let uploadedBytes = 0;

  /** CPU 側の更新。粒子数に比例して重くなるのはここと、次の bufferSubData(本文 6 節) */
  function updateParticles(deltaSeconds: number): void {
    const decay = deltaSeconds / PARTICLE_LIFE_SPAN;
    for (let i = 0; i < particleCount; i++) {
      const base = i * FLOATS_PER_PARTICLE;
      const life = particleData[base + 3] - decay;
      if (life <= 0) {
        respawnParticle(i, 1);
        continue;
      }
      particleVelocities[i * 3 + 1] -= PARTICLE_GRAVITY * deltaSeconds;
      particleData[base] += particleVelocities[i * 3] * deltaSeconds;
      particleData[base + 1] += particleVelocities[i * 3 + 1] * deltaSeconds;
      particleData[base + 2] += particleVelocities[i * 3 + 2] * deltaSeconds;
      particleData[base + 3] = life;
    }
  }

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

  const orbit = { theta: 0.5, phi: 0.28, radius: 7.5 };
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 3.5;
  const MAX_RADIUS = 22;

  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 model = mat4.create();
  const view = mat4.create();
  const projection = mat4.create();
  const normalMatrix = mat3.create();
  const panelOrder = [0, 1, 2];
  const panelViewDepth = [0, 0, 0];

  const setVec3 = (location: WebGLUniformLocation | null, v: ReadonlyVec3): void => {
    gl.uniform3f(location, v[0], v[1], v[2]);
  };

  // --- 描画の部品 ---------------------------------------------------------------

  function drawMesh(mesh: Mesh): void {
    gl.bindVertexArray(mesh.vao);
    gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
  }

  function useOpaque(): void {
    gl.useProgram(opaqueProgram);
    gl.uniformMatrix4fv(opaqueLocations.view, false, view);
    gl.uniformMatrix4fv(opaqueLocations.projection, false, projection);
    setVec3(opaqueLocations.lightDirection, LIGHT_DIRECTION);
  }

  function drawOpaque(
    mesh: Mesh,
    modelMatrix: ReadonlyMat4,
    color: ReadonlyVec3,
    checker: boolean,
  ): void {
    mat3.normalFromMat4(normalMatrix, modelMatrix); // 法線行列(第17章)
    gl.uniformMatrix4fv(opaqueLocations.model, false, modelMatrix); // transpose は常に false
    gl.uniformMatrix3fv(opaqueLocations.normalMatrix, false, normalMatrix);
    setVec3(opaqueLocations.color, color);
    gl.uniform1i(opaqueLocations.checker, checker ? 1 : 0);
    drawMesh(mesh);
  }

  function usePanels(premultiply: boolean): void {
    gl.useProgram(blendProgram);
    gl.uniformMatrix4fv(blendLocations.view, false, view);
    gl.uniformMatrix4fv(blendLocations.projection, false, projection);
    gl.uniform1i(blendLocations.premultiply, premultiply ? 1 : 0);
  }

  function drawPanel(panel: Panel, time: number): void {
    mat4.fromTranslation(model, [
      panel.center[0],
      panel.center[1] + Math.sin(time * 0.6 + panel.center[0]) * 0.12,
      panel.center[2],
    ]);
    mat4.rotateY(model, model, panel.yaw + Math.sin(time * 0.35 + panel.center[2]) * 0.1);
    gl.uniformMatrix4fv(blendLocations.model, false, model);
    setVec3(blendLocations.color, panel.color);
    gl.uniform1f(blendLocations.alpha, panel.alpha);
    drawMesh(panelMesh);
  }

  /**
   * 板を「奥から手前へ」または「手前から奥へ」並べ替える。
   * 毎フレーム CPU でソートする — この 3 枚では一瞬だが、コストは枚数に対して
   * N log N で増える(本文 4 節)
   */
  function sortPanels(farToNear: boolean): void {
    for (let i = 0; i < panels.length; i++) {
      const c = panels[i].center;
      // ビュー空間の z(カメラ前方が負)。小さいほど遠い
      panelViewDepth[i] = view[2] * c[0] + view[6] * c[1] + view[10] * c[2] + view[14];
    }
    panelOrder.sort((a, b) =>
      farToNear ? panelViewDepth[a] - panelViewDepth[b] : panelViewDepth[b] - panelViewDepth[a],
    );
  }

  // --- ブレンドのプリセット(解説ページ 2 節の表の 4 つ + 「ブレンドなし」) -----------
  // 並び順は表とは変えてある。乗算済みアルファ(3 節)を通常アルファの隣に置いて、
  // 「絵が変わらない」ことをボタン 1 つで見比べられるようにするため

  interface BlendPreset {
    label: string;
    /** null なら gl.disable(gl.BLEND) */
    func: readonly [GLenum, GLenum] | null;
    /** ソースの RGB にアルファを掛けてから出すか(3 節) */
    premultiply: boolean;
  }

  const blendPresets: readonly BlendPreset[] = [
    { label: 'ブレンドなし', func: null, premultiply: false },
    { label: '通常アルファ', func: [gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA], premultiply: false },
    { label: '乗算済みアルファ', func: [gl.ONE, gl.ONE_MINUS_SRC_ALPHA], premultiply: true },
    { label: '加算', func: [gl.SRC_ALPHA, gl.ONE], premultiply: false },
    { label: '乗算', func: [gl.DST_COLOR, gl.ZERO], premultiply: false },
  ];

  function applyBlend(preset: BlendPreset): void {
    if (preset.func === null) {
      gl.disable(gl.BLEND);
      return;
    }
    gl.enable(gl.BLEND);
    gl.blendEquation(gl.FUNC_ADD);
    gl.blendFunc(preset.func[0], preset.func[1]);
  }

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

  const FRAME_SAMPLES = 30;
  const frameTimes: number[] = [];

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

  /** いま GL に入っているブレンド・深度の状態を、そのままの定数名で読み出す */
  function blendStateText(): string {
    if (!gl.getParameter(gl.BLEND)) {
      return 'BLEND 無効(ソースがそのまま上書き)';
    }
    const equation = equationNames.get(gl.getParameter(gl.BLEND_EQUATION_RGB)) ?? '?';
    const src = factorNames.get(gl.getParameter(gl.BLEND_SRC_RGB)) ?? '?';
    const dst = factorNames.get(gl.getParameter(gl.BLEND_DST_RGB)) ?? '?';
    return `BLEND 有効 / blendEquation(${equation}) / blendFunc(${src}, ${dst})`;
  }

  function depthStateText(): string {
    const test = gl.getParameter(gl.DEPTH_TEST) ? '有効' : '無効';
    const mask = gl.getParameter(gl.DEPTH_WRITEMASK) ? 'true' : 'false';
    return `DEPTH_TEST ${test} / depthMask(${mask})`;
  }

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

  interface Demo {
    label: string;
    /** #option-buttons に並べるラベル。空ならボタン列そのものを隠す */
    options: readonly string[];
    /** カメラの注視点と、このデモへ切り替えたときの距離 */
    target: ReadonlyVec3;
    radius: number;
    draw(time: number, deltaSeconds: number, option: number): void;
    readout(option: number): string;
  }

  const demos: readonly Demo[] = [
    {
      label: 'ブレンド式',
      options: blendPresets.map((preset) => preset.label),
      target: vec3.fromValues(0, 0, 0),
      radius: 7.5,
      draw(time, _delta, option) {
        const preset = blendPresets[option];

        // ① 不透明な背景板を先に、深度書き込みありで描く
        gl.enable(gl.DEPTH_TEST);
        gl.depthMask(true);
        gl.disable(gl.BLEND);
        useOpaque();
        mat4.fromTranslation(model, [0, 0, -2.6]);
        drawOpaque(backdropMesh, model, vec3.fromValues(0.16, 0.17, 0.2), true);

        // ② 半透明の板を後から、深度書き込みなし・奥から手前へ(本文 4 節)
        gl.depthMask(false);
        applyBlend(preset);
        usePanels(preset.premultiply);
        sortPanels(true);
        for (const index of panelOrder) {
          drawPanel(panels[index], time);
        }
      },
      readout() {
        return `${blendStateText()} / ${depthStateText()} / 板 3 枚を奥から手前へ`;
      },
    },
    {
      label: '半透明と深度',
      options: [
        '書き込みあり・手前→奥',
        '書き込みなし・手前→奥',
        '書き込みなし・奥→手前',
        '加算・並べ替えなし',
      ],
      target: vec3.fromValues(0, 0, 0),
      radius: 6.4,
      draw(time, _delta, option) {
        const additive = option === 3;

        // ① 不透明なトーラス(深度書き込みあり・ブレンドなし)
        gl.enable(gl.DEPTH_TEST);
        gl.depthMask(true);
        gl.disable(gl.BLEND);
        useOpaque();
        mat4.fromYRotation(model, time * 0.25);
        mat4.rotateX(model, model, 0.42);
        drawOpaque(torusMesh, model, vec3.fromValues(0.5, 0.42, 0.3), false);

        // ② 半透明の板。ここが 4 節の実験台。
        // 4 つのプリセットは「深度書き込み」「描画順」「ブレンド式」を 1 つずつ変えていく
        gl.depthMask(option === 0);
        gl.enable(gl.BLEND);
        gl.blendEquation(gl.FUNC_ADD);
        gl.blendFunc(gl.SRC_ALPHA, additive ? gl.ONE : gl.ONE_MINUS_SRC_ALPHA);
        usePanels(false);
        if (additive) {
          // 加算は順序に依存しないので、並べ替えずに配列の順のまま描く
          for (const panel of panels) {
            drawPanel(panel, time);
          }
        } else {
          sortPanels(option === 2);
          for (const index of panelOrder) {
            drawPanel(panels[index], time);
          }
        }
      },
      readout(option) {
        const order = option === 3 ? '並べ替えなし(配列順)' : option === 2 ? '奥→手前' : '手前→奥';
        return `${blendStateText()} / ${depthStateText()} / 板 3 枚 ${order}`;
      },
    },
    {
      label: '点を描く',
      options: ['四角のまま', '円に切り抜く', '円 + 大きさを距離で変える'],
      target: vec3.fromValues(0, 0, 0),
      radius: 8.5,
      draw(time, _delta, option) {
        gl.enable(gl.DEPTH_TEST);
        gl.depthMask(true);
        gl.disable(gl.BLEND); // このデモだけはブレンドを使わない
        gl.useProgram(pointsProgram);
        mat4.fromYRotation(model, time * 0.18);
        gl.uniformMatrix4fv(pointsLocations.model, false, model);
        gl.uniformMatrix4fv(pointsLocations.view, false, view);
        gl.uniformMatrix4fv(pointsLocations.projection, false, projection);
        gl.uniform1f(pointsLocations.pointSize, LATTICE_POINT_SIZE);
        gl.uniform1i(pointsLocations.attenuate, option === 2 ? 1 : 0);
        gl.uniform1i(pointsLocations.round, option === 0 ? 0 : 1);

        gl.bindVertexArray(latticeVao);
        // 1 頂点 = 1 点。格子ぜんぶが 1 回のドローコールで出る
        gl.drawArrays(gl.POINTS, 0, latticeCount);
      },
      readout(option) {
        const size =
          option === 2
            ? `gl_PointSize = ${LATTICE_POINT_SIZE}.0 × REFERENCE_DISTANCE / gl_Position.w`
            : `gl_PointSize = ${LATTICE_POINT_SIZE}.0(一定)`;
        const shape = option === 0 ? '四角(切り抜きなし)' : 'gl_PointCoord で円に切り抜き';
        return (
          `gl.POINTS ${latticeCount} 点 / ドローコール 1 回 / ${size} / ${shape} / ` +
          `ALIASED_POINT_SIZE_RANGE = [${pointSizeRange[0]}, ${pointSizeRange[1]}]`
        );
      },
    },
    {
      label: 'CPU パーティクル',
      options: PARTICLE_COUNTS.map((count) => `粒子 ${count.toLocaleString('en-US')}`),
      target: vec3.fromValues(0, 1.9, 0),
      radius: 11,
      draw(_time, deltaSeconds, option) {
        particleCount = PARTICLE_COUNTS[option];

        // ① CPU で全粒子を更新する。粒子数に比例した仕事(本文 6 節)
        updateParticles(deltaSeconds);

        // ② 更新した範囲だけを GPU へ送る
        gl.bindVertexArray(particleVao);
        gl.bindBuffer(gl.ARRAY_BUFFER, particleVbo);
        const upload = particleData.subarray(0, particleCount * FLOATS_PER_PARTICLE);
        gl.bufferSubData(gl.ARRAY_BUFFER, 0, upload);
        uploadedBytes = upload.byteLength;

        // ③ 加算合成で描く。深度テストは残したまま書き込みだけ切る(本文 4 節)。
        // このデモには不透明な物体が無いのでテストは実質的に素通しだが、
        // 粒どうしが互いを深度で消さないことが要点なので、この形のまま置いている。
        // 加算は順序に依存しないので、並べ替えは一切していない
        gl.enable(gl.DEPTH_TEST);
        gl.depthMask(false);
        gl.enable(gl.BLEND);
        gl.blendEquation(gl.FUNC_ADD);
        gl.blendFunc(gl.ONE, gl.ONE);

        gl.useProgram(particlesProgram);
        gl.uniformMatrix4fv(particlesLocations.view, false, view);
        gl.uniformMatrix4fv(particlesLocations.projection, false, projection);
        gl.uniform1f(particlesLocations.pointSize, PARTICLE_POINT_SIZE);
        gl.drawArrays(gl.POINTS, 0, particleCount);
      },
      readout() {
        const perParticle = FLOATS_PER_PARTICLE * Float32Array.BYTES_PER_ELEMENT;
        const ms = frameTimes.length === 0 ? '—' : `${averageFrameMs().toFixed(1)} ms`;
        return (
          `粒子 ${particleCount.toLocaleString('en-US')} / ` +
          `1 粒子 ${FLOATS_PER_PARTICLE} float × ${Float32Array.BYTES_PER_ELEMENT} B = ${perParticle} B / ` +
          `bufferSubData 1 回 = ${uploadedBytes.toLocaleString('en-US')} B/フレーム / ` +
          `${blendStateText()} / ドローコール 1 回 / ` +
          `rAF 間隔 ${ms}(CPU 側の計測。GPU 時間ではありません・第35章)`
        );
      },
    },
  ];

  let demoIndex = 0;
  const optionIndices = demos.map(() => 0);

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

  function addButtons(
    container: HTMLParagraphElement,
    labels: readonly string[],
    activeIndex: number,
    onSelect: (index: number) => void,
  ): void {
    container.replaceChildren();
    for (const [index, label] of labels.entries()) {
      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = label;
      button.setAttribute('aria-pressed', index === activeIndex ? 'true' : 'false');
      button.addEventListener('click', () => {
        onSelect(index);
        for (const other of container.querySelectorAll('button')) {
          other.setAttribute('aria-pressed', 'false');
        }
        button.setAttribute('aria-pressed', 'true');
      });
      container.append(button);
    }
  }

  /** デモを切り替えるたびにオプションのボタン列を作り直す。オプションが無ければ隠す */
  function rebuildOptionButtons(): void {
    const demo = demos[demoIndex];
    if (demo.options.length === 0) {
      optionControls.replaceChildren();
      optionControls.hidden = true;
      return;
    }
    optionControls.hidden = false;
    addButtons(optionControls, demo.options, optionIndices[demoIndex], (index) => {
      optionIndices[demoIndex] = index;
    });
  }

  addButtons(
    demoControls,
    demos.map((demo) => demo.label),
    demoIndex,
    (index) => {
      demoIndex = index;
      orbit.radius = demos[index].radius;
      rebuildOptionButtons();
    },
  );
  rebuildOptionButtons();

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

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    // 表示サイズ 0 でもバッファは最低 1px 確保する(アスペクト比が NaN になるのを防ぐ)
    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);
    }
  }

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

  let lastTimestamp = 0;
  let lastReadoutAt = 0;

  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();
    }
    const deltaSeconds = Math.min(MAX_DELTA, lastTimestamp === 0 ? 1 / 60 : delta / 1000);
    lastTimestamp = timestamp;

    const demo = demos[demoIndex];

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

    // 深度テストを使うのでクリアには DEPTH_BUFFER_BIT を含める(第13章)。
    // clear は depthMask の影響を受ける(ES 3.0.6 §4.2.3)ので、必ず true に戻してから呼ぶ。
    // ただし既定のフレームバッファでは、合成のたびにブラウザが色・深度・ステンシルを消すため
    // (preserveDrawingBuffer の既定が false)この 1 行が無くても絵は変わらない。
    // 効いてくるのは自作の FBO(第20章)なので、規則どおりの書き方を手本として残してある
    gl.depthMask(true);
    gl.clearColor(BACKGROUND_COLOR[0], BACKGROUND_COLOR[1], BACKGROUND_COLOR[2], 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    demo.draw(time, deltaSeconds, optionIndices[demoIndex]);

    if (timestamp - lastReadoutAt > 250) {
      readout.textContent = demo.readout(optionIndices[demoIndex]);
      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('デモに必要な要素が見つかりません');
}

// alpha: false でコンテキストを作る(第2章 2 節のコンテキスト属性の表)。
// この章は canvas の中のブレンドだけを見せたいので、canvas とページの合成という
// もう一段のブレンドを最初から切っておく。描画バッファにアルファ成分が無くなるので、
// premultipliedAlpha も効かなくなる(本文 3 節)。
// なお ES 3.0.6 §4.1.7.4 は「カラーバッファに A が無ければ Ad は 1 とみなす」と定めている
const gl = canvas.getContext('webgl2', { alpha: false });
if (!gl) {
  throw new Error('このブラウザは WebGL2 に対応していません');
}

setup(gl, canvas, demoControls, optionControls, readout);
src/lessons/30-blending-particles/01-blend.vert
#version 300 es

// 第30章 半透明の板(デモ 1・デモ 2 で共用)。
// ブレンドの話に集中したいので、頂点シェーダーは座標変換と UV の受け渡しだけ。

// 第3部の共通の属性配置(第14章から): 0 = 位置, 1 = 法線, 2 = UV。
// この板は法線を使わないので location = 1 は宣言していない
// (VAO 側では 3 本とも配線してある。使わない属性が流れていても問題は起きない)
layout(location = 0) in vec3 a_position;
layout(location = 2) in vec2 a_uv;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;

out vec2 v_uv;

void main() {
  v_uv = a_uv;
  gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
}
src/lessons/30-blending-particles/01-blend.frag
#version 300 es

// 第30章 半透明の板(デモ 1・デモ 2 で共用)。
// このシェーダーが決めるのは「ソースの色とアルファ」だけで、混ぜ方は決めていない。
// 混ぜ方は gl.blendFunc / gl.blendEquation という状態のほうにある(本文 1〜2 節)。

precision highp float;

// 板の色。リニア空間の値として書く(第27章の約束)
uniform vec3 u_color;
uniform float u_alpha;
// 1 なら RGB にアルファを掛けてから出す = アルファ乗算済み(本文 3 節)。
// このとき CPU 側は blendFunc(ONE, ONE_MINUS_SRC_ALPHA) に切り替える
uniform int u_premultiply;

in vec2 v_uv;

out vec4 fragColor;

#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 円板の縁をぼかす幅(直径に対する割合。第8章の smoothstep)
const float EDGE = 0.28;
// ----------------------------------------------------------------------

void main() {
  // UV の中心からの距離で円板に切り抜き、中心ほど濃くする
  float d = length(v_uv - 0.5) * 2.0;
  // smoothstep は edge0 < edge1 でしか定義されていない(GLSL ES 3.00 §8.3
  // 「Results are undefined if edge0 >= edge1.」)。向きを逆にしたいときは
  // 引数を入れ替えるのではなく、1.0 - で反転する(第8章と同じ形)
  float mask = 1.0 - smoothstep(1.0 - EDGE, 1.0, d);
  if (mask <= 0.0) {
    // 円の外は捨てる。アルファを 0 にするだけでは足りない —
    // 「ブレンドなし」と「乗算」はアルファを見ないので、四角い板がそのまま出てしまう
    discard;
  }

  vec3 linear = u_color * (0.45 + 0.55 * mask);
  float alpha = u_alpha * mask;

  // 出口で 1 回だけ sRGB へエンコードする(第27章 2 節)。
  // ここから先のブレンド段は、このエンコード値の上で足し引きする(本文 2 節の落とし穴)
  vec3 encoded = linearToSrgb(linear);

  fragColor = vec4(u_premultiply == 1 ? encoded * alpha : encoded, alpha);
}
src/lessons/30-blending-particles/02-opaque.vert
#version 300 es

// 第30章 不透明な物体(デモ 1 の背景板・デモ 2 のトーラス)。
// 半透明を語るには「向こう側」が要る。その向こう側を用意するためだけのシェーダー。

// 第3部の共通の属性配置(第14章から): 0 = 位置, 1 = 法線, 2 = UV
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_uv;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat3 u_normalMatrix; // モデル行列の左上 3×3 の逆転置(第17章)

// ライティングはワールド空間で行う(第17章の約束)
out vec3 v_normal;
out vec2 v_uv;

void main() {
  v_normal = u_normalMatrix * a_normal;
  v_uv = a_uv;
  gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
}
src/lessons/30-blending-particles/02-opaque.frag
#version 300 es

// 第30章 不透明な物体(デモ 1 の背景板・デモ 2 のトーラス)。
// 陰影は Lambert 拡散 1 灯だけ(第17章)。この章の主題はブレンドなので、材質は最小限。

precision highp float;

uniform vec3 u_color; // リニア空間の基本色
// 面から光源へ向かう単位ベクトル・ワールド空間(第17章の約束)。受け側で再正規化しない
uniform vec3 u_lightDirection;
// 1 なら UV の格子で市松に濃淡を付ける(半透明の板の向こうで模様が動くのが見える)
uniform int u_checker;

in vec3 v_normal;
in vec2 v_uv;

out vec4 fragColor;

#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 隙間が真っ黒にならない程度の環境光(リニア)。正しい扱い(IBL)は第22章
const vec3 AMBIENT = vec3(0.04, 0.045, 0.055);
// 市松の升目の数
const float CHECKER_CELLS = 8.0;
// ----------------------------------------------------------------------

void main() {
  vec3 N = normalize(v_normal); // 補間で長さが崩れるので受け側で正規化する(第17章)
  float diffuse = max(dot(N, u_lightDirection), 0.0);

  vec3 base = u_color;
  if (u_checker == 1) {
    vec2 cell = floor(v_uv * CHECKER_CELLS);
    float checker = mod(cell.x + cell.y, 2.0);
    base *= mix(0.62, 1.0, checker);
  }

  vec3 linear = base * (AMBIENT + vec3(diffuse));

  // 出口で 1 回だけ sRGB へエンコードする(第27章 2 節)
  fragColor = vec4(linearToSrgb(linear), 1.0);
}
src/lessons/30-blending-particles/03-points.vert
#version 300 es

// 第30章 デモ 3: gl.POINTS で格子を描く。
// 1 頂点が 1 つの点になり、その大きさをここで決める。

layout(location = 0) in vec3 a_position;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
// 点の大きさ(ピクセル)。ALIASED_POINT_SIZE_RANGE に clamp される(本文 5 節)
uniform float u_pointSize;
// 1 なら距離で小さくする
uniform int u_attenuate;

out vec3 v_color;

// ---- 書き換えて試すための定数 ----------------------------------------
// 距離で小さくするときの基準距離(この距離で u_pointSize になる)
const float REFERENCE_DISTANCE = 7.0;
// ----------------------------------------------------------------------

void main() {
  vec4 clip = u_projection * u_view * u_model * vec4(a_position, 1.0);
  gl_Position = clip;

  // 透視射影では clip.w がカメラからの距離に比例する(第12章)。
  // 割れば「遠いほど小さい」= 立体としてもっともらしい大きさになる。
  // gl_PointSize に何も書かないと点の大きさは未定義なので(本文 5 節)、
  // どちらの枝でも必ず書く
  gl_PointSize = u_attenuate == 1 ? u_pointSize * REFERENCE_DISTANCE / clip.w : u_pointSize;

  // 格子の位置から色を作る(リニア空間の値として扱う)
  v_color = 0.12 + 0.55 * (a_position * 0.5 + 0.5);
}
src/lessons/30-blending-particles/03-points.frag
#version 300 es

// 第30章 デモ 3: 点の中を gl_PointCoord で塗り分ける。
// この章でここだけはブレンドを使わない。点そのものの性質を見るためのデモなので、
// 深度テストだけを有効にして不透明に描いている。

precision highp float;

// 1 なら円に切り抜く
uniform int u_round;

in vec3 v_color;

out vec4 fragColor;

#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 円の縁をぼかす幅(直径に対する割合)。
// fwidth(第8章)を使えば見かけ 1 ピクセル幅に揃えられるが、この章は点の大きさが
// 点ごとに変わるので、割合で決めてどの点も同じ縁の見え方に揃えている(本文 5 節)
const float EDGE = 0.22;
// ----------------------------------------------------------------------

void main() {
  // gl_PointCoord は点の中での 0〜1。s は左から右へ、t は上から下へ増える。
  // t が gl_FragCoord.y と逆向きであることに注意(本文 5 節)
  vec2 p = gl_PointCoord - 0.5;

  if (u_round == 1) {
    float d = length(p) * 2.0;
    // 向きの反転は引数の入れ替えではなく 1.0 - で書く。smoothstep は
    // edge0 >= edge1 のとき結果が未定義(GLSL ES 3.00 §8.3)
    float mask = 1.0 - smoothstep(1.0 - EDGE, 1.0, d);
    if (mask <= 0.0) {
      discard; // 円の外は捨てる。正方形のフラグメントは作られている(本文 5 節)
    }
    fragColor = vec4(linearToSrgb(v_color * mask), 1.0);
    return;
  }

  // 四角のまま。t が上から下へ増えることを確かめるため、上半分だけ明るくする
  float topHalf = gl_PointCoord.y < 0.5 ? 1.0 : 0.62;
  fragColor = vec4(linearToSrgb(v_color * topHalf), 1.0);
}
src/lessons/30-blending-particles/04-particles.vert
#version 300 es

// 第30章 デモ 4: CPU で動かすパーティクル。
// 位置と寿命は毎フレーム JS 側で計算され、gl.bufferSubData でこの属性に届く(本文 6 節)。

// 粒子はワールド空間の座標そのものを持つので、モデル行列は無い。
// インターリーブした 1 本の VBO から 2 本の属性を取る(第13章)
layout(location = 0) in vec3 a_position;
layout(location = 1) in float a_life; // 1 = 生まれたて、0 = 寿命切れ

uniform mat4 u_view;
uniform mat4 u_projection;
uniform float u_pointSize;

out float v_life;

// ---- 書き換えて試すための定数 ----------------------------------------
// 距離で小さくするときの基準距離(第12章の w。本文 5 節)
const float REFERENCE_DISTANCE = 9.0;
// ----------------------------------------------------------------------

void main() {
  vec4 clip = u_projection * u_view * vec4(a_position, 1.0);
  gl_Position = clip;

  // 生まれたては大きく、消えぎわは小さく。遠近も掛ける
  float size = u_pointSize * (0.35 + 0.65 * a_life);
  gl_PointSize = size * REFERENCE_DISTANCE / clip.w;

  v_life = a_life;
}
src/lessons/30-blending-particles/04-particles.frag
#version 300 es

// 第30章 デモ 4: 加算合成で描く粒。
// 加算合成では「黒 = 何も足さない」なので、粒の縁は黒へ落とすだけで自然に消える。
// アルファで抜く必要も、奥から手前へ並べ替える必要もない(本文 4 節)。

precision highp float;

in float v_life;

out vec4 fragColor;

#include "color.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 消えぎわの色と生まれたての色(どちらもリニア空間)
const vec3 COOL = vec3(0.16, 0.10, 0.42);
const vec3 HOT = vec3(1.00, 0.62, 0.22);
// 1 粒の明るさ。上げすぎると重なったところがすぐ白飛びする
const float BRIGHTNESS = 0.55;
// ----------------------------------------------------------------------

void main() {
  // gl_PointCoord の中心からの距離で、中心が明るい柔らかい粒を作る(本文 5 節)
  float d = length(gl_PointCoord - 0.5) * 2.0;
  // 中心 1 → 縁 0。smoothstep は edge0 >= edge1 のとき未定義なので
  // (GLSL ES 3.00 §8.3)、引数を逆順にせず 1.0 - で反転する
  float glow = 1.0 - smoothstep(0.0, 1.0, d);
  glow *= glow;

  vec3 linear = mix(COOL, HOT, v_life) * glow * v_life * BRIGHTNESS;

  // 出口で 1 回だけ sRGB へエンコードする(第27章 2 節)。
  // 足し算そのものはこのエンコード値の上で起きる(本文 2 節の落とし穴)。
  // アルファはこのデモでは使わない — blendFunc(ONE, ONE) が RGB を素通しで足すため
  fragColor = vec4(linearToSrgb(linear), 1.0);
}

src/lessons/30-blending-particles/color.glslは載せていません。第27章のcolor.glslからsrgbToLinear/linearToSrgbの 2 関数を 1 文字も変えずに複製したもので、中身は第28章・第29章のものと同一です(理由はdocs/plan.mdの抽象化タイムライン。読者が壊して遊ぶ対象なので、章をまたいだ結合を作らない)。

three.js との対応

この章の内容は、three.js では Material のプロパティとして表に出ています。 以下は three.js のdevブランチのソース(src/materials/Material.js/src/renderers/webgl/WebGLState.js/src/renderers/webgl/WebGLRenderLists.js/src/renderers/shaders/ShaderLib/points.glsl.js/src/renderers/webgl/WebGLAttributes.js)を読んで確かめたものです。

three.jsこの章
Material.blending(既定 NormalBlending)gl.enable(gl.BLEND)+blendFuncのプリセット。NoBlendingがgl.disable(gl.BLEND)
Material.transparent(既定 false)この章には対応物がありません。three.js はこのフラグで「ブレンドを実際に有効にするか」と「不透明キューと半透明キューのどちらへ 振り分けるか」を決めています。WebGLState.setMaterialはblending === NormalBlending && transparent === falseのときだけNoBlendingを適用します —transparent: trueを立て忘れると opacityが効かないのは、これが理由です
THREE.AdditiveBlendingpremultipliedAlpha: falseならblendFuncSeparate(SRC_ALPHA, ONE, ONE, ONE)、premultipliedAlpha: trueならblendFunc(ONE, ONE)。2 節の表の「加算」の 2 通りが、そのまま両方使われています
THREE.NormalBlendingblendFuncSeparate(SRC_ALPHA, ONE_MINUS_SRC_ALPHA, ONE, ONE_MINUS_SRC_ALPHA)。RGB とアルファで係数を分けているのがポイントで、アルファ側をソースONEにすることで、描画バッファのアルファが半透明を重ねるたびに下がってしまうのを防いでいます
THREE.MultiplyBlending / THREE.SubtractiveBlendingこの 2 つはmaterial.premultipliedAlpha = trueを要求します(そうでないとWebGLStateがエラーを出して何も設定しません)。乗算はblendFuncSeparate(DST_COLOR, ONE_MINUS_SRC_ALPHA, ZERO, ONE)で、2 節の表のblendFunc(DST_COLOR, ZERO)にアルファの扱いを足した形です
Material.blendSrc/ blendDst / blendEquation /blendColor(blending = CustomBlendingのとき)gl.blendFunc/ gl.blendEquation / gl.blendColorそのもの。定数(SrcAlphaFactorなど)は GL の定数と 1 対 1
Material.premultipliedAlpha(既定 false)3 節。コンテキスト属性の premultipliedAlpha とは別物で、こちらは「マテリアルが出す色が乗算済みか」= blendFunc の選び方の話です
Material.depthWrite(既定 true)gl.depthMask(...)(4 節)
Object3D.renderOrder と、透明キューのソート4 節のsortPanels。three.js は不透明キューをpainterSortStable(手前から)、半透明キューをreversePainterSortStable(奥から)で並べ、どちらもrenderOrderを z より優先します。renderer.setOpaqueSort/setTransparentSortで差し替えもできます
THREE.Points / PointsMaterialgl.drawArrays(gl.POINTS, ...)(5 節)
PointsMaterial.sizeAttenuation(既定 true)gl_PointSizeを距離で割る処理。three.js の points シェーダーはgl_PointSize = size;のあと、透視射影のときだけgl_PointSize *= scale / -mvPosition.z;としています(scaleは描画バッファ高さの半分)。標準的な透視射影行列では-mvPosition.zとクリップ座標のwが一致するので、この章のclip.wで割るやり方と同じことをしています
BufferAttribute.setUsage(THREE.DynamicDrawUsage)とneedsUpdate = truegl.bufferData(..., gl.DYNAMIC_DRAW)とgl.bufferSubData(6 節)。DynamicDrawUsageの値は 35048 =gl.DYNAMIC_DRAWそのものです。needsUpdate = trueはversionを 1 つ進めるだけで、次の描画のときにWebGLAttributesがgl.bufferSubData(bufferType, 0, array)を呼びます(updateRangesを設定すれば、その範囲だけを送る形になります)

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

この章のコードはsrc/lessons/30-blending-particles/にあります。ブレンドと深度は「壊れ方」に情報が詰まっている題材なので、ぜひ一通り触ってみてください。

まとめ

点は 1 頂点で 1 個ぶんという意味では最高に安上がりですが、5 節で見たとおり向きを持てません。常に画面に正対した正方形で、回転する破片も、伸びる火花も、木の葉も作れない。向きのある形を 大量に出したいなら、「同じ形を 1 回のドローコールで N 体」という別の道具が要ります。 次章第31章「インスタンシング — 1 ドローコールで大量描画」では、それを用意します。vertexAttribDivisorで属性を「インスタンスごとに 1 回だけ進める」ように配線し、drawArraysInstanced/drawElementsInstancedで N 体をまとめて描く。属性を持たずに何体目かを知るgl_InstanceID(第5章の伏線)も、そこで回収します。