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

ポストプロセス — ブルーム・トーンマップ・FXAA

ここまでの 32 章は、ずっと何をどう描くかの話でした。この章は描き終わった絵をどう加工するかの話です。やることは第20章で覚えたことの繰り返し — 描画先を画面ではなくテクスチャに差し替える。ただしそれを何段も並べます。

この章の主題は、ブルームでも FXAA でもありません。1 枚の絵を作るのに描画パスを何本も並べるという構図そのものです。ブルームや FXAA は、その上に乗る具体例にすぎません。並べ方が分かれば、載せるものは自分で決められます。

ついでに、これまで置いてきた宿題をまとめて回収します。第2章のantialiasが自作 FBO に効かないこと、第23章のディファードと第28章のレイマーチングで MSAA が効かないこと、第27章の露出とトーンマップをパイプラインのどこに置くか、 第30章の「リニアのまま足したいならRGBA16Fの FBO が要る」。全部この章の宛先でした。

4 本のデモは同じシーンを描いています(ドラッグで回転、ホイールで寄り引き。第15章の軌道カメラの簡略版)。変わるのは後段のパスだけです。「パスを覗く」はチェーンを途中で打ち切って中間の FBO をそのまま画面に出し、「ブルーム」はしきい値と段数、「アンチエイリアス」はなし / FXAA / MSAA、「仕上げ」は色収差・グレイン・ビネット・ディザを切り替えます。読み出し行に、パス数・中間 FBO の枚数とバイト数・画面 1 ピクセルあたりのテクスチャ読み取り回数(FXAA は早期脱出があるので最悪の場合の上限)が出ます(時間は測っていません。GPU 時間の測り方は第35章 2 節)。デモを切り替えるとつまみは既定値に戻ります(しきい値 1.5 / 段数 3 / FXAA / 仕上げは全部 on)。戻さないと、たとえば「ブルーム」で段数を 5 にしたまま「仕上げ」へ移ったときに、中間 FBO の枚数がなぜ 13 枚なのかが画面から読めません。

この章で学ぶこと:

1. 描き終わった絵を、もう一度描く — FBO チェーン

第20章でやったのは、こう言い換えられます —「シーンを画面に描く」を「シーンをテクスチャに描く」に置き換える。 そのあと、そのテクスチャを画面いっぱいの三角形に貼って描き直せば、絵は元どおりです。 貼り直すときにフラグメントシェーダーで何かすれば、それが加工になります。 ポストプロセスと呼ばれるものの正体はこれだけで、1 段目はここで終わりです。

面白くなるのは 2 段目からです。加工の結果をまたテクスチャに描いて、それをもう一度貼る。 3 段目、4 段目と続けられます。この数珠つなぎが FBO チェーンです。 この章のデモ 4「仕上げ」は、ブルームの段数を 3 にした状態でシーンを含めて11 本のパスを並べています(シーン 1 + 抽出 1 + ぼかし 6 + 合成 1 + FXAA 1 + 仕上げ 1)。それでも 書いてあることは「描画先を切り替える → 全画面三角形を 1 枚描く」の繰り返しでしかありません。

全画面三角形は第6章の資産をそのまま使う

貼り直す側の頂点シェーダーは、この章では 1 行も書きません。src/lib/fullscreen.vert(第6章 2〜3 節)をそのまま使います。第23章のライティングパスと同じ使い回しです。

ひとつだけ注意があります。fullscreen.vertはvarying を 1 つも出しません。gl_VertexIDからgl_Positionを作るだけです。だから UV は、フラグメント側でgl_FragCoordから自分で作ります。u_resolutionは描画先の解像度で、ソースの解像度ではありません(この 2 つが違うのがこの章の普通の状態です)。

src/lessons/33-postprocess/blur.frag(抜粋)
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec3 sum = texture(u_source, uv).rgb * WEIGHTS[0];

書き先と読み元を、同時に同じにはできない

パスが増えると、すぐに「さっき書いた FBO を読みながら、別の FBO へ書く」形になります。同じテクスチャを描画先とサンプル元に同時にはできないからです。第29章 1〜2 節でやった ping-pong とまったく同じ理由で、根拠の条文もそこに書いてあります。

ただし入れ替える対象が違います。第29章が交互にしていたのは「前フレームの状態」と 「これから書く状態」でした。時間をまたぐ入れ替えです。この章が交互にするのは同じフレームの中の前段と次段です。ぼかしの横パスがbへ書き、縦パスがbを読んでaへ書く。1 フレームのうちに同じ 2 枚を行ったり来たりします。フレームをまたいで持ち越す情報は 何もないので、この章のチェーンは毎フレーム同じ手順を最初からやり直しているだけです。

src/lessons/33-postprocess/main.ts(抜粋)
  /** 段 i のぼかし 2 本。入力は段 0 なら抽出の結果、それ以外は 1 つ上の段の結果 */
  function blurPassesFor(index: number): Pass[] {
    const source = index === 0 ? levels[0].a : levels[index - 1].a;
    return [
      // 段 1 以降は横パスがそのまま縮小も兼ねる(描画先が半分なので LINEAR が 2×2 を平均する)。
      // 段 0 のソースは抽出の結果で、すでに半分の解像度なので縮小は起きない
      blurPass(source, levels[index].b, true),
      blurPass(levels[index].b, levels[index].a, false),
    ];
  }

パスは配列に並べて、1 本のループで流す

パスを表す型はこれだけです。描画先と、描画先の大きさを受け取って uniform を用意する関数の 2 つしかありません。

src/lessons/33-postprocess/main.ts(抜粋)
  interface Pass {
    label: string;
    /** null なら画面(既定のフレームバッファ) */
    target: RenderTarget | null;
    /** 描画先の幅・高さを受け取って、プログラムと uniform を用意する */
    setup: (width: number, height: number) => void;
  }

流すループも、これだけです。

src/lessons/33-postprocess/main.ts(抜粋)
    // --- 全画面パスのチェーン。並べた配列を 1 本のループで流すだけ -------------------
    const passes = buildPasses();
    // 全画面三角形は深度を持たないし、書き潰すだけなので深度テストは切る
    gl.disable(gl.DEPTH_TEST);
    for (const pass of passes) {
      bindRenderTarget(gl, pass.target); // viewport も一緒に切り替わる(第20章)
      const targetWidth = pass.target ? pass.target.width : gl.drawingBufferWidth;
      const targetHeight = pass.target ? pass.target.height : gl.drawingBufferHeight;
      pass.setup(targetWidth, targetHeight);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    }
    gl.enable(gl.DEPTH_TEST);

デモごとの違いは、この配列を組み立てる関数 1 か所に集まります。「ブルーム」のデモならこうです。

src/lessons/33-postprocess/main.ts(抜粋)
    passes.push(thresholdPass(levels[0].a));
    for (let i = 0; i < levelCount; i++) passes.push(...blurPassesFor(i));

    if (demo.id === 'bloom') {
      passes.push(compositePass(null, levelCount));
      return passes;
    }

PostProcessingのようなヘルパーモジュールは作りませんでした。src/lib/にも、章ローカルにもです。理由は、この章で見せたいものがチェーンの形そのものだからです。パスの生成をクラスの中に畳むと、読者が読むのは「どのパスをaddPassしたか」だけになって、何を読んで何へ書いているかが見えなくなります。 並べて書いてあるほうが、この章の主題には合っています。共通化の是非の総まとめは第34章 7 節です。

シーン(3D を描く)W×H / RGBA16F抽出(しきい値)W/2 × H/2ぼかし 段 0(横 → 縦)W/2 × H/2ぼかし 段 1(横 → 縦)W/4 × H/4ぼかし 段 2(横 → 縦)W/8 × H/8合成 → 露出 → トーンマップ→ sRGB エンコードW×H / ここで 0〜1 へFXAAW×H / sRGB 値の輝度で判定仕上げ(色収差・グレイン・ビネット・ディザ)画面(既定のフレームバッファ)W×H / 8 ビットシーンそのものも合成へ入る
段数 3 のときのチェーン全体。段は解像度が半分ずつ下がっていきます。破線は、合成パスが ぼかしの結果だけでなく元のシーンも読むことを表しています。 解像度と形式を添えてあるのは、7 節でこの表をもう一度使うためです。この 11 本が全部つながるのはデモ 4「仕上げ」だけで、デモ 2「ブルーム」は合成で打ち切って画面へ出すので 9 本です。 デモ 1「パスを覗く」は 途中で打ち切って、その FBO をそのまま画面へ出す 1 本を足すので 2 / 3 / 9 / 10 本 (読み出し行のパス数で確かめられます)。

デモ 1「パスを覗く」は、この図のどこかでチェーンを打ち切って、そこまでの結果を 画面に出しています。オプションを切り替えると読み出し行のパス数がそのまま変わるので、 どこで止めたかが数字でも分かります。

2. HDR で受け取る — RGBA16F と、1 を超える明るさ

チェーンの 1 段目、シーンを描く先をRGBA8ではなくRGBA16Fにしています。ここがこの章でいちばん効いている一手なので、 理由をはっきりさせておきます。

ブルームは「明るすぎるところが滲む」効果です。では明るすぎるとは何より明るいのか。 基準は「画面に出せる上限」、つまりリニア値の 1.0 です。1.0 を超えた光は画面には出せませんが、超えた分が滲みとして周りに漏れる、 というのがブルームの筋書きです。

ところが、中間バッファが 8 ビット固定小数だと1 を超えた値がそもそも保持できません。 シェーダーが 20.0 を出しても、固定小数のカラーバッファへ書く時点で 1.0 にクランプされます。しきい値を 1.5 にしても 3.0 にしても、それを超える画素は定義上 1 つも存在しません。しきい値を 0.8 まで下げれば「超える画素」は出てきますが、1.0 に潰れた画素どうしの明るさの差はもう消えていて、光源の芯もその隣のハイライトも同じ 1.0 です。

RGBA16Fなら 20.0 は 20.0 のまま入ります。だから「1 を超えた分だけ抜き出す」がそのまま書けます。

シーンパスの側でも、1 か所だけ第17〜23章と違うことをしています。最後に sRGB へ変換しません。画面へ直接描く章なら、そこがチェーンの終点なので出す直前にlinearToSrgbを通していました。この章の終点はずっと先にあります。

src/lessons/33-postprocess/scene.frag(抜粋)
  vec3 color = u_baseColor * (u_ambientColor + u_lightColor * NdotL)
    + u_lightColor * specular * u_specularStrength
    + u_emissive;

  // リニア HDR。1 を超えた値は RGBA16F にそのまま入る(2 節)
  fragColor = vec4(color, 1.0);

自発光の小球はu_emissiveにリニア値で 16 倍した色を入れてあります。この「1 を大きく超える画素」がブルームの種で、 球の鏡面ハイライトも同じように 1 を超えます。シーンの組み立て方そのものは第13〜17章の道具をそのまま使っているだけなので、この章では解説しません。

half float の値域と刻み

RGBA16Fの 1 成分は 16 ビットの浮動小数点数(IEEE 754 の binary16)です。OpenGL ES 3.0.6 §2.1.2 が 「符号 1 ビット・指数 5 ビット・仮数 10 ビット」と定め、値の式も書いています。 そこから出る数はこうです。

項目RGBA8(8 ビット固定小数)RGBA16F(half float)
表せる最大値1.0(それ以上はクランプ)65504
刻み1/255 = 0.003922(全域で一定)相対 2⁻¹¹ = 0.000488(指数ごと)
0 に近いところ0 の次が 0.003922正規化数の最小 2⁻¹⁴ = 6.1035×10⁻⁵
1 px のバイト数(RGBA)4 バイト/px8 バイト/px

ES 3.0.6 §2.1.2 の式から計算した値(scratchpad/p5b/ch33-numbers.pyの「2.」)。最大値は指数 30・仮数 1023 のとき 2¹⁵ × (1 + 1023/1024) = 65504。 「相対 2⁻¹¹」は丸め幅(刻みの半分)の上限であって、刻みそのものではない。 刻み(ulp)は 2⁻¹⁰ 〜 2⁻¹¹ のあいだで、指数区間の下端で相対 2⁻¹⁰、上端で 2⁻¹¹ に近づく。 下の本文の「0.1 付近なら 0.00006 刻み」は実際の刻み 2⁻¹⁴ = 6.1035×10⁻⁵(0.1 に対して相対 6.10×10⁻⁴)。

大事なのは「1 を超えられる」だけではないことです。刻みが相対値になるので、 暗いところの刻みも細かくなります。8 ビットの 0〜1 は 0.003922 刻みで一様ですが、half float は 0.1 付近なら 0.00006 刻み、0.01 付近ならさらに細かい。合成やぼかしを何段も通す 途中でバンディング(縞)が出にくいのは、これが理由です。

中間 FBO が float なので、読む側のサンプラはこの章のシェーダーすべてでuniform highp sampler2Dと明示してあります(理由は第21章 6 節。各シェーダーのコメントもそこへ送っています)。

EXT_color_buffer_float が要る理由と、無いときの落とし方

RGBA16Fは WebGL2 のコアでテクスチャとしては使えますが、描画先には使えません。 OpenGL ES 3.0.6 §3.8.3.1 は必須の形式を 2 つに分けていて、RGBA16Fは「Texture-only color formats」のほうに入っています。§4.4.2.2 が「レンダーバッファに必須なのはテクスチャの必須形式と同じもの。ただしtexture-onlyと書かれた色形式は除く」と念を押します。だから第20章 8 節で名前だけ出した拡張が要ります。

EXT_color_buffer_floatは、拡張仕様の原文(Khronos の WebGL Extension Registry)で 「R16F、RG16F、RGBA16F、R32F、RG32F、RGBA32F、R11F_G11F_B10Fがcolor-renderableになる」と定めています。依存はAPI version 2.0、つまり WebGL 2.0 専用です。

無いときの落とし方は 2 段です。まずEXT_color_buffer_half_float。これは元々 WebGL 1.0 用の拡張ですが、2020-08-31 の改訂で WebGL 2.0 にも公開されました。改訂の理由がそのまま書いてあります — 「16 ビット浮動小数点のターゲットにしか描けない、したがってEXT_color_buffer_floatに対応できない端末をサポートするため」。どちらも無ければRGBA8まで落ちます。そのとき 1 を超える画素は 1 つも作れないので、この章はしきい値のほうを 0.35 倍に下げて抽出します(thresholdScale)。ブルームは出ますが「1 を超えた分」ではなくなり、 別物に変わります。u_kneeは連動して下げていないので、肩の幅も元より相対的に広くなります。

src/lessons/33-postprocess/main.ts(抜粋)
  const hasColorBufferFloat = gl.getExtension('EXT_color_buffer_float') !== null;
  const hasColorBufferHalfFloat = gl.getExtension('EXT_color_buffer_half_float') !== null;
  const hdrFormat = hasColorBufferFloat || hasColorBufferHalfFloat ? gl.RGBA16F : gl.RGBA8;
  const hdrBytesPerPixel = hdrFormat === gl.RGBA8 ? 4 : 8;
  const hdrFormatName = hdrFormat === gl.RGBA8 ? 'RGBA8' : 'RGBA16F';
  // RGBA8 に落ちたときは 1 を超える明るさが保持できないので、**しきい値のほうを 0.35 倍に
  // 下げて**抽出する。ブルームは出るが「1 を超えた分」ではなくなる(本文 2 節)
  const thresholdScale = hdrFormat === gl.RGBA8 ? 0.35 : 1.0;

なお、HDR 画像ファイル(.hdr/ RGBE)のローダーはこの章では作りません。 このサイトは外部の HDR 素材を持たず、この章のシーンは HDR を手続き的に作っているためです。形式(RGBE / RGB9_E5 / half float)と共有指数の制約の解説は第27章 6 節にあります。

3. 明るいところだけを抜き出す — しきい値とブルーム

抽出パスがやることは 1 つ、しきい値より暗い画素を落とすことです。 素朴に書けばstepでのハードなカットですが、そうすると「しきい値のちょうど境目にいる画素」がフレームごとに 出たり入ったりします。smoothstepで軟らかい肩を付けます。

src/lessons/33-postprocess/threshold.frag(抜粋)
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec3 color = texture(u_source, uv).rgb;

  float luma = dot(color, LUMA_BT709);
  // smoothstep の edge0 < edge1 は守ること。逆順は GLSL ES 3.00 §8.3 で「未定義」
  // (第30章が実際にこれを踏んだ)。u_knee は CPU 側で必ず正の値にしてある
  float weight = smoothstep(u_threshold, u_threshold + u_knee, luma);

  fragColor = vec4(color * weight, 1.0);

輝度で判定する。その係数はどこから来たのか

判定は成分ごとにもできます(「赤だけ 1 を超えていたら赤だけ抜く」)が、この章は輝度 1 本で判定して、抜けた画素の色はそのまま通しています。成分ごとにすると、 たとえば赤い光源の抽出結果が赤だけになって、滲んだときに色が変わってしまうからです。

係数は 0.2126 / 0.7152 / 0.0722。これはRec. ITU-R BT.709-6(2015 年 6 月)の item 3.2「Derivation of luminance signal」のE'Y = 0.2126 E'R + 0.7152 E'G + 0.0722 E'Bです。ただし条文をそのまま読むと、この式が掛かる相手はitem 3.1 の非線形プリコレクションを通したあとの信号 E'で、リニア値ではありません。 ではリニア値に掛けてよいのか。

よいのですが、その根拠は item 3.2 ではなく同じ勧告の item 1.3 と 1.4です。原色が R (0.640, 0.330) / G (0.300, 0.600) / B (0.150, 0.060)、白色点が D65 (0.3127, 0.3290) と定められているので、そこから「リニア RGB → CIE XYZ」の 3×3 行列が一意に決まります。そのY の行を計算すると 0.212639 / 0.715169 / 0.072192 になり、item 3.2 の 3 つの数(4 桁に丸めたもの)と一致します。つまり同じ 3 つの数が、非線形信号に掛ければ「ルーマ」、リニア値に掛ければ「輝度」になります。この章がリニア HDR に掛けているので、これは輝度のほうです。

計算はscratchpad/p5b/ch33-numbers.pyの「1.」にあります(行の和が 1.000000000 になること、つまりリニアの白 (1, 1, 1) の輝度が 1 になることも同時に確かめています)。第27章 3 節が「原色は BT.709 = sRGB と同じ」で閉じたのと矛盾しません。

段を下げて半径を稼ぐ

ブルームの滲みは、画面の何十ピクセルにも広がってほしいものです。しかし半径 40 のガウシアンを直接掛けるのは論外のコストです(次の節で数えます)。定石は解像度を下げてから小さい半径でぼかすこと。解像度が半分になれば、同じ 4 ピクセルの半径が画面上では 8 ピクセルぶんに効きます。段を下げるたびに倍々です (倍々にするには歩幅の測り方に条件が付きます。4 節の pitfall)。

そしてこれはほとんどタダです。段 0 のピクセル数は画面の 1/4、段 1 は 1/16、段 2 は 1/64。合計は

1/4 + 1/16 + 1/64 + … = 0.328125(3 段)… 0.333008(5 段)… → 1/3(無限段)

で、何段積んでも画面のピクセル数の 1/3 を超えません。半分の解像度から始めずに 等倍から積んだとしても、1 + 1/4 + 1/16 + … = 4/3 にしかなりません。段を増やすコストが頭打ちになる、というのがこの設計の要点です(値はch33-numbers.pyの「5.」)。

合成は加算です。ぼかした各段を平均して、シーンの色に足します。リニア空間で足していることが肝心で、sRGB エンコード値を足しても光を足したことにはなりません(第27章 4 節・第30章 2 節の pitfall がここへ送っていました)。この章では、合成パスに入る値も出る値もlinearToSrgbの手前にあるので、足し算は必ずリニアの上で起きます。

src/lessons/33-postprocess/composite.frag(抜粋)
  color *= u_exposure; // 露出 → トーンマップ → sRGB の順(第27章 5 節)
  color = toneMapReinhard(color);

  fragColor = vec4(linearToSrgb(color), 1.0);

段を平均してから 1 つの強さで混ぜているのは、「広がり」と「強さ」の 2 つのつまみを絡ませないためです。合計にすると、段数を 1 から 5 に増やしたときにブルームの総量まで 5 倍近くになって、何を変えたのか分からなくなります。

4. 分離可能ガウシアン — N² のぼかしを 2N にする

2 次元のガウシアンは、横と縦の積に分解できます。指数法則 1 行で終わりです。

exp(−(x² + y²) / 2σ²) = exp(−x² / 2σ²) · exp(−y² / 2σ²)

だから「2 次元のカーネルを 1 回」ではなく「1 次元のカーネルを横に 1 回、縦に 1 回」でまったく同じ結果が出ます。この性質を持つフィルタを分離可能 (separable)と呼びます。効くのはタップ数です。

半径 r2 次元 (2r+1)² タップ分離 2(2r+1) タップ比
1961.50
225102.50
349143.50
4(この章)81184.50
8289348.50

1 出力ピクセルあたりのテクスチャ読み取り回数。分離のほうは 2 パス(横 + 縦)の合計で、 パスが 1 本増える代わりに中間の FBO が 1 枚要る。ch33-numbers.pyの「6.」。

この章が使っているのは半径 4、つまり 81 タップが 18 タップになる段です。 半径を広げるほど得が大きくなるのが見どころで、半径 8 なら 289 が 34 です。

重み

σ = 2.0 のガウシアンを半径 4 で打ち切り、9 個の合計がちょうど 1 になるよう正規化した値を使っています。合計が 1 でないと、ぼかすたびに絵が明るくなったり暗くなったりします。

中心からのオフセット重み(この章が使っている値)線形補間でまとめた場合の重み / オフセット
0(中心)0.2041640.204164 / +0.000000 px
±10.1801740.304005 / ±1.407333 px
±20.123832(±1 とまとめた)
±30.0662820.093913 / ±3.294215 px
±40.027631(±3 とまとめた)

σ = 2.0・半径 4(9 タップ)。重みは exp(−x²/2σ²) を整数点で標本して合計で割ったもので、丸める前の合計は 1.0000000000、表と blur.frag に載っている 6 桁の値を足すと 1.000002。 正規化が 9 個へ配り直しているのは打ち切った標本のぶんで、全整数点の標本の 合計に対して 2.298% にあたる(参考: 連続ガウシアンを ±4.5 で切ったときに外へ出る質量は 2.445% で、これは別の数え方の値)。 右列は下で説明する「線形補間でまとめる」場合の値で、この章の blur.frag は実装していない。 すべてch33-numbers.pyの「3.」「4.」の出力。

src/lessons/33-postprocess/blur.frag(抜粋)
const float WEIGHTS[5] = float[5](0.204164, 0.180174, 0.123832, 0.066282, 0.027631);

out vec4 fragColor;

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec3 sum = texture(u_source, uv).rgb * WEIGHTS[0];

  for (int i = 1; i < 5; i++) {
    vec2 offset = u_direction * u_texelStep * float(i);
    sum += texture(u_source, uv + offset).rgb * WEIGHTS[i];
    sum += texture(u_source, uv - offset).rgb * WEIGHTS[i];
  }

テクスチャの線形補間で 2 タップを 1 タップにまとめる

もう一段の削減があります。LINEARフィルタは、2 つのテクセルの間の座標を読むと重み付きの平均を返します。 ならば隣り合う 2 タップは、適切な位置を 1 回読むだけで済むはずです。

位置の決め方には条件があります。テクセル 1 の重みを w₁、テクセル 2 の重みをw₂とすると、読む座標はテクセル 1 の中心から w₂ / (w₁ + w₂) だけ離れた点でなければなりません。そうすれば線形補間が返す値が、ちょうど(w₁·t₁ + w₂·t₂) / (w₁ + w₂)になるからです。これに (w₁ + w₂) を掛ければ、2 タップぶんの寄与が復元されます。

この手は Daniel Rákos が“Efficient Gaussian blur with linear sampling”(rastergrid.com、2010 年 9 月 7 日)で書いているもので、上の条件も同記事の文言(「得られる座標のテクセル #1 の中心からの距離は、テクセル #2 の重みを 2 つの重みの和で割った値に等しくなければならない」)に沿っています。ただし現在 rastergrid.com にあるのは移行版で、記事の冒頭に「元の個人ブログから移行したもので、 書式や内容が元記事と異なる場合がある」という但し書きが付いています。元記事そのものには到達していません。

この章のblur.fragはこれを実装していません。9 タップを 9 回読んでいます。理由は、まとめると 重みとオフセットの表がシェーダーの見た目から離れてしまい、「ガウシアンの重みを掛けて足す」 という節の主題が読みにくくなるからです。上の表の右列に、まとめた場合の値を計算だけしてあります —9 タップが 5 フェッチになります。

5. アンチエイリアス — MSAA が効かない絵と、FXAA

第2章 2 節でantialiasというコンテキスト属性を見ました。既定はtrueで、ブラウザが勝手にマルチサンプルしてくれます。ところが第20章・第23章・第28章はどれも 「この章の絵には MSAA が効かない」と書き残しました。ここで理由を片付けます。

antialias が効くのは「描画バッファ」だけ

WebGL 1.0 仕様のコンテキスト属性の項は、antialiasをこう定義しています — 「値が true で実装がアンチエイリアスに対応していれば、描画バッファ (the drawing buffer)が実装の選んだ手法と品質でアンチエイリアスを行う」。depthもstencilも同じ文型で、主語はすべて「描画バッファ」です。自分で作ったフレームバッファのことは 1 語も言っていません。(WebGL 2.0 仕様はこの 3 つについて「WebGL 1.0 と違い、実装はこれらの属性に従わなければならない」と強めているだけで、 掛かる相手は変わりません。)

OpenGL ES 側から見ると、もっとはっきりします。ES 3.0.6 §3.3 Multisampling はこう書き分けています — 「ウィンドウシステムが提供するフレームバッファでは、これはピクセルが更新されるたびに起こるので、 アンチエイリアスはアプリケーションから見ると自動である。アプリケーションが作ったフレームバッファでは、 これはBlitFramebufferコマンドを呼んで要求しなければならない」。さらに「マルチサンプルバッファと呼ばれる追加のバッファが、ウィンドウシステムが提供するフレームバッファに加えられる」「SAMPLE_BUFFERSの値はフレームバッファ依存の定数である」と続きます。

つまり「マルチサンプルかどうか」はフレームバッファごとの性質で、 描画先を FBO に切り替えた瞬間にantialiasは無関係になります。この章のシーンパスはRGBA16Fのテクスチャへ描くので、既定ではSAMPLE_BUFFERSが 0 のまま。ギザギザがそのまま出ます。

ディファードとレイマーチングは、別々の理由で MSAA と相性が悪い

第23章のディファードレンダリングでは、G-buffer に材質と法線が入っています。 仮に G-buffer をマルチサンプルにできたとしても、1 ピクセルの中の 4 サンプルが別々の物体を指していたとき、それを平均すると「金属 50% + 木 50%」という存在しない材質ができます。色を平均するのは意味がありますが、材質を平均するのは意味がありません。 正しくやるならライティングをサンプルごとに計算することになり、ディファードにした理由 (フラグメント数 × 光源数を減らす)が消えます。

第28章のレイマーチングは、そもそも三角形を描いていません。画面いっぱいの 三角形 1 枚を描いて、その中で距離関数をたどっているだけです。MSAA は「三角形が 1 ピクセルの各サンプル点を覆っているか」を数える仕組みなので、 ここで数えられるのは画面いっぱいの三角形のカバレッジだけ — 全ピクセルで 100% です。物体のシルエットは三角形の縁ではなくフラグメントシェーダーの中で決まった値なので、カバレッジという概念が定義されません。

前者は「平均してはいけないものを平均する」、後者は「平均する対象が定義されていない」。 症状は同じ「MSAA が効かない」ですが、原因はまったく別です。

MSAA を自分で作る — renderbufferStorageMultisample と blitFramebuffer

自作 FBO でも MSAA はできます。WebGL2 のコア機能です — WebGL 2.0 仕様のWebGL2RenderingContextBaseの IDL にrenderbufferStorageMultisampleとblitFramebufferとgetInternalformatParameterが並んでいます(拡張ではありません)。手順は 3 つです。

  1. renderbufferStorageMultisampleでマルチサンプルのレンダーバッファを作り、FBO にアタッチする。カラーも深度も同じサンプル数にすること
  2. その FBO にシーンを描く
  3. blitFramebufferで、マルチサンプルの FBO からふつうの FBO へコピーする。このコピーが解決 (resolve)を兼ねる
src/lessons/33-postprocess/main.ts(抜粋)
  const color = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, color);
  gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, internalFormat, width, height);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, color);

  // 深度も同じサンプル数で作る。揃っていないと FRAMEBUFFER_INCOMPLETE_MULTISAMPLE になる
  const depth = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, depth);
  gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, gl.DEPTH_COMPONENT24, width, height);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth);

手順 3 の解決へ進む前に、1 つ新しい形が出てきます。次のコードはbindFramebufferを2 回呼んでいます。第20章からずっと使ってきたgl.FRAMEBUFFERは、実はバインド先を 2 つまとめて指す名前でした。ES 3.0.6 §4.4.1 が 「DRAW_FRAMEBUFFERにバインドされた FBO は描画の宛先に、READ_FRAMEBUFFERにバインドされた FBO は読み出しの元になる」「FRAMEBUFFERを指定してBindFramebufferを呼ぶと、描画側と読み出し側の両方にバインドされる」と定めています。

blitFramebufferは「読み出し側から描画側へ写す」コマンドなので、2 つが別の FBO でないと成立しません。 だからここだけはgl.FRAMEBUFFERではなく、読み側と書き側を 1 つずつ指定します。写し終わったら両方nullに戻して、以降のパスがこれまでどおりgl.FRAMEBUFFERだけで済むようにしてあります。

src/lessons/33-postprocess/main.ts(抜粋)
      gl.bindFramebuffer(gl.READ_FRAMEBUFFER, multisample.framebuffer);
      gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, sceneTarget.framebuffer);
      gl.blitFramebuffer(
        0,
        0,
        multisample.width,
        multisample.height,
        0,
        0,
        sceneTarget.width,
        sceneTarget.height,
        gl.COLOR_BUFFER_BIT,
        gl.NEAREST, // 解決のときは filter は無視される(§4.3.3)
      );

解決の規則は ES 3.0.6 §4.3.3 にあります。読み側のSAMPLE_BUFFERSが 1 で書き側が 0 のとき、「各ピクセル位置に対応するサンプル群が 1 つのサンプルに変換されてから書き込まれる。filterパラメータは無視される」。 ただし条件が 2 つあって、読み側と書き側の形式が同一で、転送元と転送先の矩形が同じ (X0, Y0), (X1, Y1) で定義されていること。違えばINVALID_OPERATIONです。上のコードが同じRGBA16F・同じ矩形なのはこのためです。

src/lessons/33-postprocess/main.ts(抜粋)
function supportedSampleCounts(gl: WebGL2RenderingContext, internalFormat: GLenum): number[] {
  const counts = gl.getInternalformatParameter(gl.RENDERBUFFER, internalFormat, gl.SAMPLES);
  return counts instanceof Int32Array ? Array.from(counts) : [];
}

MAX_SAMPLESの最小要求値は4です(ES 3.0.6 Table 6.35 “Framebuffer Dependent Values”、 §4.4.2.1)。§4.4.2.2 が「実装は、必須の形式についてMAX_SAMPLESの値までのマルチサンプルでレンダーバッファを作れなければならない(符号付き・符号なし整数形式を除く)」 としています。ただしこの必須は「必須の形式」に対するもので、 拡張で足されたRGBA16Fはその外側です。問い合わせが空で返る環境では、デモ 3 のボタンのラベルがMSAA(非対応)になり、押しても切り替わりません(main.tsのapplyがmsaaSamples === 0のとき何もしないので、読み出し行には何も出ません。 非対応であることはラベルのほうで分かります)。

FXAA

もう 1 本の道がポストプロセスでのアンチエイリアスです。1 枚の絵しか無いところで、結果の輝度から「ここに縁があったはずだ」と推測してぼかします。情報は増えません。 増えないので、細い線は消えることがあります。それでも、シェーダーの中で作った縁にも効くのが強みです。

この章が実装したのはFXAA (Fast approXimate Anti-Aliasing)の簡略版です。出典は Timothy Lottes による NVIDIA の白書 PDF (developer.download.nvidia.com/assets/gamedev/files/sdk/11/FXAA_WhitePaper.pdf)。原文の PDF は入手して読みましたが、書誌情報には食い違いがあります — 表紙には “February 2009” とありますが、2 ページ目の Document Change History には「1.0 / 25/01/11 / Timothy Lottes / Initial release」、末尾の著作権表示は “© 2010 NVIDIA Corporation”。白書に付き物の文書番号 (WP-…)はこの PDF 本文には現れません。以下の引用はすべてこの PDF からです。 白書が参照している配布実装FxaaShader.hのほうには到達していません。

白書が挙げるアルゴリズムの流れは 8 段です。

  1. 非線形 RGB を受け取り、内部で輝度のスカラー推定に変換する
  2. 局所コントラストを見て、縁でないところは処理しない
  3. 通った画素を、横の縁か縦の縁かに分類する
  4. 縁に垂直な方向で、コントラストが最も高い画素のペアを選ぶ
  5. 縁に沿って正負両方向へ、ペアの平均輝度が変わるところ(縁の端)を探す
  6. 端の位置から、その画素の縁に垂直なサブピクセルのずらし量を決める
  7. そのずらし量で入力テクスチャを読み直す
  8. サブピクセルのエイリアス量に応じてローパス(3×3 の箱フィルタ)を混ぜる

輝度の式は白書に載っているものをそのまま使いました。赤と緑だけから作ります (白書の言い分は「実際のゲームの絵で青だけのエイリアスが出ることはめったにない」)。 正規化されていないので値域は 0〜2.963 です。

src/lessons/33-postprocess/fxaa.frag(抜粋)
float fxaaLuma(vec3 rgb) {
  return rgb.g * (0.587 / 0.299) + rgb.r;
}

2 段目の早期脱出も白書のコードそのままです。しきい値は白書の “Tuning Defines” にあるFXAA_EDGE_THRESHOLD= 1/8(“high quality”)とFXAA_EDGE_THRESHOLD_MIN= 1/16(“high quality”)。後者は「暗いところを処理させないため」の下限です。

src/lessons/33-postprocess/fxaa.frag(抜粋)
  float rangeMin = min(lumaM, min(min(lumaN, lumaW), min(lumaS, lumaE)));
  float rangeMax = max(lumaM, max(max(lumaN, lumaW), max(lumaS, lumaE)));
  float range = rangeMax - rangeMin;
  if (range < max(EDGE_THRESHOLD_MIN, rangeMax * EDGE_THRESHOLD)) {
    fragColor = vec4(rgbM, 1.0);
    return;
  }

5 段目の探索も、白書のループの形をそのまま移しました。

src/lessons/33-postprocess/fxaa.frag(抜粋)
  vec2 posB = pos + across * (lengthSign * 0.5);
  vec2 posN = posB - along;
  vec2 posP = posB + along;
  float lumaEndN = lumaPair;
  float lumaEndP = lumaPair;
  bool doneN = false;
  bool doneP = false;
  for (int i = 0; i < SEARCH_STEPS; i++) {
    if (!doneN) lumaEndN = fxaaLuma(fetch(posN));
    if (!doneP) lumaEndP = fxaaLuma(fetch(posP));
    doneN = doneN || abs(lumaEndN - lumaPair) >= gradient;
    doneP = doneP || abs(lumaEndP - lumaPair) >= gradient;
    if (doneN && doneP) break;
    if (!doneN) posN -= along;
    if (!doneP) posP += along;
  }

探索の開始位置をペアのちょうど中間(半テクセルずらし)に置いているのが要点です。 こうすると、縁に沿って 1 回読むだけでLINEARフィルタがペアの平均を返してくれます。白書も「性能上の最適化として、アルゴリズムの end-of-edge 探索のステップは2 つのテクセルの中間を読む」と書いています。

そしてFXAA を掛ける場所。白書にはっきり書いてあります — 「FXAA は、エンジンの後処理の終わりのほう、低ダイナミックレンジへの変換と、表示のための sRGB 色空間への変換のあとに適用するように作ってある」。理由も書いてあります。 「トーンマップの前の HDR 画像に FXAA を掛けると、MSAA のサーフェスをトーンマップの前に解決するのと同じ破綻が起きる。LDR に収まる強度と収まらない強度の両方を持つ縁で、アンチエイリアスが実質無効になる。たとえば強度 0.0 の画素と 16.0 の画素は平均すると 8.0 になり、それが後でトーンマップされて 1.0 になるかもしれない(= 2 つの画素のあいだに何も混ざらない)」。この章のチェーンで FXAA がcomposite.fragのあとにいるのは、この一節が理由です。7 節でもう一度出てきます。

MSAA と FXAA を並べる

観点MSAA(自作 FBO で)FXAA
サンプル数1 ピクセルにつき MAX_SAMPLES まで(最小要求値 4・ES 3.0.6 Table 6.35)1 ピクセルにつき 1。増やさない
メモリカラーと深度がサンプル数だけ増える(4x なら 4 倍)1 ピクセルあたりは増えない(サンプルを増やさない)。ただしパスとしては入力と同じ大きさの出力が 1 枚要る
三角形の縁に効くか効く(カバレッジがそこで定義されている)効く(結果の輝度から縁を推測する)
シェーダーの中で作った縁に効くか効かない(縁が三角形の縁ではない。第28章)効く(何で描かれた縁かを問わない)
情報を増やすか捨てるか増やす(サンプルを余分に取る)捨てる(1 枚の絵をぼかす。細い線は消える)
ディファードとの相性悪い(サンプルごとに材質が違い、平均できない。第23章)良い(材質ではなく最終の色を見る)
掛ける場所シーンパス(描く時点)sRGB エンコードのあと(7 節)

デモ 3 で 3 通りを見比べてください。この 2 つは排他ではありません— 三角形の縁は MSAA で、シェーダーの中で作った縁は FXAA で、と併用する構成もあります (そのぶんコストは足し算になります)。

6. 仕上げの層 — 色収差・グレイン・ビネット

ここから先は、物理的に正しいからではなく画作りのためにやるものです。 実際のレンズの色収差は補正すべき欠陥ですし、フィルムの粒子はノイズです。 それをわざと足すのは「そう見えると気持ちがいい」からで、それ以上の理屈はありません。 だからこそどこに置くかは自分で決めなければならない、というのがこの節の話です。

色収差

R・G・B を別々の UVでサンプルします。ずらす量を画面中心からの距離に比例させると、 中心では合っていて周辺で分離する、レンズらしい見え方になります。

コストははっきりしています。1 ピクセルにつきテクスチャ読み取りが 1 回から 3 回に 増えます。デモ 4 で色収差を切ると、読み出し行の「テクスチャ読み取り」の数字が 2 だけ減ります。

グレイン

hash.glslのhash21(第25章のハッシュの複製)で、ピクセル座標から乱数を作って足します。毎フレーム別の模様にするには、 引く座標をフレームごとにずらします。

src/lessons/33-postprocess/grade.frag(抜粋)
  if (u_grain > 0.0) {
    // hash21 は 0〜1 なので、-0.5 して ±0.5 にする。座標をフレーム番号でずらすと
    // 毎フレーム別の模様になる。u_frame を整数のまま渡すのがこの章の約束(6 節)
    ivec2 cell = ivec2(gl_FragCoord.xy) + ivec2(u_frame * 1237, u_frame * 9781);
    color += (hash21(cell) - 0.5) * u_grain;
  }

ずらす量にfloat の u_time ではなく整数のフレーム番号を使っているのがここの要点です。u_timeは秒数なので、長時間走らせると float の刻みが粗くなって「同じ値の連続」が出ます。 整数なら 1 フレーム進むたびに必ず違う値になります。第29章 2 節がu_frameで同じ話をしていて、そこにはprecision highp int;が要る理由も書いてあります(mediumpの符号付き整数は 32767 までしか保証されない)。この章のgrade.fragがprecision highp int;を書いているのは、pcg3dのためとu_frameのためです。

ビネット

画面中心からの距離で暗くするだけです。この章はsRGB エンコード後の値に掛けると 決めました。リニア値に掛けると物理的には「光の量を減らす」ことになって正しいのですが、 絵としては隅の落ち方が急に見えます。

どちらが正解ということはありません。第27章 7 節の流儀どおり、決めていないことだけが問題です。リニアで掛けたければ、この処理をcomposite.fragのトーンマップの手前へ移してください(grade.fragのコメントにもそう書いてあります)。

ディザは最後のパスでだけ

src/lessons/33-postprocess/grade.frag(抜粋)
  if (u_dither > 0.0) {
    // 三角 PDF ±1 段のディザ。**8 ビットへ落ちる直前のこのパスでだけ**掛ける(6 節)。
    // 原理は第27章 8 節
    color += triangularDither(ivec2(gl_FragCoord.xy)) / 255.0;
  }

7. 順序と精度 — どのパスをどこに置くか

この章の結論です。正しい順序は 1 本の並びで書けます。

シーン(リニア HDR)→ 抽出 → ぼかし → 合成 → 露出 → トーンマップ → sRGB エンコード → FXAA → 色収差・グレイン・ビネット → ディザ → 画面(8 ビット)

各段の「入力がどの空間の値か」と「出力の形式」を並べると、こうなります。

パスファイル入力の空間出力
シーンscene.vert / scene.frag—リニア HDR / RGBA16F・全解像度
抽出threshold.fragリニア HDRリニア HDR / RGBA16F・1/2
ぼかし 段 i(横→縦)blur.fragリニア HDRリニア HDR / RGBA16F・1/2ⁱ⁺¹
合成 → 露出 → トーンマップ → sRGB エンコードcomposite.fragリニア HDRsRGB エンコード値 0〜1 / RGBA16F・全解像度
FXAAfxaa.fragsRGB エンコード値sRGB エンコード値 / RGBA16F・全解像度
仕上げ(色収差・グレイン・ビネット・ディザ)grade.fragsRGB エンコード値sRGB エンコード値 → 画面
画面(既定のフレームバッファ)—sRGB エンコード値8 ビット固定小数(ここで初めて量子化される)

「1/2ⁱ⁺¹」は段 0 が 1/2、段 1 が 1/4、段 2 が 1/8 の意味。中間 FBO の内部フォーマットは、EXT_color_buffer_floatかEXT_color_buffer_half_floatが使えるかどうかでRGBA16FかRGBA8に決まる(2 節)。

なぜトーンマップが sRGB エンコードの前なのか

これは第27章 5 節が済ませています。linearToSrgbは入力を 0〜1 にクランプするので、先に掛けると1 を超えていた情報がそこで消えます。 露出(リニア値への掛け算)→ トーンマップ(0〜∞ を 0〜1 へ)→ sRGB エンコード、の順は動かせません。

この章が足すのは「パスをまたいでも同じ順序を守る」という一点だけです。 パスが分かれると、うっかり順序が入れ替わります。よくあるのが「composite.fragでlinearToSrgbを通したあと、次のパスでもう一度露出を掛ける」形で、これは 0〜1 に潰れた値をさらに 引き伸ばしているだけです。表の「入力の空間」の列が、その事故を防ぐためのものです。

どこで 8 ビットに落ちるか

この章の中間 FBO は全部 floatです。composite.fragが出す sRGB エンコード値(0〜1)も、float のバッファに入れています。理由は8 ビットに落ちる場所を 1 か所だけにするためです。

もし合成の出力をRGBA8に置いていたら、そこで量子化が起きます。すると 6 節のディザは手遅れです — バンディングはすでに焼き付いていて、最後のパスでノイズを足しても戻りません。 FXAA が読む値も 8 ビットに丸まったものになります。float のまま持ち回れば、 量子化は画面へ書く 1 回だけになります。

代償はメモリです。全解像度の中間 FBO 1 枚がRGBA8の 2 倍になります。読み出し行の「中間 FBO の合計バイト数」がその量で、幅 × 高さ × 4 成分 × 2 バイトで数えています(カラーアタッチメントのみ。深度レンダーバッファは含めていません)。 デモ 3 で MSAA を選んだときだけ、これにマルチサンプルのカラーレンダーバッファ 1 枚(幅 × 高さ × 4 成分 × 2 バイト × サンプル数)が加わります。 描画バッファ 694 × 462 なら、9 枚ぶんの 8.94 MiB に 4x のぶん 9.78 MiB が乗って 18.7 MiB です。 仕様が保証するのは「少なくともこの成分サイズを確保する」(ES 3.0.6 §3.8.3.1)なので、 実際の確保量はこれ以上になりえます。

ビネットとグレインが sRGB のあとにいる引き替え

表を見ると、色収差・グレイン・ビネットが sRGB エンコードのあとにいます。 これは物理的な正しさで決めたのではなく見た目で決めたものです。 引き替えに何を失っているかははっきりしています — グレインの振幅が「明るさに対して一定」ではなく「エンコード値に対して一定」になるので、暗部で相対的に強く、明部で弱くなります。フィルムの粒子としてはむしろ それらしく見えるので、この章はそのままにしました。リニアで掛けたい人はcomposite.fragのトーンマップの手前へ移してください。移してよいことがはっきりしているのは、 表があるからです。

第32章のチェーンと、この章のチェーン

第32章は 1 フレームを更新パスと描画パスの 2 本に分けました。RASTERIZER_DISCARDで絵を出さないパスを作り、その出力を次のパスが読む。この章のチェーンは、骨格がまったく同じです。「前のパスの出力を、次のパスが入力として読む」 という形が繰り返されているだけで、違うのは置き場がバッファかテクスチャかと、パスが 2 本か 11 本かだけです。

ファイル名の規約もここで揃えておきます。1 本のデモが複数のシェーダーを持つとき、ファイル名でパスの役割を表すのがこのサイトの流儀です。

コード全文

src/lessons/33-postprocess/main.ts
// 第33章: ポストプロセス — ブルーム・トーンマップ・FXAA
//
// この章の骨格は「1 枚の絵を作るのに、描画パスを何本も並べる」こと。
// 4 本のデモは**同じシーン**を描き、変わるのは後段のパスだけ。
//
//   シーン(3D・RGBA16F へ)
//     → 抽出(しきい値・半分の解像度へ)
//     → ぼかし(横 → 縦。段を下げながら N 回)
//     → 合成(シーン + ブルーム → 露出 → トーンマップ → sRGB エンコード)
//     → FXAA
//     → 仕上げ(色収差・グレイン・ビネット・ディザ)
//     → 画面(8 ビット)
//
// 後段のパスはすべて「全画面三角形 1 枚 + フラグメントシェーダー」で、
// 頂点シェーダーは第6章の src/lib/fullscreen.vert をそのまま使い回す(第23章と同じ)。
//
// パスをヘルパーモジュールに畳まず、配列と 1 本のループで書いてある。
// この章で見せたいのはチェーンの形そのものなので、並べてあるほうが読める(本文 1 節)。

import { mat3, mat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import {
  bindRenderTarget,
  createRenderTarget,
  deleteRenderTarget,
  type RenderTarget,
  resizeRenderTarget,
} from '../../lib/framebuffer';
import fullscreenVertexSource from '../../lib/fullscreen.vert?raw';
import {
  createPlane,
  createSphere,
  createTorus,
  type Geometry,
  interleave,
} from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import blurFragmentSource from './blur.frag?raw';
import colorChunkSource from './color.glsl?raw';
import compositeFragmentTemplate from './composite.frag?raw';
import fxaaFragmentSource from './fxaa.frag?raw';
import gradeFragmentTemplate from './grade.frag?raw';
import hashChunkSource from './hash.glsl?raw';
import passthroughFragmentTemplate from './passthrough.frag?raw';
import sceneFragmentSource from './scene.frag?raw';
import sceneVertexSource from './scene.vert?raw';
import thresholdFragmentSource from './threshold.frag?raw';

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

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

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

// ポストプロセスのコストはピクセル数にそのまま比例するので、dpr の上限を 2 から 1.5 へ下げる。
// ピクセル数は (1.5 / 2)² = 0.5625 倍になる(第23・28章と同じ判断。一般論は第35章 5 節)
const MAX_DPR = 1.5;

// ブルームの段数の上限。composite.frag のサンプラの本数と必ず揃えること
const MAX_BLOOM_LEVELS = 5;
// blur.frag の重みは半径 4(9 タップ)。読み取り回数の計算に使う
const BLUR_TAPS = 9;
// fxaa.frag の SEARCH_STEPS と揃える。読み取り回数の上限の計算に使う
const FXAA_SEARCH_STEPS = 8;

const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
const TARGET: ReadonlyVec3 = vec3.fromValues(0, 0, 0);

// canvas の CSS 背景色と同じ sRGB 値。ただしシーンを描く FBO はリニア空間なので、
// クリアにはこれを srgbToLinear で通した値を使う(第27章 3 節の約束)
const BACKGROUND_SRGB: ReadonlyVec3 = vec3.fromValues(0.06, 0.07, 0.09);

// 光の向きは「面から光源へ」(第17章の約束)
const LIGHT_DIRECTION: ReadonlyVec3 = vec3.normalize(
  vec3.create(),
  vec3.fromValues(0.45, 0.8, 0.4),
);
const LIGHT_COLOR: ReadonlyVec3 = vec3.fromValues(1.0, 0.95, 0.86);
const AMBIENT_COLOR: ReadonlyVec3 = vec3.fromValues(0.05, 0.06, 0.08);

const EXPOSURE = 1.0;
const BLOOM_STRENGTH = 1.0;

/** sRGB エンコード値 → リニア値。color.glsl の srgbToLinear を CPU 側に書き写したもの */
function srgbToLinear(c: number): number {
  return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}

// ---------------------------------------------------------------------------
// メッシュ(第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 };
}

// ---------------------------------------------------------------------------
// マルチサンプルのレンダーターゲット(この章ローカル)
//
// src/lib/framebuffer.ts の createRenderTarget はカラーをテクスチャで持つ。
// マルチサンプルの画像はテクスチャにできない(サンプルの束であって 1 枚の絵ではない)ので、
// レンダーバッファで作り、blitFramebuffer で解決してからテクスチャへ移す(本文 5 節)。
// 共通ヘルパーにモードを足さないのは第23・24・29・32章と同じ判断
// ---------------------------------------------------------------------------

interface MultisampleTarget {
  framebuffer: WebGLFramebuffer;
  color: WebGLRenderbuffer;
  depth: WebGLRenderbuffer;
  width: number;
  height: number;
  samples: number;
}

/**
 * この形式で使えるサンプル数を GPU に聞く。降順で返ってくる(ES 3.0.6 §6.1.15)。
 * RGBA16F のマルチサンプルは EXT_color_buffer_float では**任意**なので、
 * 「拡張があるから 4x が使える」とは限らない。必ず問い合わせる(本文 5 節)
 */
function supportedSampleCounts(gl: WebGL2RenderingContext, internalFormat: GLenum): number[] {
  const counts = gl.getInternalformatParameter(gl.RENDERBUFFER, internalFormat, gl.SAMPLES);
  return counts instanceof Int32Array ? Array.from(counts) : [];
}

function createMultisampleTarget(
  gl: WebGL2RenderingContext,
  width: number,
  height: number,
  samples: number,
  internalFormat: GLenum,
): MultisampleTarget {
  const framebuffer = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);

  const color = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, color);
  gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, internalFormat, width, height);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, color);

  // 深度も同じサンプル数で作る。揃っていないと FRAMEBUFFER_INCOMPLETE_MULTISAMPLE になる
  const depth = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, depth);
  gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, gl.DEPTH_COMPONENT24, width, height);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth);
  gl.bindRenderbuffer(gl.RENDERBUFFER, null);

  const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  if (status !== gl.FRAMEBUFFER_COMPLETE) {
    gl.deleteFramebuffer(framebuffer);
    gl.deleteRenderbuffer(color);
    gl.deleteRenderbuffer(depth);
    throw new Error(`マルチサンプル FBO が不完全です(0x${status.toString(16)})`);
  }
  // 実際に確保されたサンプル数は「要求以上・実装が対応する次の段以下」なので、聞き直す
  gl.bindRenderbuffer(gl.RENDERBUFFER, color);
  const actual = gl.getRenderbufferParameter(gl.RENDERBUFFER, gl.RENDERBUFFER_SAMPLES) as number;
  gl.bindRenderbuffer(gl.RENDERBUFFER, null);

  return { framebuffer, color, depth, width, height, samples: actual };
}

function deleteMultisampleTarget(gl: WebGL2RenderingContext, target: MultisampleTarget): void {
  gl.deleteFramebuffer(target.framebuffer);
  gl.deleteRenderbuffer(target.color);
  gl.deleteRenderbuffer(target.depth);
}

// ---------------------------------------------------------------------------
// デモとオプション
// ---------------------------------------------------------------------------

type PeekStage = 'scene' | 'threshold' | 'blur' | 'composite';
type AntialiasMode = 'none' | 'fxaa' | 'msaa';

interface Demo {
  id: string;
  label: string;
  note: string;
}

const demos: Demo[] = [
  { id: 'peek', label: 'パスを覗く', note: 'チェーンの途中の FBO をそのまま画面に出す' },
  { id: 'bloom', label: 'ブルーム', note: 'しきい値と段数でブルームの出方が変わる' },
  { id: 'aa', label: 'アンチエイリアス', note: '同じ絵を「なし / FXAA / MSAA」で見比べる' },
  { id: 'grade', label: '仕上げ', note: '色収差・グレイン・ビネット・ディザの on-off' },
];

// つまみの既定値。デモを切り替えるたびにここへ戻す(下の切替ボタンを参照)
const DEFAULT_STATE = {
  peek: 'composite' as PeekStage,
  threshold: 1.5,
  levels: 3,
  antialias: 'fxaa' as AntialiasMode,
  chromatic: true,
  grain: true,
  vignette: true,
  dither: true,
};

const state = { ...DEFAULT_STATE };

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
  options: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  // --- float の描画先が使えるか(第20章 8 節・本文 2 節) --------------------------
  // EXT_color_buffer_float が本命。無ければ EXT_color_buffer_half_float
  // (「16 ビット float にしか描けない端末のために WebGL 2.0 へも公開した」と
  //  拡張仕様の 2020-08-31 の改訂に書いてある)。どちらも無ければ RGBA8 へ落ちる
  const hasColorBufferFloat = gl.getExtension('EXT_color_buffer_float') !== null;
  const hasColorBufferHalfFloat = gl.getExtension('EXT_color_buffer_half_float') !== null;
  const hdrFormat = hasColorBufferFloat || hasColorBufferHalfFloat ? gl.RGBA16F : gl.RGBA8;
  const hdrBytesPerPixel = hdrFormat === gl.RGBA8 ? 4 : 8;
  const hdrFormatName = hdrFormat === gl.RGBA8 ? 'RGBA8' : 'RGBA16F';
  // RGBA8 に落ちたときは 1 を超える明るさが保持できないので、**しきい値のほうを 0.35 倍に
  // 下げて**抽出する。ブルームは出るが「1 を超えた分」ではなくなる(本文 2 節)
  const thresholdScale = hdrFormat === gl.RGBA8 ? 0.35 : 1.0;

  // --- MSAA が使えるか(本文 5 節) -------------------------------------------------
  // RGBA16F のマルチサンプルは EXT_color_buffer_float では任意扱いなので、
  // 深度と合わせて「両方が対応しているサンプル数」を取る
  const colorSamples = supportedSampleCounts(gl, hdrFormat);
  const depthSamples = supportedSampleCounts(gl, gl.DEPTH_COMPONENT24);
  const msaaSamples = colorSamples
    .filter((s) => s <= 4 && depthSamples.includes(s))
    .reduce((best, s) => Math.max(best, s), 0);

  // --- プログラム -------------------------------------------------------------------
  const sceneProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, sceneVertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, sceneFragmentSource),
  );

  /** 全画面パスのプログラム。頂点シェーダーは第6章の資産をそのまま使い回す */
  function createPostProgram(fragmentSource: string): WebGLProgram {
    return linkProgram(
      gl,
      compileShader(gl, gl.VERTEX_SHADER, fullscreenVertexSource),
      compileShader(gl, gl.FRAGMENT_SHADER, resolveIncludes(fragmentSource)),
    );
  }

  const thresholdProgram = createPostProgram(thresholdFragmentSource);
  const blurProgram = createPostProgram(blurFragmentSource);
  const compositeProgram = createPostProgram(compositeFragmentTemplate);
  const fxaaProgram = createPostProgram(fxaaFragmentSource);
  const gradeProgram = createPostProgram(gradeFragmentTemplate);
  const passthroughProgram = createPostProgram(passthroughFragmentTemplate);

  const sceneUniforms = {
    model: gl.getUniformLocation(sceneProgram, 'u_model'),
    view: gl.getUniformLocation(sceneProgram, 'u_view'),
    projection: gl.getUniformLocation(sceneProgram, 'u_projection'),
    normalMatrix: gl.getUniformLocation(sceneProgram, 'u_normalMatrix'),
    baseColor: gl.getUniformLocation(sceneProgram, 'u_baseColor'),
    emissive: gl.getUniformLocation(sceneProgram, 'u_emissive'),
    lightDirection: gl.getUniformLocation(sceneProgram, 'u_lightDirection'),
    lightColor: gl.getUniformLocation(sceneProgram, 'u_lightColor'),
    ambientColor: gl.getUniformLocation(sceneProgram, 'u_ambientColor'),
    cameraPosition: gl.getUniformLocation(sceneProgram, 'u_cameraPosition'),
    shininess: gl.getUniformLocation(sceneProgram, 'u_shininess'),
    specularStrength: gl.getUniformLocation(sceneProgram, 'u_specularStrength'),
  };
  const thresholdUniforms = {
    source: gl.getUniformLocation(thresholdProgram, 'u_source'),
    resolution: gl.getUniformLocation(thresholdProgram, 'u_resolution'),
    threshold: gl.getUniformLocation(thresholdProgram, 'u_threshold'),
    knee: gl.getUniformLocation(thresholdProgram, 'u_knee'),
  };
  const blurUniforms = {
    source: gl.getUniformLocation(blurProgram, 'u_source'),
    resolution: gl.getUniformLocation(blurProgram, 'u_resolution'),
    texelStep: gl.getUniformLocation(blurProgram, 'u_texelStep'),
    direction: gl.getUniformLocation(blurProgram, 'u_direction'),
  };
  const compositeUniforms = {
    scene: gl.getUniformLocation(compositeProgram, 'u_scene'),
    bloom: [0, 1, 2, 3, 4].map((i) => gl.getUniformLocation(compositeProgram, `u_bloom${i}`)),
    resolution: gl.getUniformLocation(compositeProgram, 'u_resolution'),
    bloomLevels: gl.getUniformLocation(compositeProgram, 'u_bloomLevels'),
    bloomStrength: gl.getUniformLocation(compositeProgram, 'u_bloomStrength'),
    exposure: gl.getUniformLocation(compositeProgram, 'u_exposure'),
  };
  const fxaaUniforms = {
    source: gl.getUniformLocation(fxaaProgram, 'u_source'),
    resolution: gl.getUniformLocation(fxaaProgram, 'u_resolution'),
  };
  const gradeUniforms = {
    source: gl.getUniformLocation(gradeProgram, 'u_source'),
    resolution: gl.getUniformLocation(gradeProgram, 'u_resolution'),
    frame: gl.getUniformLocation(gradeProgram, 'u_frame'),
    chromatic: gl.getUniformLocation(gradeProgram, 'u_chromatic'),
    grain: gl.getUniformLocation(gradeProgram, 'u_grain'),
    vignette: gl.getUniformLocation(gradeProgram, 'u_vignette'),
    dither: gl.getUniformLocation(gradeProgram, 'u_dither'),
  };
  const passthroughUniforms = {
    source: gl.getUniformLocation(passthroughProgram, 'u_source'),
    resolution: gl.getUniformLocation(passthroughProgram, 'u_resolution'),
    gain: gl.getUniformLocation(passthroughProgram, 'u_gain'),
    tonemap: gl.getUniformLocation(passthroughProgram, 'u_tonemap'),
  };

  // --- シーン(第13〜17章の道具をそのまま使っているだけ) ---------------------------
  const floorMesh = createMesh(gl, createPlane(26, 26));
  const torusMesh = createMesh(gl, createTorus(1.55, 0.42, 72, 32));
  const sphereMesh = createMesh(gl, createSphere(0.85, 48, 24));
  const lampMesh = createMesh(gl, createSphere(0.15, 20, 12));

  // 自発光の小球。リニア値で 1 を大きく超える色を入れる。これがブルームの種になる(2 節)
  const LAMP_COLORS: readonly ReadonlyVec3[] = [
    vec3.fromValues(1.0, 0.42, 0.14),
    vec3.fromValues(0.2, 0.62, 1.0),
    vec3.fromValues(0.95, 0.85, 0.25),
    vec3.fromValues(0.85, 0.24, 0.62),
    vec3.fromValues(0.3, 1.0, 0.62),
    vec3.fromValues(0.55, 0.4, 1.0),
  ];
  const LAMP_INTENSITY = 16;
  const LAMP_RADIUS = 2.9;

  const FLOOR_BASE_COLOR: ReadonlyVec3 = vec3.fromValues(0.045, 0.05, 0.06);
  const TORUS_BASE_COLOR: ReadonlyVec3 = vec3.fromValues(0.34, 0.16, 0.09);
  const SPHERE_BASE_COLOR: ReadonlyVec3 = vec3.fromValues(0.55, 0.56, 0.58);
  const NO_EMISSIVE: ReadonlyVec3 = vec3.fromValues(0, 0, 0);

  const backgroundLinear = vec3.fromValues(
    srgbToLinear(BACKGROUND_SRGB[0]),
    srgbToLinear(BACKGROUND_SRGB[1]),
    srgbToLinear(BACKGROUND_SRGB[2]),
  );

  // --- レンダーターゲット -------------------------------------------------------------
  interface BloomLevel {
    a: RenderTarget;
    b: RenderTarget;
  }

  function levelSize(width: number, height: number, index: number): [number, number] {
    // 段 0 が半分。1 ピクセル未満にならないように必ず 1 で止める
    const shift = index + 1;
    return [Math.max(1, width >> shift), Math.max(1, height >> shift)];
  }

  function createLevel(width: number, height: number, index: number): BloomLevel {
    const [w, h] = levelSize(width, height, index);
    const opts = { depth: false, internalFormat: hdrFormat, filter: gl.LINEAR };
    return { a: createRenderTarget(gl, w, h, opts), b: createRenderTarget(gl, w, h, opts) };
  }

  let sceneTarget = createRenderTarget(gl, 1, 1, {
    depth: true, // シーンパスだけは深度が要る
    internalFormat: hdrFormat,
    filter: gl.LINEAR,
  });
  // 合成 → FXAA → 仕上げ を数珠つなぎにするための 2 枚。**中身は sRGB エンコード済みの値**だが、
  // 入れ物は float のまま。8 ビットに落ちるのは画面だけ、という形にしてある(本文 7 節)
  let ldrA = createRenderTarget(gl, 1, 1, {
    depth: false,
    internalFormat: hdrFormat,
    filter: gl.LINEAR,
  });
  let ldrB = createRenderTarget(gl, 1, 1, {
    depth: false,
    internalFormat: hdrFormat,
    filter: gl.LINEAR,
  });
  const levels: BloomLevel[] = [];
  let multisample: MultisampleTarget | null = null;

  /** 描画バッファのサイズと段数に、全 FBO を合わせる */
  function syncTargets(width: number, height: number, levelCount: number): void {
    // resizeRenderTarget は「サイズが違えば新しく作ってから古いものを消す」ので、
    // **戻り値を必ず受け取り直す**(渡したほうは解放済みになっている)
    sceneTarget = resizeRenderTarget(gl, sceneTarget, width, height);
    ldrA = resizeRenderTarget(gl, ldrA, width, height);
    ldrB = resizeRenderTarget(gl, ldrB, width, height);

    while (levels.length > levelCount) {
      const level = levels.pop();
      if (level) {
        deleteRenderTarget(gl, level.a);
        deleteRenderTarget(gl, level.b);
      }
    }
    for (let i = 0; i < levelCount; i++) {
      const [w, h] = levelSize(width, height, i);
      if (i < levels.length) {
        levels[i] = {
          a: resizeRenderTarget(gl, levels[i].a, w, h),
          b: resizeRenderTarget(gl, levels[i].b, w, h),
        };
      } else {
        levels.push(createLevel(width, height, i));
      }
    }

    // MSAA のレンダーバッファは使うときだけ持つ。デモを離れたら解放する
    const wantMultisample = demos[demoIndex].id === 'aa' && state.antialias === 'msaa';
    if (
      multisample &&
      (!wantMultisample || multisample.width !== width || multisample.height !== height)
    ) {
      deleteMultisampleTarget(gl, multisample);
      multisample = null;
    }
    if (wantMultisample && multisample === null && msaaSamples > 0) {
      multisample = createMultisampleTarget(gl, width, height, msaaSamples, hdrFormat);
    }
  }

  /** 中間 FBO の枚数と、カラーアタッチメントの合計バイト数 */
  function targetStats(): { count: number; bytes: number } {
    const rts = [sceneTarget, ldrA, ldrB];
    for (const level of levels) rts.push(level.a, level.b);
    let bytes = 0;
    for (const rt of rts) bytes += rt.width * rt.height * hdrBytesPerPixel;
    let count = rts.length;
    if (multisample) {
      // マルチサンプルのカラーは 1 ピクセルにつきサンプル数だけ持つ
      bytes += multisample.width * multisample.height * hdrBytesPerPixel * multisample.samples;
      count += 1;
    }
    return { count, bytes };
  }

  // --- 状態 -----------------------------------------------------------------------
  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.CULL_FACE);

  let demoIndex = 0;
  let frameNumber = 0;

  // --- 軌道カメラ(第15章。解説はそちらの担当) -------------------------------------
  const orbit = { theta: 0.55, phi: 0.22, radius: 8.2 };
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 3.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章の作りをそのまま) ---------------------------------------
  interface OptionButton {
    label: string;
    pressed: () => boolean;
    apply: () => void;
  }

  function currentOptions(): OptionButton[] {
    switch (demos[demoIndex].id) {
      case 'peek':
        return (
          [
            ['シーン', 'scene'],
            ['しきい値', 'threshold'],
            ['ぼかし', 'blur'],
            ['合成', 'composite'],
          ] as [string, PeekStage][]
        ).map(([label, stage]) => ({
          label,
          pressed: () => state.peek === stage,
          apply: () => {
            state.peek = stage;
          },
        }));
      case 'bloom':
        return [
          ...[0.8, 1.5, 3.0].map((value) => ({
            label: `しきい値 ${value.toFixed(1)}`,
            pressed: () => state.threshold === value,
            apply: () => {
              state.threshold = value;
            },
          })),
          ...[1, 3, 5].map((value) => ({
            label: `段数 ${value}`,
            pressed: () => state.levels === value,
            apply: () => {
              state.levels = value;
            },
          })),
        ];
      case 'aa':
        return (
          [
            ['なし', 'none'],
            ['FXAA', 'fxaa'],
            [msaaSamples > 0 ? `MSAA ${msaaSamples}x` : 'MSAA(非対応)', 'msaa'],
          ] as [string, AntialiasMode][]
        ).map(([label, mode]) => ({
          label,
          pressed: () => state.antialias === mode,
          apply: () => {
            if (mode === 'msaa' && msaaSamples === 0) return;
            state.antialias = mode;
          },
        }));
      default: {
        const toggles: [string, 'chromatic' | 'grain' | 'vignette' | 'dither'][] = [
          ['色収差', 'chromatic'],
          ['グレイン', 'grain'],
          ['ビネット', 'vignette'],
          ['ディザ', 'dither'],
        ];
        return toggles.map(([label, key]) => ({
          label,
          pressed: () => state[key],
          apply: () => {
            state[key] = !state[key];
          },
        }));
      }
    }
  }

  function renderOptions(): void {
    options.replaceChildren();
    const items = currentOptions();
    options.hidden = items.length === 0;
    for (const item of items) {
      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = item.label;
      button.setAttribute('aria-pressed', item.pressed() ? 'true' : 'false');
      button.addEventListener('click', () => {
        item.apply();
        renderOptions();
      });
      options.append(button);
    }
  }

  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', () => {
      demoIndex = index;
      // つまみを既定へ戻す。戻さないと「前のデモで動かしたつまみ」が残る。とくに
      // ブルームの段数はデモ 3 / 4 にボタンが出ないので、戻さないと読み出し行の
      // 中間 FBO の枚数がなぜ変わったのか画面から読み取れなくなる(第35章も同じ扱い)
      Object.assign(state, DEFAULT_STATE);
      for (const other of controls.querySelectorAll('button')) {
        other.setAttribute('aria-pressed', 'false');
      }
      button.setAttribute('aria-pressed', 'true');
      renderOptions();
    });
    controls.append(button);
  }
  renderOptions();

  // --- 毎フレーム使い回す入れ物 -------------------------------------------------
  const eye = vec3.create();
  const view = mat4.create();
  const projection = mat4.create();
  const model = mat4.create();
  const normalMatrix = mat3.create();
  const lampPosition = vec3.create();
  const lampEmissive = vec3.create();

  // --- リサイズ -----------------------------------------------------------------
  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, MAX_DPR);
    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)のまま
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
  }

  // --- シーンパス ---------------------------------------------------------------
  function drawObject(
    mesh: Mesh,
    baseColor: ReadonlyVec3,
    emissive: ReadonlyVec3,
    shininess: number,
    specularStrength: number,
  ): void {
    mat3.normalFromMat4(normalMatrix, model);
    gl.uniformMatrix4fv(sceneUniforms.model, false, model); // transpose は常に false
    gl.uniformMatrix3fv(sceneUniforms.normalMatrix, false, normalMatrix);
    gl.uniform3fv(sceneUniforms.baseColor, baseColor);
    gl.uniform3fv(sceneUniforms.emissive, emissive);
    gl.uniform1f(sceneUniforms.shininess, shininess);
    gl.uniform1f(sceneUniforms.specularStrength, specularStrength);
    gl.bindVertexArray(mesh.vao);
    gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
  }

  function drawScene(width: number, height: number, time: number): void {
    gl.clearColor(backgroundLinear[0], backgroundLinear[1], backgroundLinear[2], 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    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);
    // aspect は描画先の幅 / 高さから(第3部の規約)。ここは FBO なので FBO のサイズ
    mat4.perspective(projection, FOVY, width / height, NEAR, FAR);

    gl.useProgram(sceneProgram);
    gl.uniformMatrix4fv(sceneUniforms.view, false, view);
    gl.uniformMatrix4fv(sceneUniforms.projection, false, projection);
    gl.uniform3fv(sceneUniforms.lightDirection, LIGHT_DIRECTION);
    gl.uniform3fv(sceneUniforms.lightColor, LIGHT_COLOR);
    gl.uniform3fv(sceneUniforms.ambientColor, AMBIENT_COLOR);
    gl.uniform3fv(sceneUniforms.cameraPosition, eye);

    mat4.fromTranslation(model, [0, -1.6, 0]);
    mat4.rotateX(model, model, -Math.PI / 2);
    drawObject(floorMesh, FLOOR_BASE_COLOR, NO_EMISSIVE, 24, 0.2);

    mat4.identity(model);
    mat4.rotateY(model, model, time * 0.25);
    mat4.rotateX(model, model, Math.PI / 2 + Math.sin(time * 0.35) * 0.25);
    drawObject(torusMesh, TORUS_BASE_COLOR, NO_EMISSIVE, 96, 1.6);

    mat4.identity(model);
    // 鏡面が 1 を大きく超えるように強めに。この白飛びがブルームの種になる(2 節)
    drawObject(sphereMesh, SPHERE_BASE_COLOR, NO_EMISSIVE, 220, 6.0);

    for (const [index, color] of LAMP_COLORS.entries()) {
      const angle = time * 0.18 + (index / LAMP_COLORS.length) * Math.PI * 2;
      lampPosition[0] = Math.cos(angle) * LAMP_RADIUS;
      lampPosition[1] = Math.sin(angle * 2 + index) * 0.9;
      lampPosition[2] = Math.sin(angle) * LAMP_RADIUS;
      mat4.fromTranslation(model, lampPosition);
      vec3.scale(lampEmissive, color, LAMP_INTENSITY);
      drawObject(lampMesh, NO_EMISSIVE, lampEmissive, 32, 0.0);
    }

    gl.bindVertexArray(null);
  }

  // --- 全画面パスのチェーン -------------------------------------------------------
  interface Pass {
    label: string;
    /** null なら画面(既定のフレームバッファ) */
    target: RenderTarget | null;
    /** 描画先の幅・高さを受け取って、プログラムと uniform を用意する */
    setup: (width: number, height: number) => void;
  }

  function bindSource(
    unit: number,
    texture: WebGLTexture,
    location: WebGLUniformLocation | null,
  ): void {
    gl.activeTexture(gl.TEXTURE0 + unit);
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.uniform1i(location, unit);
  }

  function thresholdPass(target: RenderTarget): Pass {
    return {
      label: '抽出',
      target,
      setup: (width, height) => {
        gl.useProgram(thresholdProgram);
        gl.uniform2f(thresholdUniforms.resolution, width, height);
        gl.uniform1f(thresholdUniforms.threshold, state.threshold * thresholdScale);
        // smoothstep の edge0 < edge1 を守るため、幅は必ず正にする(3 節)
        gl.uniform1f(thresholdUniforms.knee, Math.max(0.05, state.threshold * 0.5));
        bindSource(0, sceneTarget.texture, thresholdUniforms.source);
      },
    };
  }

  function blurPass(source: RenderTarget, target: RenderTarget, horizontal: boolean): Pass {
    return {
      label: horizontal ? 'ぼかし(横)' : 'ぼかし(縦)',
      target,
      setup: (width, height) => {
        gl.useProgram(blurProgram);
        gl.uniform2f(blurUniforms.resolution, width, height);
        // 歩幅は**描画先**のテクセル 1 つぶん。ソースのテクセル幅にすると、縮小を兼ねる
        // 横パスだけ歩幅が画面ピクセル換算で半分になって滲みが縦長になる(4 節)
        gl.uniform2f(blurUniforms.texelStep, 1 / target.width, 1 / target.height);
        gl.uniform2f(blurUniforms.direction, horizontal ? 1 : 0, horizontal ? 0 : 1);
        bindSource(0, source.texture, blurUniforms.source);
      },
    };
  }

  function compositePass(target: RenderTarget | null, bloomLevels: number): Pass {
    return {
      label: '合成',
      target,
      setup: (width, height) => {
        gl.useProgram(compositeProgram);
        gl.uniform2f(compositeUniforms.resolution, width, height);
        gl.uniform1i(compositeUniforms.bloomLevels, bloomLevels);
        gl.uniform1f(compositeUniforms.bloomStrength, BLOOM_STRENGTH);
        gl.uniform1f(compositeUniforms.exposure, EXPOSURE);
        bindSource(0, sceneTarget.texture, compositeUniforms.scene);
        // 使わない段のサンプラにも、有効なテクスチャを結んでおく。不完全なテクスチャを
        // 読んでもエラーにはならず、黙って (0, 0, 0, 1) が返るだけ(ES 3.0.6 §3.9.2)なので、
        // 結び忘れは「絵が変わらない不具合」になって気づきにくい
        for (let i = 0; i < MAX_BLOOM_LEVELS; i++) {
          const level = levels[Math.min(i, Math.max(0, levels.length - 1))];
          bindSource(1 + i, level.a.texture, compositeUniforms.bloom[i]);
        }
      },
    };
  }

  function fxaaPass(source: RenderTarget, target: RenderTarget | null): Pass {
    return {
      label: 'FXAA',
      target,
      setup: (width, height) => {
        gl.useProgram(fxaaProgram);
        gl.uniform2f(fxaaUniforms.resolution, width, height);
        bindSource(0, source.texture, fxaaUniforms.source);
      },
    };
  }

  function gradePass(source: RenderTarget, target: RenderTarget | null): Pass {
    return {
      label: '仕上げ',
      target,
      setup: (width, height) => {
        gl.useProgram(gradeProgram);
        gl.uniform2f(gradeUniforms.resolution, width, height);
        gl.uniform1i(gradeUniforms.frame, frameNumber);
        gl.uniform1f(gradeUniforms.chromatic, state.chromatic ? 3.0 : 0.0);
        gl.uniform1f(gradeUniforms.grain, state.grain ? 0.045 : 0.0);
        gl.uniform1f(gradeUniforms.vignette, state.vignette ? 0.55 : 0.0);
        gl.uniform1f(gradeUniforms.dither, state.dither ? 1.0 : 0.0);
        bindSource(0, source.texture, gradeUniforms.source);
      },
    };
  }

  function passthroughPass(source: RenderTarget, tonemap: boolean, gain: number): Pass {
    return {
      label: '覗き見',
      target: null,
      setup: (width, height) => {
        gl.useProgram(passthroughProgram);
        gl.uniform2f(passthroughUniforms.resolution, width, height);
        gl.uniform1f(passthroughUniforms.gain, gain);
        gl.uniform1f(passthroughUniforms.tonemap, tonemap ? 1 : 0);
        bindSource(0, source.texture, passthroughUniforms.source);
      },
    };
  }

  /** 段 i のぼかし 2 本。入力は段 0 なら抽出の結果、それ以外は 1 つ上の段の結果 */
  function blurPassesFor(index: number): Pass[] {
    const source = index === 0 ? levels[0].a : levels[index - 1].a;
    return [
      // 段 1 以降は横パスがそのまま縮小も兼ねる(描画先が半分なので LINEAR が 2×2 を平均する)。
      // 段 0 のソースは抽出の結果で、すでに半分の解像度なので縮小は起きない
      blurPass(source, levels[index].b, true),
      blurPass(levels[index].b, levels[index].a, false),
    ];
  }

  /** この 1 フレームで走らせるパスの並びを組み立てる。デモの違いはここに全部出る */
  function buildPasses(): Pass[] {
    const demo = demos[demoIndex];
    const passes: Pass[] = [];
    const levelCount = levels.length;

    if (demo.id === 'peek') {
      // 覗くところでチェーンを打ち切る。パス数がそのまま変わるのが読み出し行に出る
      if (state.peek === 'scene') {
        return [passthroughPass(sceneTarget, true, 1.0)];
      }
      passes.push(thresholdPass(levels[0].a));
      if (state.peek === 'threshold') {
        return [...passes, passthroughPass(levels[0].a, true, 1.0)];
      }
      for (let i = 0; i < levelCount; i++) passes.push(...blurPassesFor(i));
      if (state.peek === 'blur') {
        return [...passes, passthroughPass(levels[0].a, true, 1.0)];
      }
      passes.push(compositePass(ldrA, levelCount));
      return [...passes, passthroughPass(ldrA, false, 1.0)];
    }

    passes.push(thresholdPass(levels[0].a));
    for (let i = 0; i < levelCount; i++) passes.push(...blurPassesFor(i));

    if (demo.id === 'bloom') {
      passes.push(compositePass(null, levelCount));
      return passes;
    }
    if (demo.id === 'aa') {
      if (state.antialias === 'fxaa') {
        passes.push(compositePass(ldrA, levelCount));
        passes.push(fxaaPass(ldrA, null));
      } else {
        // 「なし」も「MSAA」も後段は同じ。MSAA はシーンパスの側で効いている
        passes.push(compositePass(null, levelCount));
      }
      return passes;
    }
    // 仕上げ: 合成 → FXAA → 色収差・グレイン・ビネット・ディザ
    passes.push(compositePass(ldrA, levelCount));
    passes.push(fxaaPass(ldrA, ldrB));
    passes.push(gradePass(ldrB, null));
    return passes;
  }

  // --- 読み出し行 -----------------------------------------------------------------
  // この章は時間を測らない。ポストプロセスのコストは「何ピクセルを何回読んで何回書くか」で
  // ほぼ決まるので、数えられる量だけを出す。GPU 時間・rAF 間隔の読み方は第35章

  /** 画面 1 ピクセルあたりのテクスチャ読み取り回数。段は面積が 1/4 ずつ減る */
  function textureReadsPerPixel(passes: Pass[]): number {
    let total = 0;
    for (const pass of passes) {
      const width = pass.target ? pass.target.width : gl.drawingBufferWidth;
      const height = pass.target ? pass.target.height : gl.drawingBufferHeight;
      const area = (width * height) / (gl.drawingBufferWidth * gl.drawingBufferHeight);
      let taps = 1;
      if (pass.label.startsWith('ぼかし')) taps = BLUR_TAPS;
      else if (pass.label === '合成') taps = 1 + levels.length;
      // FXAA は最悪の場合: 十字 5 + 角 4 + 探索 2 × 歩数 + 再サンプル 1
      else if (pass.label === 'FXAA') taps = 5 + 4 + 2 * FXAA_SEARCH_STEPS + 1;
      else if (pass.label === '仕上げ') taps = state.chromatic ? 3 : 1;
      total += taps * area;
    }
    return total;
  }

  let lastReadoutAt = 0;

  function updateReadout(passes: Pass[]): void {
    const demo = demos[demoIndex];
    const stats = targetStats();
    const pixels = gl.drawingBufferWidth * gl.drawingBufferHeight;
    const mib = stats.bytes / (1024 * 1024);
    // multisample が非 null なのは「デモ 3 で MSAA を選べた」ときだけ。
    // 使えない環境では apply() が state.antialias を 'msaa' にしないので、
    // 読者が見るのはボタンのラベル `MSAA(非対応)` のほうになる(本文 5 節)
    const msaaNote = multisample
      ? ` / MSAA ${multisample.samples}x のレンダーバッファ 1 枚を blitFramebuffer で解決`
      : '';
    readout.textContent =
      `${demo.note} / パス ${passes.length + 1} 本(シーン 1 + 全画面 ${passes.length}) / ` +
      `中間 FBO ${stats.count} 枚・カラーの合計 ${stats.bytes.toLocaleString('en-US')} B ` +
      `= ${mib.toFixed(1)} MiB(${hdrFormatName} = 4 成分 × ${hdrBytesPerPixel / 4} バイト) / ` +
      `画面 1 px あたりのテクスチャ読み取り ${textureReadsPerPixel(passes).toFixed(2)} 回 / ` +
      `描画バッファ ${gl.drawingBufferWidth}×${gl.drawingBufferHeight} = ` +
      `${pixels.toLocaleString('en-US')} px${msaaNote}`;
  }

  // --- 描画ループ -----------------------------------------------------------------
  function frame(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();
    const width = gl.drawingBufferWidth;
    const height = gl.drawingBufferHeight;
    syncTargets(width, height, state.levels);
    const time = timestamp / 1000;
    // grade.frag は u_frame に定数を掛けて座標をずらすので、その掛け算が 32 ビットに
    // 収まる範囲で折り返しておく(GLSL ES 3.00 §4.1.3 は overflow も「下位 n ビットへ
    // 折り返す」と定めているので壊れはしないが、追える形にしておく)。
    // それでも 32767 は軽く超えるので、grade.frag 側は precision highp int が要る(第29章 2 節)
    frameNumber = (frameNumber + 1) % 65536;

    // --- シーンパス。MSAA のときだけレンダーバッファへ描いて解決する ----------------
    if (multisample) {
      gl.bindFramebuffer(gl.FRAMEBUFFER, multisample.framebuffer);
      gl.viewport(0, 0, multisample.width, multisample.height);
      drawScene(multisample.width, multisample.height, time);
      // マルチサンプルの束を 1 枚の絵に解決してテクスチャへ移す。
      // 読み側と書き側で形式が違ったり、矩形の大きさが違ったりすると INVALID_OPERATION
      // (ES 3.0.6 §4.3.3)。ここは同じ RGBA16F・同じ矩形
      gl.bindFramebuffer(gl.READ_FRAMEBUFFER, multisample.framebuffer);
      gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, sceneTarget.framebuffer);
      gl.blitFramebuffer(
        0,
        0,
        multisample.width,
        multisample.height,
        0,
        0,
        sceneTarget.width,
        sceneTarget.height,
        gl.COLOR_BUFFER_BIT,
        gl.NEAREST, // 解決のときは filter は無視される(§4.3.3)
      );
      gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
      gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, null);
    } else {
      bindRenderTarget(gl, sceneTarget);
      drawScene(sceneTarget.width, sceneTarget.height, time);
    }

    // --- 全画面パスのチェーン。並べた配列を 1 本のループで流すだけ -------------------
    const passes = buildPasses();
    // 全画面三角形は深度を持たないし、書き潰すだけなので深度テストは切る
    gl.disable(gl.DEPTH_TEST);
    for (const pass of passes) {
      bindRenderTarget(gl, pass.target); // viewport も一緒に切り替わる(第20章)
      const targetWidth = pass.target ? pass.target.width : gl.drawingBufferWidth;
      const targetHeight = pass.target ? pass.target.height : gl.drawingBufferHeight;
      pass.setup(targetWidth, targetHeight);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    }
    gl.enable(gl.DEPTH_TEST);

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

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

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

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

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

setup(gl, canvas, controls, optionControls, readout);
src/lessons/33-postprocess/scene.vert
#version 300 es

// シーンパスの頂点シェーダー。第13〜17章でやっていることと同じで、この章の主題ではない。

// 第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 vec3 v_worldPosition;

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

  v_worldPosition = worldPosition.xyz;
  v_normal = u_normalMatrix * a_normal;

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/33-postprocess/scene.frag
#version 300 es
precision highp float;

// シーンパスのフラグメントシェーダー。Blinn-Phong(第17章)そのままで、
// この章の主題ではない。ひとつだけ違うのが最後の 1 行 —
// **リニア空間の値をそのまま出す**。sRGB エンコードはしない。
//
// 画面へ直接描く章(第17〜23章)なら、ここが最後のパスなので出す直前に
// linearToSrgb を通していた。この章の描画先は RGBA16F の FBO で、
// そのあとに抽出・ぼかし・合成が続く。足し算も掛け算もリニア値の上でやりたいので、
// sRGB へ変換するのは**チェーンのいちばん最後**(composite.frag)だけになる。

in vec3 v_normal;
in vec3 v_worldPosition;

uniform vec3 u_baseColor; // リニア空間の反射率(第22章の約束と同じ)
uniform vec3 u_emissive; // 自発光。1 を大きく超える値を入れてよい(2 節)
uniform vec3 u_lightDirection; // 面から光源へ向かう単位ベクトル(第17章)
uniform vec3 u_lightColor;
uniform vec3 u_ambientColor;
uniform vec3 u_cameraPosition;
uniform float u_shininess;
uniform float u_specularStrength;

out vec4 fragColor;

void main() {
  vec3 N = normalize(v_normal);
  vec3 L = u_lightDirection; // 単位ベクトルと約束した uniform は受け側で再正規化しない
  vec3 V = normalize(u_cameraPosition - v_worldPosition);
  vec3 H = normalize(L + V);

  float NdotL = max(dot(N, L), 0.0);
  // 裏を向いた面にハイライトが乗らないよう、拡散が 0 のところでは鏡面も 0 にする
  float specular = pow(max(dot(N, H), 0.0), u_shininess) * step(1e-4, NdotL);

  vec3 color = u_baseColor * (u_ambientColor + u_lightColor * NdotL)
    + u_lightColor * specular * u_specularStrength
    + u_emissive;

  // リニア HDR。1 を超えた値は RGBA16F にそのまま入る(2 節)
  fragColor = vec4(color, 1.0);
}
src/lessons/33-postprocess/threshold.frag
#version 300 es
precision highp float;

// 抽出パス: シーン(リニア HDR)から「明るいところ」だけを取り出す(3 節)。
// 描画先はシーンの半分の解像度なので、このパスは縮小も兼ねている。
//
// 頂点シェーダーは src/lib/fullscreen.vert(第6章)。あれは varying を 1 つも出さないので、
// UV は gl_FragCoord から自分で作る。u_resolution は**描画先**の解像度。

// 中間 FBO は RGBA16F。float テクスチャを読むサンプラには highp を明示する(第21章 6 節)
uniform highp sampler2D u_source;
uniform vec2 u_resolution;
uniform float u_threshold; // ここから下は捨てる(リニア値)
uniform float u_knee; // しきい値をまたぐ幅。0 だと境界がちらつく

// BT.709 の輝度係数。出典: Rec. ITU-R BT.709-6 (06/2015) item 3.2。
// item 3.2 は「非線形プリコレクション後の信号 E'」に掛ける式だが、同じ 3 つの数は
// item 1.3 の原色と item 1.4 の白色点 D65 から作った RGB→XYZ 行列の Y の行でもあるので、
// リニア値に掛ければリニアの輝度になる(3 節)
const vec3 LUMA_BT709 = vec3(0.2126, 0.7152, 0.0722);

out vec4 fragColor;

void main() {
  // 描画先が半分の解像度なので、この uv はソースの 2×2 テクセルのちょうど角を指す。
  // LINEAR フィルタがその 4 つの平均を返すので、縮小はここで済んでいる
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec3 color = texture(u_source, uv).rgb;

  float luma = dot(color, LUMA_BT709);
  // smoothstep の edge0 < edge1 は守ること。逆順は GLSL ES 3.00 §8.3 で「未定義」
  // (第30章が実際にこれを踏んだ)。u_knee は CPU 側で必ず正の値にしてある
  float weight = smoothstep(u_threshold, u_threshold + u_knee, luma);

  fragColor = vec4(color * weight, 1.0);
}
src/lessons/33-postprocess/blur.frag
#version 300 es
precision highp float;

// ぼかしパス: 1 次元のガウシアンを 1 回かける(4 節)。
// 横と縦の区別は u_direction((1,0) か (0,1))だけで、シェーダーは 1 本しかない。
// 2 次元のガウシアンが横 × 縦に分解できるので、これを 2 回通せば 2 次元のぼかしになる。
//
// ソースが描画先より大きいとき(段を下げるとき)は、この横パスが縮小も兼ねる。
// そのとき uv はソースの 2×2 テクセルの角を指し、LINEAR がその 4 つの平均を返す。

uniform highp sampler2D u_source; // 中間 FBO は RGBA16F(第21章 6 節)
uniform vec2 u_resolution; // 描画先の解像度
// 1 タップぶんの歩幅(uv 単位)。**描画先**のテクセル 1 つぶん = 1.0 / 描画先の解像度。
// ソース側のテクセル幅で歩かせると、縮小を兼ねる横パスだけ歩幅が画面ピクセル換算で
// 半分になり、滲みが縦長になる(4 節)
uniform vec2 u_texelStep;
uniform vec2 u_direction; // (1, 0) = 横 / (0, 1) = 縦

// σ = 2.0 のガウシアンを半径 4 で打ち切り、9 個の合計が 1 になるよう正規化した重み。
// 中心から外向きに 0, 1, 2, 3, 4 テクセル。値の出どころは
// scratchpad/p5b/ch33-numbers.py の「3.」(4 節の表と同じ値)
const float WEIGHTS[5] = float[5](0.204164, 0.180174, 0.123832, 0.066282, 0.027631);

out vec4 fragColor;

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec3 sum = texture(u_source, uv).rgb * WEIGHTS[0];

  for (int i = 1; i < 5; i++) {
    vec2 offset = u_direction * u_texelStep * float(i);
    sum += texture(u_source, uv + offset).rgb * WEIGHTS[i];
    sum += texture(u_source, uv - offset).rgb * WEIGHTS[i];
  }

  // 読み取り回数は 1 + 4 × 2 = 9 回。テクスチャの線形補間で 2 タップを 1 タップに
  // まとめれば 5 回にできる(4 節)が、この章はやっていない
  fragColor = vec4(sum, 1.0);
}
src/lessons/33-postprocess/composite.frag
#version 300 es
precision highp float;

// 合成パス: シーン + ブルーム → 露出 → トーンマップ → sRGB エンコード(3 節・7 節)。
//
// このチェーンで**リニア値を扱う最後のパス**がここ。ここから先(FXAA・色収差・
// グレイン・ビネット・ディザ)は全部 sRGB エンコード済みの値の上で動く。
// 順序の理由は 7 節にまとめてある。

uniform highp sampler2D u_scene; // シーン(リニア HDR・RGBA16F)
// ブルームの段。使う枚数は u_bloomLevels で、使わない段にも
// 有効なテクスチャを結び直してある(サンプラは黙って不完全になりうるため)
uniform highp sampler2D u_bloom0;
uniform highp sampler2D u_bloom1;
uniform highp sampler2D u_bloom2;
uniform highp sampler2D u_bloom3;
uniform highp sampler2D u_bloom4;

uniform vec2 u_resolution;
uniform int u_bloomLevels; // 0 なら合成しない
uniform float u_bloomStrength;
uniform float u_exposure; // リニア値への掛け算(第27章 5 節)

#include "color.glsl"

out vec4 fragColor;

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec3 color = texture(u_scene, uv).rgb;

  if (u_bloomLevels > 0) {
    // 段は解像度が違うだけで役割は同じなので、平均してから 1 つの強さで混ぜる。
    // 合計にすると段数を増やしたときに明るさまで変わり、
    // 「広がり」と「強さ」の 2 つのつまみが絡まる
    vec3 bloom = texture(u_bloom0, uv).rgb;
    if (u_bloomLevels > 1) bloom += texture(u_bloom1, uv).rgb;
    if (u_bloomLevels > 2) bloom += texture(u_bloom2, uv).rgb;
    if (u_bloomLevels > 3) bloom += texture(u_bloom3, uv).rgb;
    if (u_bloomLevels > 4) bloom += texture(u_bloom4, uv).rgb;
    bloom /= float(u_bloomLevels);
    // 足し算はリニア空間で行う。sRGB エンコード値を足すのは光を足すことにならない
    // (第27章 4 節・第30章 2 節)
    color += bloom * u_bloomStrength;
  }

  color *= u_exposure; // 露出 → トーンマップ → sRGB の順(第27章 5 節)
  color = toneMapReinhard(color);

  fragColor = vec4(linearToSrgb(color), 1.0);
}
src/lessons/33-postprocess/fxaa.frag
#version 300 es
precision highp float;

// FXAA(Fast approXimate Anti-Aliasing)の簡略版(5 節)。
//
// 出典: Timothy Lottes, "FXAA", NVIDIA の白書 PDF。
//   https://developer.download.nvidia.com/assets/gamedev/files/sdk/11/FXAA_WhitePaper.pdf
//   (表紙は "February 2009" だが、変更履歴は「1.0 / 25/01/11 / Timothy Lottes / Initial release」)
// 下の定数と式(FxaaLuma・ローカルコントラスト判定・サブピクセル判定・縦横判定・
// 端点探索のループの形)は、この白書に載っているコード断片を GLSL ES 3.00 に移したもの。
//
// **簡略版であること**。白書と違うのは次の 3 点で、詳しくは 5 節に書いてある:
//   1. 探索の加速(異方性フィルタで 2〜4 ピクセルまとめて見る。プリセット 0〜2)を入れていない。
//      この実装は 1 歩 = 1 ピクセルで、プリセット 3 と同じ「加速なし」に相当する
//   2. 探索の歩数を SEARCH_STEPS で固定している(白書は FXAA_SEARCH_STEPS で調整する)
//   3. 白書のコード断片が**使っているだけで定義していない**値 — SUBPIX_TRIM_SCALE、
//      探索の打ち切りに使う gradient、ペアの平均輝度 lumaPair — と、
//      コード断片に無い部分 — 探索の開始位置、最後のサブピクセルずらし量の式 — は、
//      白書の本文の説明から組み立てたもの。配布実装 FxaaShader.h は参照していない
//
// 入力は **sRGB エンコード済みの絵**。白書が「FXAA は LDR へ落として sRGB へ変換したあと、
// 後処理の終わりのほうで掛けるように作ってある」と明記している(5 節・7 節)。

uniform highp sampler2D u_source; // 中間 FBO は RGBA16F(第21章 6 節)
uniform vec2 u_resolution;

// 白書の "Tuning Defines" の値。コメントの評価も白書のもの
const float EDGE_THRESHOLD = 1.0 / 8.0; // 1/8 は "high quality"
const float EDGE_THRESHOLD_MIN = 1.0 / 16.0; // 1/16 は "high quality"。暗部を処理しないための下限
const float SUBPIX_TRIM = 1.0 / 4.0; // "default removal"
const float SUBPIX_TRIM_SCALE = 1.0 / (1.0 - SUBPIX_TRIM);
const float SUBPIX_CAP = 3.0 / 4.0; // "default amount of filtering"
const float SEARCH_THRESHOLD = 1.0 / 4.0; // "seems best quality wise"
const int SEARCH_STEPS = 8;

out vec4 fragColor;

// 白書の FxaaLuma。赤と緑だけから輝度を見積もる(青だけのエイリアスは実際にはめったに出ない、
// というのが白書の言い分)。0〜1 の色に対して値域は 0〜2.963 で、正規化されていない。
// しきい値の EDGE_THRESHOLD は比なので影響を受けず、EDGE_THRESHOLD_MIN はこの目盛りの上の絶対値
float fxaaLuma(vec3 rgb) {
  return rgb.g * (0.587 / 0.299) + rgb.r;
}

// 探索のループは「一部のフラグメントだけが回る」= 非一様な制御フロー(GLSL ES 3.00 §3.10.2)。
// §8.8 は「Lod / Grad の付かないテクスチャ関数の暗黙の微分は、非一様な制御フローの中では未定義」
// と書いているので、この章のサンプルは全部 textureLod でレベル 0 を明示する。
// 中間 FBO はミップマップを持たない(levels = 1)ので、絵はどちらでも同じ
vec3 fetch(vec2 uv) {
  return textureLod(u_source, uv, 0.0).rgb;
}

void main() {
  vec2 texel = 1.0 / u_resolution;
  vec2 pos = gl_FragCoord.xy * texel;

  // --- 1. 十字の 5 点を読む。N / S は白書と逆で、ここでは N が +y(画面の上) --------
  vec3 rgbM = fetch(pos);
  vec3 rgbN = fetch(pos + vec2(0.0, texel.y));
  vec3 rgbS = fetch(pos - vec2(0.0, texel.y));
  vec3 rgbW = fetch(pos - vec2(texel.x, 0.0));
  vec3 rgbE = fetch(pos + vec2(texel.x, 0.0));

  float lumaM = fxaaLuma(rgbM);
  float lumaN = fxaaLuma(rgbN);
  float lumaS = fxaaLuma(rgbS);
  float lumaW = fxaaLuma(rgbW);
  float lumaE = fxaaLuma(rgbE);

  // --- 2. ローカルコントラスト判定。縁でないところは、ここで抜ける -------------------
  float rangeMin = min(lumaM, min(min(lumaN, lumaW), min(lumaS, lumaE)));
  float rangeMax = max(lumaM, max(max(lumaN, lumaW), max(lumaS, lumaE)));
  float range = rangeMax - rangeMin;
  if (range < max(EDGE_THRESHOLD_MIN, rangeMax * EDGE_THRESHOLD)) {
    fragColor = vec4(rgbM, 1.0);
    return;
  }

  // --- 3. サブピクセルのエイリアス量と、3×3 のローパス -------------------------------
  vec3 rgbNW = fetch(pos + vec2(-texel.x, texel.y));
  vec3 rgbNE = fetch(pos + vec2(texel.x, texel.y));
  vec3 rgbSW = fetch(pos + vec2(-texel.x, -texel.y));
  vec3 rgbSE = fetch(pos + vec2(texel.x, -texel.y));
  vec3 rgbL = (rgbN + rgbW + rgbM + rgbE + rgbS + rgbNW + rgbNE + rgbSW + rgbSE) / 9.0;

  float lumaLowpass = (lumaN + lumaW + lumaE + lumaS) * 0.25;
  float rangeL = abs(lumaLowpass - lumaM);
  // 「画素のコントラスト ÷ 周辺のコントラスト」が 1 に近いほど、1 画素だけの点 = サブピクセル
  float blendL = min(SUBPIX_CAP, max(0.0, (rangeL / range) - SUBPIX_TRIM) * SUBPIX_TRIM_SCALE);

  float lumaNW = fxaaLuma(rgbNW);
  float lumaNE = fxaaLuma(rgbNE);
  float lumaSW = fxaaLuma(rgbSW);
  float lumaSE = fxaaLuma(rgbSE);

  // --- 4. 縦の縁か横の縁か。3×3 の行と列のハイパスの重み付き和(白書の式そのまま) -----
  float edgeVert = abs((0.25 * lumaNW) + (-0.5 * lumaN) + (0.25 * lumaNE))
    + abs((0.50 * lumaW) + (-1.0 * lumaM) + (0.50 * lumaE))
    + abs((0.25 * lumaSW) + (-0.5 * lumaS) + (0.25 * lumaSE));
  float edgeHorz = abs((0.25 * lumaNW) + (-0.5 * lumaW) + (0.25 * lumaSW))
    + abs((0.50 * lumaN) + (-1.0 * lumaM) + (0.50 * lumaS))
    + abs((0.25 * lumaNE) + (-0.5 * lumaE) + (0.25 * lumaSE));
  bool horzSpan = edgeHorz >= edgeVert;

  // --- 5. 縁に垂直な向きで、コントラストの高いほうの隣とペアを組む ---------------------
  float lumaPlus = horzSpan ? lumaN : lumaE; // 縁をまたぐ 2 方向
  float lumaMinus = horzSpan ? lumaS : lumaW;
  float gradientPlus = abs(lumaPlus - lumaM);
  float gradientMinus = abs(lumaMinus - lumaM);
  bool pairPlus = gradientPlus >= gradientMinus;
  float lengthSign = pairPlus ? 1.0 : -1.0;
  // ペアの平均輝度。縁に沿って歩きながら、この値が変わったところが縁の端
  float lumaPair = 0.5 * ((pairPlus ? lumaPlus : lumaMinus) + lumaM);
  float gradient = SEARCH_THRESHOLD * max(gradientPlus, gradientMinus);

  vec2 across = horzSpan ? vec2(0.0, texel.y) : vec2(texel.x, 0.0); // 縁をまたぐ向き
  vec2 along = horzSpan ? vec2(texel.x, 0.0) : vec2(0.0, texel.y); // 縁に沿う向き

  // --- 6. 縁の端を、両方向へ同時に探す ------------------------------------------------
  // 開始位置をペアのちょうど真ん中(半テクセルずらし)に置くのが要点。こうすると
  // LINEAR フィルタの 1 回のフェッチが、そのままペアの平均を返してくれる
  vec2 posB = pos + across * (lengthSign * 0.5);
  vec2 posN = posB - along;
  vec2 posP = posB + along;
  float lumaEndN = lumaPair;
  float lumaEndP = lumaPair;
  bool doneN = false;
  bool doneP = false;
  for (int i = 0; i < SEARCH_STEPS; i++) {
    if (!doneN) lumaEndN = fxaaLuma(fetch(posN));
    if (!doneP) lumaEndP = fxaaLuma(fetch(posP));
    doneN = doneN || abs(lumaEndN - lumaPair) >= gradient;
    doneP = doneP || abs(lumaEndP - lumaPair) >= gradient;
    if (doneN && doneP) break;
    if (!doneN) posN -= along;
    if (!doneP) posP += along;
  }

  // --- 7. 端までの距離から、垂直方向のずらし量を決める --------------------------------
  // 距離の比しか使わないので、ピクセルでも UV でも同じ結果になる
  float distN = horzSpan ? pos.x - posN.x : pos.y - posN.y;
  float distP = horzSpan ? posP.x - pos.x : posP.y - pos.y;
  bool nearerN = distN < distP;
  float dist = nearerN ? distN : distP;
  float lumaEnd = nearerN ? lumaEndN : lumaEndP;
  float spanLength = distN + distP; // 最短でも 2 テクセルあるので 0 除算はしない

  // 近いほうの端の輝度が中心と同じ側にあるなら、この画素は縁のまたぎ側ではない。
  // その場合はずらさない(白書の lengthSign = 0 に当たる)
  bool sameSide = ((lumaM - lumaPair) < 0.0) == ((lumaEnd - lumaPair) < 0.0);
  float shift = sameSide ? 0.0 : (0.5 - dist / spanLength);
  vec3 rgbF = fetch(pos + across * (lengthSign * shift));

  // --- 8. 最後に、サブピクセルのぶんだけローパスを混ぜる ------------------------------
  fragColor = vec4(mix(rgbF, rgbL, blendL), 1.0);
}
src/lessons/33-postprocess/grade.frag
#version 300 es
precision highp float;
// hash.glsl の pcg3d は 32 ビットで折り返すことを前提にしている。
// フラグメントシェーダーの整数の既定精度は mediump なので、highp を明示する(第25章 3 節)。
// u_frame も増え続けるので、その意味でも highp が要る(第29章 2 節)
precision highp int;

// 仕上げのパス(6 節)。色収差・グレイン・ビネット・ディザを 1 本にまとめてある。
// **どれも物理的に正しいからやることではなく、画作りのため**。
//
// 入力はすでに sRGB エンコード済み(composite.frag か fxaa.frag の出力)。
// このパスの出力先は既定のフレームバッファ = 8 ビットなので、
// ディザ(量子化の直前に足すノイズ)に意味があるのはここだけ(6 節・7 節)。

uniform highp sampler2D u_source; // 中間 FBO は RGBA16F(第21章 6 節)
uniform vec2 u_resolution;
uniform int u_frame; // グレインを動かすためのフレーム番号。float の u_time は使わない(6 節)

uniform float u_chromatic; // 0 で切、正の値で色収差の強さ(画面端でのずれ量・ピクセル)
uniform float u_grain; // 0 で切。sRGB エンコード値に足す振幅
uniform float u_vignette; // 0 で切。画面隅をどれだけ落とすか(0〜1)
uniform float u_dither; // 0 か 1。1 で三角 PDF ディザ ±1 段

#include "hash.glsl"

out vec4 fragColor;

void main() {
  vec2 texel = 1.0 / u_resolution;
  vec2 uv = gl_FragCoord.xy * texel;
  // 画面中心を原点にした座標。短辺で正規化して、縦横比で歪まないようにする
  vec2 centered = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);

  vec3 color;
  if (u_chromatic > 0.0) {
    // 中心からの距離に比例させて、R と B を反対向きにずらす。ここだけ 3 回読む
    vec2 shift = centered * u_chromatic * texel;
    color = vec3(
      textureLod(u_source, uv + shift, 0.0).r,
      textureLod(u_source, uv, 0.0).g,
      textureLod(u_source, uv - shift, 0.0).b
    );
  } else {
    color = textureLod(u_source, uv, 0.0).rgb;
  }

  if (u_vignette > 0.0) {
    // 中心 1.0 → 隅 (1 - u_vignette)。sRGB エンコード後の値に掛けている。
    // リニアで掛けたいなら composite.frag へ移すこと。決めていないことだけが問題(7 節)
    float falloff = 1.0 - u_vignette * smoothstep(0.35, 1.45, length(centered));
    color *= falloff;
  }

  if (u_grain > 0.0) {
    // hash21 は 0〜1 なので、-0.5 して ±0.5 にする。座標をフレーム番号でずらすと
    // 毎フレーム別の模様になる。u_frame を整数のまま渡すのがこの章の約束(6 節)
    ivec2 cell = ivec2(gl_FragCoord.xy) + ivec2(u_frame * 1237, u_frame * 9781);
    color += (hash21(cell) - 0.5) * u_grain;
  }

  if (u_dither > 0.0) {
    // 三角 PDF ±1 段のディザ。**8 ビットへ落ちる直前のこのパスでだけ**掛ける(6 節)。
    // 原理は第27章 8 節
    color += triangularDither(ivec2(gl_FragCoord.xy)) / 255.0;
  }

  fragColor = vec4(color, 1.0);
}
src/lessons/33-postprocess/passthrough.frag
#version 300 es
precision highp float;

// デモ 1 専用。チェーンの途中の FBO を 1 枚選んで画面に出すだけのパス(1 節)。
//
// 「そのまま出す」と言っても、画面は 8 ビットで 0〜1 しか出せません。
// シーンや抽出やぼかしの中身はリニア HDR なので、**露出 → トーンマップ → sRGB エンコード**を
// 通さないと画面には出せない(7 節)。u_tonemap が 0 のときは入力がすでに
// sRGB エンコード済み(合成の出力)なので、そのまま流す。

uniform highp sampler2D u_source; // 中間 FBO は RGBA16F(第21章 6 節)
uniform vec2 u_resolution;
uniform float u_gain; // 暗い段を見えるようにするための掛け算。1.0 で素のまま
uniform float u_tonemap; // 1 = リニア HDR なので露出+トーンマップ+sRGB / 0 = そのまま

#include "color.glsl"

out vec4 fragColor;

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;
  vec3 color = texture(u_source, uv).rgb * u_gain;

  if (u_tonemap > 0.5) {
    color = linearToSrgb(toneMapReinhard(color));
  }

  fragColor = vec4(color, 1.0);
}
src/lessons/33-postprocess/color.glsl
// 第33章のシェーダーが共有する色の関数。
//
// srgbToLinear / linearToSrgb は第27章 color.glsl の複製(1 文字も変えていない)。
// toneMapReinhard / toneMapNarkowicz も同じく第27章のもの。
// 複製の理由と、片方を直したら他も直すという約束は docs/plan.md の抽象化タイムラインにある。
//
// 注意: 下の doc コメントの中にある「(5 節)」「本文 5 節」は、複製元である
// 第27章の節番号を指している。この章の節番号ではない(複製をバイト単位で保つため直していない)。

/**
 * 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));
}

// ---------------------------------------------------------------------------
// トーンマッピング: 0〜∞ のリニア値を 0〜1 へ写す関数。
// 露出(リニア値への掛け算)とは別物で、必ず sRGB エンコードの「前」に掛ける。
// 順序を逆にすると、1 を超えていた情報が linearToSrgb の clamp で先に消える(5 節)。
// ---------------------------------------------------------------------------

/**
 * Reinhard。x / (1 + x) は 0 → 0 の単調増加で、どんなに大きい入力でも 1 未満に収まる。
 * 出典: Erik Reinhard, Michael Stark, Peter Shirley, James Ferwerda,
 *   "Photographic Tone Reproduction for Digital Images", ACM SIGGRAPH 2002
 *   (ACM Trans. Graph. 21(3), pp.267-276) の式 (3)。
 * ただし論文の式 (3) は「輝度」1 チャンネルに掛けるもので、下のように RGB の
 * 各成分へ別々に掛けるのは論文の手続きではない(理由と影響は本文 5 節)。
 */
vec3 toneMapReinhard(vec3 x) {
  return x / (1.0 + x);
}

/**
 * フィルミックな S 字カーブ。**ACES ではない。**
 * ACES の RRT + ODT の出力を標本して手で当てはめた近似式で、
 * 出典: Krzysztof Narkowicz, "ACES Filmic Tone Mapping Curve" (2016 年 1 月 6 日)
 *   https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/
 * 記事本人の記述: 最大フィット誤差 0.0138、暗部を合わせるように手で調整、
 * 入力の 1 が出力の約 0.8 に写るよう露出込みで当てはめてある(元の ACES の
 * カーブが欲しければ入力に 0.6 を掛ける)。ライセンスは CC0 または MIT。
 * 記事の UPDATE 節に「輝度だけの単純なフィットなので明部の彩度が上がりすぎる」
 * という本人の注意書きがある。
 */
vec3 toneMapNarkowicz(vec3 x) {
  const float a = 2.51;
  const float b = 0.03;
  const float c = 2.43;
  const float d = 0.59;
  const float e = 0.14;
  return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
src/lessons/33-postprocess/hash.glsl
// 第33章のシェーダーが共有するハッシュとディザ。
//
// pcg3d / hash21 は第25章 noise.glsl の複製(第27章 hash.glsl・第29章 hash.glsl と同じもの)。
// triangularDither は第27章 hash.glsl のもの。
//
// 注意: 下の doc コメントの中にある「(8 節)」は複製元の第27章の節番号で、
// この章の節番号ではない。この章でディザを掛ける場所は 6 節と 7 節に書いてある。

/**
 * pcg3d — 3 つの uint を、互いに無関係に見える 3 つの uint へかき混ぜる整数ハッシュ。
 * 出典: Mark Jarzynski, Marc Olano, "Hash Functions for GPU Rendering",
 *   Journal of Computer Graphics Techniques, Vol. 9, No. 3 (2020), p.32.
 *   https://jcgt.org/published/0009/03/02/
 * 掛け算と足し算は 2^32 で折り返す(GLSL ES 3.00 §4.1.3)。
 * この桁あふれがビットをかき混ぜる動力そのもので、避けるべき事故ではない。
 */
uvec3 pcg3d(uvec3 v) {
  v = v * 1664525u + 1013904223u;
  v.x += v.y * v.z;
  v.y += v.z * v.x;
  v.z += v.x * v.y;
  v ^= v >> 16u;
  v.x += v.y * v.z;
  v.y += v.z * v.x;
  v.z += v.x * v.y;
  return v;
}

/**
 * 整数の格子座標 → 0〜1 の乱数 1 つ。
 * ivec2 → uvec2 の変換はビットパターンをそのまま移すので(§5.4.1)、負の座標も安全に扱える。
 * float から直接 uint へ変換してはいけない — 負の float から uint への変換は未定義(§5.4.1)。
 */
float hash21(ivec2 cell) {
  uint h = pcg3d(uvec3(ivec3(cell, 0))).x;
  return float(h) / 4294967296.0; // 2^32 で割って 0〜1 へ
}

// --- ここから下は第27章で足したもの(複製ではない) --------------------------

/**
 * 三角 PDF(triangular probability density function)のディザ。
 * 0〜1 の乱数 2 つの差なので値域は ±1 で、密度は 0 を頂点とする三角形になる。
 * 呼び出し側で「8 ビット出力の 1 段 = 1/255」を掛けて、量子化の直前に足す(8 節)。
 * 2 本目のハッシュを引く位置をずらしているのは、同じ座標では同じ値しか返らないため。
 */
float triangularDither(ivec2 px) {
  return hash21(px) - hash21(px + ivec2(1237, 9781));
}

three.js との対応

この章の内容は、three.js ではexamples/jsm/postprocessing/にひとそろい揃っています(コアのWebGLRendererではなくアドオン側です)。下の表はthree@0.185.1を npm から展開して中身を確認したものです(2026-08-07 時点)。

three.jsこの章
EffectComposer— パスの配列を持ち、順にrender()する。内部の作業用レンダーターゲットはnew WebGLRenderTarget(w, h, { type: HalfFloatType })Pass[]と 1 本のforループ(1 節)。作業用ターゲットはcreateRenderTarget(gl, w, h, { internalFormat: gl.RGBA16F })で、half float を選ぶ理由は同じ(2 節)
RenderPass— シーンをチェーンの先頭で描くdrawScene()。全画面パスの配列には入れず、シーンだけ手前で描いている
ShaderPass—{ uniforms, vertexShader, fragmentShader }を渡すと 1 パスになる汎用の入れ物thresholdPass/blurPass/gradePass…。汎用の入れ物は作らず、パスごとに関数を書いた(1 節の判断)
UnrealBloomPass—nMips = 5段、各段に横用・縦用のWebGLRenderTarget、抽出はLuminosityHighPassShaderのsmoothstep(luminosityThreshold, luminosityThreshold + smoothWidth, luminance(...))、 ぼかしの半径は段ごとに[6, 10, 14, 18, 22]、合成の重みは[1.0, 0.8, 0.6, 0.4, 0.2]3〜4 節。段数は 1 / 3 / 5 を切り替え、抽出は同じ形のsmoothstep、ぼかしの半径は全段 4 で固定、合成は平均(重みを付けていない)
FXAAPass/FXAAShader— ソースの doc コメントに「FXAA algorithm from NVIDIA, C# implementation by Jasper Flick, GLSL port by Dave Hoskins」とあり、参照先として同じ NVIDIA の白書 URL が挙がっているfxaa.frag。白書から直接移した簡略版で、Flick 版や Hoskins 版は経由していません(5 節)
OutputPass—renderer.toneMappingとrenderer.outputColorSpaceを見て、トーンマップと sRGB 変換をチェーンの最後で行うcomposite.fragの末尾(露出 → toneMapReinhard → linearToSrgb)。 three.js が別パスに分けているのに対し、この章は合成と同じパスに入れている(7 節)
WebGLRenderTargetのtype: HalfFloatTypeRenderTargetOptionsのinternalFormat: gl.RGBA16F+EXT_color_buffer_floatの有無の判定(2 節)
renderer.samplesを立てるとポストプロセスのターゲットがマルチサンプルになるrenderbufferStorageMultisample+blitFramebufferを手で書く。使えるサンプル数の問い合わせも自分でやる(5 節)
FilmPass/LUTPassなど、仕上げ系のパスが個別に用意されているgrade.frag1 本にまとめた。パスを分けるほうが組み替えやすく、まとめるほうが読み書きが減る(6 節)

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

この章のコードはsrc/lessons/33-postprocess/にあります。4 本のデモは同じシーン・同じシェーダーを共有しているので、 1 か所いじると 4 本すべてに効きます。

まとめ

パスを並べる形が手に入ると、次に効いてくるのはuniform を配る手間です。 この章のチェーンは 6 種類のプログラムに毎フレームu_resolutionを配っていますし、シーンパスはオブジェクトごとに行列を送り直しています。次章第34章「UBO と抽象化 — ミニエンジンを設計する」は、同じ手間を第18章と第23章まで遡って数えるところから始めます。layout(std140)の uniform ブロックで「同じものを何度も送る」をまとめ、そのうえで Shader / Material / Mesh / Camera / Scene / Renderer という部品にこの本の全部を組み直します。この章があえてヘルパーを作らなかった判断も、第34章 7 節の「上げなかったものの一覧」で改めて引き取られます。