第3部 3D ラスタライズパイプライン — 第23章

ディファードレンダリング — MRT と G-buffer

第17章から第22章まで、ライティングはいつも同じ場所で行われてきました —物体を描いているフラグメントシェーダーの中です。三角形をラスタライズし、その フラグメントで法線と光源から色を決め、深度テストに通れば書き込む。この素直なやり方をフォワードレンダリング (forward rendering)といいます。

第18章の最後で、この方式のコストは「フラグメント数 × 光源数」だと書きました。光源が数十個になると、この掛け算が効いてきます。この章では掛け算を 分解します。物体を描くパスと、光を計算するパスを別々にするのです。1 パス目では光の計算をいっさいせず、「このピクセルには何色で、どの向きを向いた、どれくらい 金属的な面がある」という情報だけをテクスチャの束(G-buffer)に書き出す。2 パス目では全画面 1 枚を描き、その束を読んで光を計算する。ライティングをあとまわし (deferred)にするので、ディファードレンダリングと呼ばれます。three.js のWebGLRendererはフォワードなので、これは three.js が用意していない描き方を自分で組み立てる章でもあります。

床の上に球とトーラスを 6 × 6 で並べ、色つきの点光源を最大 64 個うろつかせています(ドラッグで回り込み、ホイールで寄れます。第15章の軌道カメラの簡略版)。「ディファード」と「フォワード」は同じ関数で陰影を計算するので、絵は原理的に一致します — 違うのは手順だけです。「G-buffer の可視化」は、ライティングの手前で止めて中間バッファをそのまま画面に出すもので、ディファードのときだけ有効になります(フォワードには G-buffer が存在しないため)。「法線 RGBA16F / RGBA8」は法線バッファの精度の切り替えで、RGBA8 にするとなめらかな球の陰影にまだらなむらが出ます(4 節)。読み出し行の所要時間はCPU 側で測った rAF の間隔で、GPU の処理時間ではありません(第35章)。

この章で学ぶこと:

1. フォワードの何が問題なのか

第18章のフラグメントシェーダーは、こういう構造をしていました — 光源の配列があり、フラグメント ごとにその配列をぐるりと回る。書き方としては何も悪くありません。問題は、このループが「描かれるフラグメントの数」だけ実行されることです。

コストの積み上がり方は 2 段構えです。

2 つめについては、第13章で予告した救いがあります。実際の GPU は、条件が揃えば深度テストをフラグメントシェーダーの前に前倒しできます —early-Z (early fragment test)です。手前のものを先に描いていれば、奥のフラグメントは色を計算する前に捨てられます。ただし これは描画順に強く依存する最適化で、奥から手前に描いてしまえば効きません。 シーンを毎フレーム前後に並べ替えるのも、それはそれでコストです。

2. 描く仕事と、照らす仕事を切り離す

ディファードレンダリングの発想は単純です。深度テストが終わってから、生き残ったピクセルだけを照らせばいい。そのためには、ライティングを深度テストより後ろに動かす必要があります。 ところがフラグメントシェーダーは深度テストより手前にあるので、同じパスの中では動かせません。 そこでパスを 2 つに割ります。

フォワード(第17〜22章)ディファード(この章)物体 1 個ぶんの流れ × 物体の数頂点シェーダー → ラスタライズフラグメントシェーダー全光源のライティング深度テスト負けたら、計算した色ごと捨てる画面へ書き込みコスト = 描いたフラグメント数 × 光源数パス 1: ジオメトリパス頂点シェーダー → ラスタライズ材質と法線を書き出すだけ深度テスト → G-buffer へパス 2: ライティングパス全画面三角形 1 枚G-buffer を読む全光源のライティング画面へ書き込みコスト = 画面のピクセル数 × 光源数(重なりのぶんは消える)
2 つの方式の流れ。オレンジがライティングです。ディファードでは、それが深度テストのあとに移動しています。「見えているピクセル」だけが 2 パス目にたどり着くので、隠れた面のぶんの ライティングは最初から発生しません。

ここで肝心なのは、2 パス目には三角形の情報がもう残っていないことです。頂点も 法線属性もモデル行列も、1 パス目で使い切ってしまいました。だから 1 パス目は、2 パス目がライティングに必要とするものを全部テクスチャに書き残しておく必要が あります。その置き場が G-buffer(geometry buffer)です。

3. MRT — 1 回の描画で複数の出力へ

G-buffer は 1 枚では足りません。基本色・法線・粗さ…と、種類の違う値を同時に書き出したい。 そこで第20章で予告した仕組みが出てきます。MRT (multiple render targets)— 1 つの FBO に複数のカラーアタッチメントを付け、1 回の描画で全部に書き込むやり方です。

必要なものは 3 つだけです。

  1. COLOR_ATTACHMENT0COLOR_ATTACHMENT1… に、それぞれテクスチャを付ける
  2. gl.drawBuffers([...])で「フラグメントシェーダーの出力 n 番を、有効にするか捨てるか」を宣言する(配列の i 番目に書けるのはCOLOR_ATTACHMENTiNONEだけで、流し先を選べるわけではありません)
  3. フラグメントシェーダーでout変数を複数宣言し、それぞれにlayout(location = n)を付ける
src/lessons/23-deferred-rendering/gbuffer.ts(抜粋)
  const framebuffer = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, baseColor, 0);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, normal, 0);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depth, 0);

  // これを忘れると 1 枚目にしか描かれない。FBO の初期状態は
  // 「フラグメントカラー 0 → COLOR_ATTACHMENT0、それ以外 → NONE」で、
  // NONE に割り当てられた出力は黙って捨てられる(OpenGL ES 3.0 §4.2.1)。
  // 配列の i 番目は COLOR_ATTACHMENTi か NONE でなければならない(順番も固定)。
  // この状態はフレームバッファごとに持たれるので、設定は生成時の 1 回でよい
  gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);

シェーダー側は、outを並べてlocationを振るだけです。第3章から今までout vec4 fragColor;と 1 つだけ書いてきたのは、GLSL ES 3.00 が「出力が 1 つだけならlocationは省略でき、既定で 0 になる」と定めているからでした。2 つ以上になった瞬間、この省略は使えません。

src/lessons/23-deferred-rendering/geometry.frag(抜粋)
// 出力が 2 つ以上あるときは、すべてに location を書かなければならない
// (GLSL ES 3.00 仕様 4.3.8.2「If there is more than one output, the location must be
//  specified for all outputs」)。location i が drawBuffers で指定した i 番目に対応する
layout(location = 0) out vec4 gBaseColor; // rgb: baseColor(リニア) / a: metallic
layout(location = 1) out vec4 gNormal; // rgb: ワールド法線 / a: roughness

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

  gBaseColor = vec4(u_baseColor, u_metallic);
  gNormal = vec4(u_packNormal ? N * 0.5 + 0.5 : N, u_roughness);
}

4. G-buffer に何を、どの精度で入れるか

ここがディファードの設計のほとんどすべてです。2 パス目のライティングが必要とするものを 書き出し、それ以外は書かない。第22章の Cook-Torrance が要求するのは、法線 N視線 V面の位置、そしてbaseColor / metallic / roughnessです。V はカメラ位置と面の位置から作れるので、実際に保存すべきは 5 つになります。

この章の構成はこうしました。

COLOR_ATTACHMENT0RGBA8(4 バイト)baseColor(RGB・リニア)metallic8 bit × 4COLOR_ATTACHMENT1RGBA16F(8 バイト)ワールド法線(XYZ・符号つき)roughness16 bit × 4DEPTH_ATTACHMENTDEPTH_COMPONENT24(4 バイト)窓座標の深度(0〜1)24 bit逆ビュー射影でワールド位置を復元(5 節)合計 16 バイト / ピクセル — 位置を RGB32F(12 バイト)で持てば 28 バイトになっていた
この章の G-buffer。ライティングに要る 5 つの値を、カラー 2 枚と深度 1 枚に詰めています。 余っているチャンネルがないのは偶然ではなく、1 ピクセルあたりのバイト数を削るために 詰めた結果です。

baseColor と metallic を RGBA8 にした理由は、どちらも 0〜1 に収まる値だからです。baseColor は最終的に画面に出る色の元なので、8 ビットあれば人間の目には足ります。metallic は実際の素材ではほとんど 0 か 1 のどちらかに寄るので、なおさら困りません。

問題は法線のほうです。法線は −1〜+1 の符号つきベクトルなので、RGBA8に入れるにはN * 0.5 + 0.5で 0〜1 に詰め直すことになります。すると 1 成分あたり256 段階、刻み幅は2 / 255 ≒ 0.008です。色ならこれで十分ですが、法線はそのあと鋭い関数に通される値です。Cook-Torrance の GGX 項はroughnessが小さいほどハイライトを細くするので、ハイライトの幅が法線の刻みより狭くなると、 連続していたはずの照り返しが飛び飛びの段に割れます。デモの「法線 RGBA8」に切り替えて、 つるつるの球(奥の行)の陰影を見比べてみてください。同じ場所がまだらにむらつくのが分かります。

そこでこの章はRGBA16F を選びました。理由は 3 つです。

代償は、第20章 8 節でやったとおり拡張が要ることです。RGBA16Fは OpenGL ES 3.0 の内部フォーマット表(Table 3.13)でcolor-renderable ではないと定められていて、拡張なしでカラーアタッチメントに 付ければFRAMEBUFFER_INCOMPLETE_ATTACHMENTになります。ただし要る拡張はEXT_color_buffer_floatだけではありません。第20章 8 節で「無いときの逃げ道」として挙げたEXT_color_buffer_half_floatでも足ります。拡張仕様は「WebGL 2.0 コンテキストではRGBA16F/RG16F/R16Fが color-renderable になる」「実装はRGBA16Fへの描画を必ずサポートしなければならない」と定めていて、この章が欲しいのはまさにRGBA16F1 つだけだからです。だからmain.tsは起動時にこの 2 つを順に取りにいき、どちらも無ければ法線バッファをRGBA8に落として、シェーダー側のu_packNormalを立てて詰め直しに切り替えます(デモの切替ボタンは、この分岐を手で押せるようにしたものです)。getExtensionは問い合わせと有効化を兼ねる関数なので、判定のために呼ぶこと自体が有効化になります。

5. 位置は持たない。深度から復元する

ライティングには面のワールド座標が要ります。素直に考えれば、G-buffer にもう 1 枚RGB32Fのテクスチャを足して、ジオメトリパスでv_worldPositionをそのまま書けば済みます。実際それでも動きます。

やらないのは、それが 1 ピクセルあたり 12 バイトの純粋な無駄だからです。位置の 情報はすでに深度バッファに入っているのです。深度アタッチメントは、深度テストの ために必ず用意します。それをレンダーバッファではなくテクスチャにしておけば (第20章の「あとで読むならテクスチャ」の判断で、第21章のシャドウマップとまったく同じ判断です)、 あとから読み返せます。

復元の手順は、第12章の座標変換をそのまま逆にたどるだけです。

窓座標uv, depthいずれも 0〜1NDCv * 2.0 - 1.0−1〜+1 の立方体×inverse(P·V)同次座標vec4(xyz, w)w は 1 ではない÷ wワールド座標第12章の「ローカル → ワールド → ビュー → クリップ → NDC → スクリーン」を、右から左へ逆にたどっている。透視除算(÷ w)は行きも帰りも必要 — 逆行列を掛けただけでは w は 1 に戻らない。深度は非線形に分布しているが(第13章)、この経路はその非線形性ごと正しく逆算する。
深度 1 つとピクセルの位置から、ワールド座標を作り直す道すじ。逆行列は CPU 側で 1 フレームに 1 回作って uniform で送ればよく、GPU 側の仕事は行列 1 回と除算 1 回だけです。
src/lessons/23-deferred-rendering/lighting.frag(抜粋)
  // --- 位置の復元(本文 5 節) ------------------------------------------------
  // 窓座標 → NDC → 逆ビュー射影 → 同次除算 → ワールド座標。
  // gl_FragCoord.xy はピクセル中心が .5 なので、そのまま割れば UV の中心になる
  vec2 uv = gl_FragCoord.xy / vec2(textureSize(u_gDepth, 0));
  vec4 ndc = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
  vec4 world = u_inverseViewProjection * ndc;
  vec3 worldPosition = world.xyz / world.w;

depth * 2.0 - 1.0としているのは、深度テクスチャに入っている値が窓座標の深度(既定では 0〜1)で、 NDC の z は −1〜+1 だからです。uvのほうも同じ理由で 2 倍して 1 を引いています。そしてworld.xyz / world.wの除算を忘れないこと — 逆行列を掛けただけではwは 1 になりません(第12章の透視除算が、逆向きにもう一度出てくる場所です)。

src/lessons/23-deferred-rendering/lighting.frag(抜粋)
  // G-buffer は画面と同じ解像度なので、1 テクセル : 1 ピクセルで読める。
  // 補間もラップも要らないので texture() ではなく texelFetch(第16章)
  ivec2 texel = ivec2(gl_FragCoord.xy);
  float depth = texelFetch(u_gDepth, texel, 0).r;

  // 深度がクリア値(1.0)のままなら、そのピクセルには何も描かれていない = 背景。
  // ディファードでは「物体があったかどうか」も深度から分かる
  if (depth >= 1.0) {
    fragColor = vec4(u_backgroundColor, 1.0);
    return;
  }

深度がクリア値の1.0のままなら、そのピクセルには三角形が 1 枚も来なかった、つまり背景です。ディファードでは「物体があったかどうか」まで深度が教えてくれます。この判定があるおかげで、背景のピクセルは 64 個の光源ループに入る前に抜けられます。

6. G-buffer を組み立てる

ここまでの判断を 1 つの関数にまとめたものがsrc/lessons/23-deferred-rendering/gbuffer.tsです。第20章のcreateRenderTarget(カラー 1 枚 + 深度レンダーバッファ)との違いは 2 つだけ —カラーアタッチメントが 2 枚あることと、深度がレンダーバッファではなくテクスチャであることです。第21章のDepthTarget(カラーなし + 深度テクスチャ)とも違って、こちらはカラー 2 枚 + 深度テクスチャ。同じ FBO でも、何を読み返したいかで形が変わります。

src/lessons/23-deferred-rendering/gbuffer.ts(抜粋)
  const baseColor = createAttachmentTexture(gl, gl.RGBA8, width, height);
  const normal = createAttachmentTexture(gl, normalFormat, width, height);
  const depth = createAttachmentTexture(gl, gl.DEPTH_COMPONENT24, width, height);

このヘルパーをsrc/lib/ではなく章のディレクトリに置いたのは、意図的な判断です。G-buffer の構成は「何を、どの精度で、何枚持つか」というシーン設計そのもので、章をまたいで使い回せる形に なりません。第20章のRenderTargetが共通化に値したのは、「カラー 1 枚 + 深度」がどんな用途でも同じ形だったからでした。共通化の 総まとめは第34章のミニエンジンで行います。

テクスチャそのものの作り方は第20章と同じで、フィルタだけが違います。

src/lessons/23-deferred-rendering/gbuffer.ts(抜粋)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

7. 2 つのパスを回す

ジオメトリパスは、これまでの 3D 描画とほとんど同じです。頂点シェーダーはフォワードと同じファイルを共用して いて、変わるのはフラグメントシェーダーだけ。深度テストは効かせます— 「手前にある面の材質だけを G-buffer に残す」のがこのパスの仕事だからです。

src/lessons/23-deferred-rendering/main.ts(抜粋)
      // --- パス 1: ジオメトリパス(G-buffer へ MRT で書き出す) -----------------
      bindGBuffer(gl, gbuffer);
      gl.enable(gl.DEPTH_TEST); // ここでは深度テストが要る(手前の材質だけを残す)
      // gl.clear は「有効なすべての描画バッファ」を同じ値で塗る。
      // アタッチメントごとに別の値で消したいときは clearBufferfv を使う
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

      gl.useProgram(geometryProgram);
      gl.uniformMatrix4fv(geometryLocations.view, false, view);
      gl.uniformMatrix4fv(geometryLocations.projection, false, projection);
      gl.uniform1i(geometryLocations.packNormal, gbuffer.normalFormat === gl.RGBA8 ? 1 : 0);
      drawInstances(geometryLocations);

ライティングパスが描くのは、第6章のハーネスで作った全画面三角形です。src/lib/fullscreen.vertをそのまま使い回すので、VBO も VAO も要りません(gl_VertexIDから 3 頂点を組み立てる、あれです)。そして深度テストは切ります

src/lessons/23-deferred-rendering/main.ts(抜粋)
      // --- パス 2: ライティングパス(全画面三角形 1 枚) -------------------------
      bindRenderTarget(gl, null);
      // 全画面 1 枚しか描かないので深度テストは要らない(第13章の伏線・本文 9 節)。
      // 既定のフレームバッファの深度は合成のたびに 1.0 へ戻るので、切らなくても絵は
      // 変わらない。それでも切るのは、画面側の深度状態に依存しないと明示するため。
      // 深度を使わないので、クリアも COLOR_BUFFER_BIT だけでよい
      gl.disable(gl.DEPTH_TEST);

ここは「切らないと壊れる」場所ではありません。既定のフレームバッファの深度は、WebGL が毎フレーム勝手にクリアしてくれるからです。WebGL 仕様の「The Drawing Buffer」節は、preserveDrawingBufferが既定のfalseのとき「合成のあと、描画バッファの内容は既定値へクリアされる」と定めていて、その既定値の表で 深度は1.0です。一方、全画面三角形のgl_Position.zは 0.0 — 窓座標の深度でいえば 0.5 なので、既定のLESSに毎フレーム必ず通ります。それでも切るのは、このパスが画面側の深度状態に依存していないことを明示するためです(依存させると何が起きるかは、下の実験リストで確かめられます)。

そして肝心の陰影の計算です。第22章の Cook-Torrance をそのまま持ってきます。D / G / F の 3 つの関数は、第22章のpbr.fragから 1 文字も変えていません。式の意味も導出も第22章の担当なので、ここでは触れません。 見てほしいのは、フォワードとディファードがまったく同じ関数を呼んでいることです。

ただし、第22章から2 つ外したものがあります。1 つはIBLです。環境光はu_ambientColor * baseColorという一律の定数に戻してあります。第22章はまさに「一律近似は乱暴で、正式版は IBL だ」と結論した章ですが、放射照度マップもプレフィルタ環境マップも G-buffer の設計とは直交した話で、両方を同時に載せると章の焦点が二重になります。forward.fragも同じ定数を使うので、2 つの方式を突き合わせるという目的には影響しません。もう 1 つはトーンマッピングです。64 灯が重なる構成なので、リニアの合計が 1.0 を超えるピクセルは実際に出ます。本来は第22章 6 節のとおり Reinhard などで畳んでから表示するべきところを、この章はガンマだけを掛けて出しているので、そこは白へ 飽和します。露出とトーンマッピングの比較は第27章で扱います。

src/lessons/23-deferred-rendering/lighting.frag(抜粋)
  // --- 合成: フォワードとまったく同じ関数を呼ぶ --------------------------------
  vec3 V = normalize(u_cameraPosition - worldPosition);
  vec3 color = u_ambientColor * baseColor;
  color += shadeLights(N, V, worldPosition, baseColor, metallic, roughness);

  fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);

同じ式を 2 つのシェーダーに配るために、極小のインクルードを 1 つ用意しました。three.js のShaderChunk+#include <...>と同じ発想です。

src/lessons/23-deferred-rendering/main.ts(抜粋)
function resolveIncludes(source: string): string {
  return source.replace('#include "pbr.glsl"', () => pbrChunkSource.trim());
}

光源の渡し方も 1 か所だけ第18章から変えました。struct の配列ではなく、プレーンな vec4 配列 2 本にパックしています。 struct の配列はロケーションを要素ごと・フィールドごとに取る必要がありましたが、プレーンな配列 なら配列名だけでロケーションが取れ、uniform4fvで一括送信できます(第18章 4 節の対比の、逆側の実例です)。

src/lessons/23-deferred-rendering/pbr.glsl(抜粋)
#define MAX_LIGHTS 64

// xyz = 光源のワールド座標 / w = 影響半径(これより遠いピクセルは計算しない)
uniform vec4 u_lightPositionRadius[MAX_LIGHTS];
// rgb = 光の色(リニア) / a = 強さ。第17章・第22章の u_lightColor(vec3・方向光 1 灯の色)とは
// 別物なので、パックしていることが分かる名前にしてある
uniform vec4 u_lightColorIntensity[MAX_LIGHTS];
uniform int u_lightCount;

64 灯 × 2 = 128 個のvec4は、決して小さくありません。GLSL ES 3.00 のgl_MaxFragmentUniformVectorsの最小保証は 224 なので、その半分以上をこれだけで使っています。光源を uniform 配列で持つやり方はこのあたりが上限で、その先はUBO (uniform buffer object)に置き換えることになります(第34章)。

8. コストはどう変わったか

フォワードとディファードの計算量を並べます。Fを「描かれたフラグメントの総数」、Pを「画面のピクセル数」、Lを光源数とすると、

フォワードライティング F × L 回F = P × 平均の重なり回数(overdraw)。early-Z が効けば減るが、描画順しだいディファード書き出し F 回ライティング P × L 回ライティング側に重なりが掛からない。ただし G-buffer の書き出しと読み戻しの帯域が増えるL が小さいうちは、固定費のぶんフォワードが速い。L が増えるほど、重なりの掛からない ディファードが有利になる。交差する L の値はシーンと GPU しだいで、一般的な数字は存在しない。
コストの構造。ディファードは L の係数から重なりを外す代わりに、光源数に依存しない固定費(G-buffer の書き出しと読み戻し)を背負います。

光源が少ないときはフォワードのほうが速い、というのがこの図の正直な読み方です。 ディファードは G-buffer の書き出しと読み戻しという固定費を先に払うので、光源 1〜2 個のシーンでは payback しません。第17章から第22章までフォワードで押し通してきたのは、単に 説明が簡単だからではなく、光源が数個のシーンではそれが正解だからでもあります。

デモの読み出し行には 1 フレームの所要時間が出ますが、これはrequestAnimationFrameのコールバックが呼ばれる間隔を CPU 側で測ったものです。GPU が実際に何ミリ秒使ったかではありません。画面のリフレッシュレートに張り付いているあいだは、 どちらの方式でも 16.7 ms 前後を表示し続けます — 差が見えるのはどちらかが間に合わなくなってからです。GPU の時間を正しく測る道具 (EXT_disjoint_timer_query_webgl2によるタイマークエリ)は第35章で扱います。

9. ディファードが引き受ける制約

ここまでの利点には、はっきりした代償があります。順に見ます。

半透明が扱えない

G-buffer は 1 ピクセルにつき 1 つの材質しか持てません。半透明の板の向こうに 別の物体が見えている状態は、この構造では表現できないのです。書き込んだほうが勝ち、負けたほうは 消えます。実務での答えは決まっていて、不透明はディファード、半透明はフォワードで描き足す、という併用です。そのとき半透明パスは不透明面との前後関係を合わせる必要があります。 WebGL2 で素直なのは、G-buffer と同じ FBO(= 同じ深度アタッチメント)へカラーだけを描き足すやり方です。ライティングの結果を画面ではなくその FBO のカラーアタッチメントへ書き、その上に半透明をブレンドし、最後にカラーだけを画面へ転送します。 ブレンドの設定そのもの(blendFunc・加算合成・描画順)は第30章で扱います。

発光する物体(光源のマーカーなど)も、このデモには出てきません。ただし理由は「1 ピクセル 1 材質」ではありません — 発光は不透明サーフェスの性質なので、G-buffer に emissive を 1 本足すか、ジオメトリパスからライティング用のバッファへ直接加算すれば、ディファードのまま 扱えます。実際の制約はこの章の G-buffer に発光の置き場を用意していないことで、光源の位置に小球を描いていないのはそのためです。マテリアルの型を先に固定してしまうと、 あとから 1 つ足すのに全ピクセルぶんの帯域が要る、という後述の話がここでも効いています。

MSAA が効きにくい

第20章で「自作の FBO にはコンテキスト属性のantialiasが効かない」と書きました。ディファードではさらに厄介になります。マルチサンプルの G-buffer を作ったとして、解決(resolve)の段でサンプルどうしを平均してしまうと、材質が混ざるからです。法線と法線の平均は法線ではありませんし、金属と非金属の中間の metallic には物理的な意味がありません。輪郭で 2 つの面の材質が溶け合った、意味のない値ができあがります。

正攻法はサンプルごとにライティングを走らせることですが、コストは跳ね上がります。そしてWebGL2 では、そもそもこの正攻法を選べません。WebGL 2.0 仕様にはTEXTURE_2D_MULTISAMPLEsampler2DMSも存在せず、マルチサンプルに使えるのはrenderbufferStorageMultisampleで作ったレンダーバッファをblitFramebufferで解決する経路だけ(第20章 8 節)だからです。レンダーバッファはシェーダーから読めないので、 「サンプル 1 つずつを G-buffer として読む」という書き方が最初から成立しません。現実的な答えは後処理でのアンチエイリアスです。できあがった 1 枚の絵から輪郭を推定して ぼかす FXAA のような手法なら、材質の混ざりようがありません。第2章から「antialias が効かない前提の対策」として送り続けてきた宿題は、第33章のポストプロセスで回収します。

帯域を食う

1 ピクセル 16 バイトの G-buffer は、1280 × 720 なら約 14 MB です。ジオメトリパスで書き、 ライティングパスで読むので、1 フレームあたり最低でもその 2 倍のメモリトラフィックが発生します。60 fps なら毎秒 1.7 GB。GPU のメモリ帯域は有限で、とくにモバイルの統合 GPU では、これが計算そのものより先に頭打ちになります。 タイルベースのモバイル GPU が伝統的にディファードを嫌ってきたのは、この理由です。

マテリアルのバリエーションを増やしにくい

フォワードなら、物体ごとに別のシェーダーを使えます。この物体は異方性反射、あれはサブサーフェス スキャッタリング、と自由に混ぜられる。ディファードでは、すべての材質が G-buffer という共通の型に収まらなければなりません。新しいパラメータを 1 つ足すたびに、全ピクセルぶんのメモリと帯域が増えます。 G-buffer の設計が「ディファードの設計のほとんどすべて」なのは、この非可逆性のためです。

10. この先 — 中間の方式と、その名前

フォワードとディファードの間には、両方の良さを取ろうとする方式があります。名前だけ挙げておきます — タイルドフォワード (tiled forward) / Forward+クラスタードシェーディング (clustered shading)です。どちらも「画面を タイルに分け、そのタイルに影響する光源の一覧を先に作ってから、フォワードで描く」という発想で、 G-buffer を持たずにオーバードローと光源数の掛け算を減らします。半透明も MSAA もフォワードのまま扱えるので、近年の実装はこちら側に寄っています。このサイトでは扱いません— 計算シェーダーや、少なくともタイルごとの光源リストを作るための下ごしらえが要り、WebGL2 の 範囲では素直に書けないからです。

影については、ディファードでも作り方は変わりません。第21章のシャドウマッピングと同じく 「光源視点で深度を描く → ライティングのときに比べる」という 2 パスで、比べる場所が ライティングパスに移るだけです。

コード全文

シェーダーは 5 ファイルです。scene.vertはジオメトリパスとフォワードパスの共用、pbr.glslは第22章の BRDF をそのまま置いたチャンクで、forward.fraglighting.fragの両方に差し込まれます。

src/lessons/23-deferred-rendering/gbuffer.ts
// 第23章のローカルヘルパー: G-buffer(複数カラーアタッチメント + 深度テクスチャ)
//
// src/lib/ ではなくレッスンのディレクトリに置いている。理由は本文 6 節のとおりで、
// 「何を、どの精度で、何枚持つか」はシーンの設計そのものであって、章をまたいで
// 使い回せる形にはならないため。共通化の総まとめは第34章。
//
// 構成(本文 4 節):
//   COLOR_ATTACHMENT0  RGBA8            rgb = baseColor(リニア) / a = metallic
//   COLOR_ATTACHMENT1  RGBA16F or RGBA8 rgb = ワールド法線     / a = roughness
//   DEPTH_ATTACHMENT   DEPTH_COMPONENT24(テクスチャ) 位置はここから復元する

import { framebufferStatusName } from '../../lib/framebuffer';

export interface GBuffer {
  framebuffer: WebGLFramebuffer;
  /** COLOR_ATTACHMENT0 のテクスチャ */
  baseColor: WebGLTexture;
  /** COLOR_ATTACHMENT1 のテクスチャ */
  normal: WebGLTexture;
  /** DEPTH_ATTACHMENT のテクスチャ。レンダーバッファではないのは、あとで読むから(第20章) */
  depth: WebGLTexture;
  width: number;
  height: number;
  /** 法線バッファの内部フォーマット(gl.RGBA16F か gl.RGBA8) */
  normalFormat: GLenum;
}

/**
 * アタッチメント 1 枚ぶんのテクスチャを作る。
 * G-buffer は必ず 1 テクセル : 1 ピクセルで読む(texelFetch)ので補間は要らない。
 * そもそも DEPTH_COMPONENT24 は texture-filterable ではないので、LINEAR にすると
 * テクスチャ完備性を失って読めなくなる(OpenGL ES 3.0 §3.8.13 の完備条件)
 */
function createAttachmentTexture(
  gl: WebGL2RenderingContext,
  internalFormat: GLenum,
  width: number,
  height: number,
): WebGLTexture {
  const texture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texStorage2D(gl.TEXTURE_2D, 1, internalFormat, width, height);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.bindTexture(gl.TEXTURE_2D, null);
  return texture;
}

/** 1 ピクセルあたりの G-buffer のバイト数(読み出し行に出す帯域の目安) */
export function gbufferBytesPerPixel(gl: WebGL2RenderingContext, buffer: GBuffer): number {
  const normalBytes = buffer.normalFormat === gl.RGBA16F ? 8 : 4;
  return 4 /* RGBA8 */ + normalBytes + 4; /* DEPTH_COMPONENT24 は 32 ビットに詰められる */
}

/**
 * G-buffer を 1 つ作る。第20章の createRenderTarget(カラー 1 枚 + 深度レンダーバッファ)
 * との違いは 2 つだけ — カラーアタッチメントが 2 枚あることと、深度がレンダーバッファでは
 * なくテクスチャなこと。第21章の createDepthTarget(カラーなし + 深度テクスチャ)とも違う。
 */
export function createGBuffer(
  gl: WebGL2RenderingContext,
  width: number,
  height: number,
  normalFormat: GLenum,
): GBuffer {
  if (width < 1 || height < 1) {
    throw new Error(`G-buffer のサイズは 1 以上にしてください(${width}×${height})`);
  }

  const baseColor = createAttachmentTexture(gl, gl.RGBA8, width, height);
  const normal = createAttachmentTexture(gl, normalFormat, width, height);
  const depth = createAttachmentTexture(gl, gl.DEPTH_COMPONENT24, width, height);

  const framebuffer = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, baseColor, 0);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT1, gl.TEXTURE_2D, normal, 0);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depth, 0);

  // これを忘れると 1 枚目にしか描かれない。FBO の初期状態は
  // 「フラグメントカラー 0 → COLOR_ATTACHMENT0、それ以外 → NONE」で、
  // NONE に割り当てられた出力は黙って捨てられる(OpenGL ES 3.0 §4.2.1)。
  // 配列の i 番目は COLOR_ATTACHMENTi か NONE でなければならない(順番も固定)。
  // この状態はフレームバッファごとに持たれるので、設定は生成時の 1 回でよい
  gl.drawBuffers([gl.COLOR_ATTACHMENT0, gl.COLOR_ATTACHMENT1]);

  const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  if (status !== gl.FRAMEBUFFER_COMPLETE) {
    deleteGBuffer(gl, { framebuffer, baseColor, normal, depth, width, height, normalFormat });
    throw new Error(
      `G-buffer が不完全です: ${framebufferStatusName(gl, status)}${width}×${height}, ` +
        `法線バッファ 0x${normalFormat.toString(16)}` +
        '(RGBA16F へ描くには EXT_color_buffer_float か ' +
        'EXT_color_buffer_half_float のどちらかが要ります・第20章 8 節)',
    );
  }

  return { framebuffer, baseColor, normal, depth, width, height, normalFormat };
}

/**
 * 描画先を G-buffer に切り替える。viewport もセットで動かす(第20章 5 節)。
 * 画面へ戻すときは第20章の bindRenderTarget(gl, null) を使う(戻り先の処理は 1 か所でよい)
 */
export function bindGBuffer(gl: WebGL2RenderingContext, buffer: GBuffer): void {
  gl.bindFramebuffer(gl.FRAMEBUFFER, buffer.framebuffer);
  gl.viewport(0, 0, buffer.width, buffer.height);
}

export function deleteGBuffer(gl: WebGL2RenderingContext, buffer: GBuffer): void {
  gl.deleteFramebuffer(buffer.framebuffer);
  gl.deleteTexture(buffer.baseColor);
  gl.deleteTexture(buffer.normal);
  gl.deleteTexture(buffer.depth);
}
src/lessons/23-deferred-rendering/main.ts
// 第23章: ディファードレンダリング — MRT と G-buffer
//
// 同じシーン(床 + 6×6 の物体 + 最大 64 灯の点光源)を 2 通りの手順で描き、切り替えて比べる。
//
//   フォワード  : 物体を描きながら、その場で全光源のライティングまで済ませる(第17〜18章)
//   ディファード: ① ジオメトリパス — 材質と法線を G-buffer(MRT)へ書き出す
//                 ② ライティングパス — 全画面三角形 1 枚で G-buffer を読み、光を計算する
//
// ライティングの式は第22章の Cook-Torrance をそのまま使う(pbr.glsl)。
// 2 つのパスは同じ関数 shadeLights() を呼ぶので、絵は原理的に一致する。

import { mat3, mat4, type ReadonlyMat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import { bindRenderTarget } 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 forwardFragmentTemplate from './forward.frag?raw';
import {
  bindGBuffer,
  createGBuffer,
  deleteGBuffer,
  type GBuffer,
  gbufferBytesPerPixel,
} from './gbuffer';
import geometryFragmentSource from './geometry.frag?raw';
import lightingFragmentTemplate from './lighting.frag?raw';
import pbrChunkSource from './pbr.glsl?raw';
import sceneVertexSource from './scene.vert?raw';

// ---------------------------------------------------------------------------
// 極小のインクルード
// ---------------------------------------------------------------------------

// フォワードパスとライティングパスに、同じ BRDF(pbr.glsl)を配るためだけの仕組み。
// three.js の ShaderChunk + `#include <...>` と同じ発想を 1 行で済ませたもの。
// 置換文字列を関数で渡しているのは、String.replace が `$&` などを特別扱いするため
// (チャンク側に `$` が現れても事故にならないようにしておく)
function resolveIncludes(source: string): string {
  return source.replace('#include "pbr.glsl"', () => pbrChunkSource.trim());
}

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

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

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

// pbr.glsl の #define MAX_LIGHTS と必ず同じ値にする
const MAX_LIGHTS = 64;
const LIGHT_COUNTS = [8, 32, 64] as const;

const GRID = 6; // 6 × 6 = 36 個の物体を並べる
const SPACING = 2.1;
const FLOOR_Y = -0.9;

const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
const TARGET: ReadonlyVec3 = vec3.fromValues(0, 0.2, 0);
// 隙間が真っ黒にならない程度の定数。環境光の正しい扱い(IBL)は第22章
const AMBIENT_COLOR: ReadonlyVec3 = vec3.fromValues(0.035, 0.04, 0.05);
// canvas の CSS 背景色と同じ。ライティングパスは背景ピクセルにこの色をそのまま置く
const BACKGROUND_COLOR: ReadonlyVec3 = vec3.fromValues(0.06, 0.07, 0.09);
// 深度の可視化で「白 → 黒」に割り当てるカメラからの距離
const DEPTH_VIEW_RANGE = 28;

const FLOOR_BASE_COLOR: ReadonlyVec3 = vec3.fromValues(0.055, 0.06, 0.07);
// 物体の基本色(リニア空間の値。第22章の約束)。列ごとに使い分ける
const OBJECT_BASE_COLORS: readonly ReadonlyVec3[] = [
  vec3.fromValues(0.62, 0.29, 0.18),
  vec3.fromValues(0.55, 0.52, 0.24),
  vec3.fromValues(0.2, 0.45, 0.36),
  vec3.fromValues(0.18, 0.36, 0.6),
  vec3.fromValues(0.42, 0.24, 0.55),
  vec3.fromValues(0.6, 0.6, 0.62),
];

const VIEWS = [
  { label: '合成', value: 0, note: 'G-buffer を読んで Cook-Torrance を適用した結果' },
  { label: 'baseColor', value: 1, note: 'RT0 の rgb。光がまだ 1 つも当たっていない材質の色' },
  { label: '法線', value: 2, note: 'RT1 の rgb を 0〜1 に写したもの。面の向きがそのまま色になる' },
  {
    label: 'metallic-roughness',
    value: 3,
    note: 'RT0 の a を青、RT1 の a を緑に。金属の列ほど青く、粗い行ほど緑が強い',
  },
  { label: '深度', value: 4, note: '深度アタッチメントから復元したカメラ距離。位置はここから作る' },
] as const;

// ---------------------------------------------------------------------------
// 光源
// ---------------------------------------------------------------------------

interface Light {
  /** 公転の中心 */
  origin: vec3;
  /** 公転の半径・速さ・位相 */
  orbitRadius: number;
  speed: number;
  phase: number;
  /** 影響半径(これより遠いピクセルは計算しない) */
  radius: number;
  color: ReadonlyVec3;
  intensity: number;
}

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

function createLights(): Light[] {
  const random = createRandom(20230823);
  const lights: Light[] = [];
  for (let i = 0; i < MAX_LIGHTS; i++) {
    // 色相を 1 周ぶん散らす。リニア空間の値なので、彩度の高い原色に近い成分になる
    const hue = (i / MAX_LIGHTS) * Math.PI * 2;
    lights.push({
      origin: vec3.fromValues(
        (random() - 0.5) * 13,
        FLOOR_Y + 0.35 + random() * 1.7,
        (random() - 0.5) * 13,
      ),
      orbitRadius: 0.6 + random() * 1.1,
      speed: 0.25 + random() * 0.55,
      phase: random() * Math.PI * 2,
      radius: 3.4,
      color: vec3.fromValues(
        0.5 + 0.5 * Math.sin(hue),
        0.5 + 0.5 * Math.sin(hue + 2.094),
        0.5 + 0.5 * Math.sin(hue + 4.189),
      ),
      intensity: 6,
    });
  }
  return lights;
}

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  modeControls: HTMLParagraphElement,
  viewControls: HTMLParagraphElement,
  lightControls: HTMLParagraphElement,
  formatControls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  // --- 拡張の確認(第20章 8 節) ------------------------------------------------
  // RGBA16F を「読む」のはコア機能だが、カラーアタッチメントにして「描く」には拡張が要る。
  // EXT_color_buffer_float が無くても、EXT_color_buffer_half_float があれば RGBA16F は
  // color-renderable になる(そちらへの描画は実装に必須と拡張仕様が定めている)。
  // getExtension は問い合わせと有効化を兼ねるので、判定の前に両方を呼んでおく
  const colorBufferFloat = gl.getExtension('EXT_color_buffer_float');
  const colorBufferHalfFloat = gl.getExtension('EXT_color_buffer_half_float');
  const hasFloatColor = colorBufferFloat !== null || colorBufferHalfFloat !== null;

  // --- プログラム 3 本 ---------------------------------------------------------
  const geometryProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, sceneVertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, geometryFragmentSource),
  );
  const forwardProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, sceneVertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, resolveIncludes(forwardFragmentTemplate)),
  );
  const lightingProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, fullscreenVertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, resolveIncludes(lightingFragmentTemplate)),
  );

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

  const geometryLocations = {
    model: at(geometryProgram, 'u_model'),
    view: at(geometryProgram, 'u_view'),
    projection: at(geometryProgram, 'u_projection'),
    normalMatrix: at(geometryProgram, 'u_normalMatrix'),
    baseColor: at(geometryProgram, 'u_baseColor'),
    metallic: at(geometryProgram, 'u_metallic'),
    roughness: at(geometryProgram, 'u_roughness'),
    packNormal: at(geometryProgram, 'u_packNormal'),
  };
  const forwardLocations = {
    model: at(forwardProgram, 'u_model'),
    view: at(forwardProgram, 'u_view'),
    projection: at(forwardProgram, 'u_projection'),
    normalMatrix: at(forwardProgram, 'u_normalMatrix'),
    baseColor: at(forwardProgram, 'u_baseColor'),
    metallic: at(forwardProgram, 'u_metallic'),
    roughness: at(forwardProgram, 'u_roughness'),
    cameraPosition: at(forwardProgram, 'u_cameraPosition'),
    ambientColor: at(forwardProgram, 'u_ambientColor'),
    lightPositionRadius: at(forwardProgram, 'u_lightPositionRadius'),
    lightColorIntensity: at(forwardProgram, 'u_lightColorIntensity'),
    lightCount: at(forwardProgram, 'u_lightCount'),
  };
  const lightingLocations = {
    gBaseColor: at(lightingProgram, 'u_gBaseColor'),
    gNormal: at(lightingProgram, 'u_gNormal'),
    gDepth: at(lightingProgram, 'u_gDepth'),
    inverseViewProjection: at(lightingProgram, 'u_inverseViewProjection'),
    cameraPosition: at(lightingProgram, 'u_cameraPosition'),
    ambientColor: at(lightingProgram, 'u_ambientColor'),
    backgroundColor: at(lightingProgram, 'u_backgroundColor'),
    packNormal: at(lightingProgram, 'u_packNormal'),
    debugView: at(lightingProgram, 'u_debugView'),
    depthViewRange: at(lightingProgram, 'u_depthViewRange'),
    lightPositionRadius: at(lightingProgram, 'u_lightPositionRadius'),
    lightColorIntensity: at(lightingProgram, 'u_lightColorIntensity'),
    lightCount: at(lightingProgram, 'u_lightCount'),
  };

  // --- メッシュとマテリアル -----------------------------------------------------

  const floorMesh = createMesh(gl, createPlane(26, 26));
  const sphereMesh = createMesh(gl, createSphere(0.55, 32, 16));
  const torusMesh = createMesh(gl, createTorus(0.44, 0.18, 48, 20));

  interface Instance {
    mesh: Mesh;
    position: ReadonlyVec3;
    /** トーラスだけ寝かせる */
    layFlat: boolean;
    baseColor: ReadonlyVec3;
    metallic: number;
    roughness: number;
  }

  const instances: Instance[] = [];
  for (let row = 0; row < GRID; row++) {
    for (let col = 0; col < GRID; col++) {
      const isTorus = (row + col) % 2 === 1;
      instances.push({
        mesh: isTorus ? torusMesh : sphereMesh,
        position: vec3.fromValues(
          (col - (GRID - 1) / 2) * SPACING,
          FLOOR_Y + (isTorus ? 0.19 : 0.56),
          (row - (GRID - 1) / 2) * SPACING,
        ),
        layFlat: isTorus,
        baseColor: OBJECT_BASE_COLORS[col],
        // 列で metallic、行で roughness を振る(可視化モードで格子が見える)
        metallic: col / (GRID - 1),
        roughness: 0.08 + (row / (GRID - 1)) * 0.82,
      });
    }
  }

  const lights = createLights();
  // uniform4fv へ一括で渡す並び。毎フレーム作り直さず、中身だけ書き換える
  const lightPositionRadius = new Float32Array(MAX_LIGHTS * 4);
  const lightColorIntensity = new Float32Array(MAX_LIGHTS * 4);
  for (let i = 0; i < MAX_LIGHTS; i++) {
    lightColorIntensity[i * 4] = lights[i].color[0];
    lightColorIntensity[i * 4 + 1] = lights[i].color[1];
    lightColorIntensity[i * 4 + 2] = lights[i].color[2];
    lightColorIntensity[i * 4 + 3] = lights[i].intensity;
    lightPositionRadius[i * 4 + 3] = lights[i].radius;
  }

  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.CULL_FACE);

  // --- 状態 ---------------------------------------------------------------------

  let mode: 'deferred' | 'forward' = 'deferred';
  let debugView = 0;
  let lightCount: number = LIGHT_COUNTS[2];
  let normalFormat: GLenum = hasFloatColor ? gl.RGBA16F : gl.RGBA8;

  // 先に canvas を実サイズへ合わせてから G-buffer を作る(第20章と同じ理由)
  resizeIfNeeded();
  let gbuffer = createGBuffer(gl, targetWidth(), targetHeight(), normalFormat);

  function targetWidth(): number {
    return Math.max(1, Math.floor(gl.drawingBufferWidth));
  }
  function targetHeight(): number {
    return Math.max(1, Math.floor(gl.drawingBufferHeight));
  }

  /** サイズか法線バッファの形式が変わったら作り直す。古いものは必ず解放する(第20章 7 節) */
  function updateGBuffer(): void {
    const width = targetWidth();
    const height = targetHeight();
    if (
      gbuffer.width === width &&
      gbuffer.height === height &&
      gbuffer.normalFormat === normalFormat
    ) {
      return;
    }
    // 先に作り、成功してから古いものを消す(第20章 7 節の例外安全)。
    // この関数は frame() から呼ばれるので、例外をここから外へ出してはいけない。
    // 抜けると末尾の requestAnimationFrame(frame) に到達できず、描画ループが永久に止まる
    let next: GBuffer;
    try {
      next = createGBuffer(gl, width, height, normalFormat);
    } catch (error) {
      console.error(error);
      // 法線バッファを RGBA8 に落として作り直す。それでも駄目なら今のものを使い続ける
      if (normalFormat === gl.RGBA8) return;
      normalFormat = gl.RGBA8;
      disableFloatFormat();
      updateGBuffer();
      return;
    }
    deleteGBuffer(gl, gbuffer);
    gbuffer = next;
  }

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

  const orbit = { theta: 0.62, phi: 0.42, radius: 14.5 };
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 7;
  const MAX_RADIUS = 26;

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

  // --- 切替ボタン ---------------------------------------------------------------

  function addButtons<T>(
    container: HTMLParagraphElement,
    entries: readonly { label: string; value: T }[],
    isActive: (value: T) => boolean,
    onSelect: (value: T) => void,
  ): HTMLButtonElement[] {
    const buttons: HTMLButtonElement[] = [];
    for (const entry of entries) {
      const button = document.createElement('button');
      button.type = 'button';
      button.textContent = entry.label;
      button.setAttribute('aria-pressed', isActive(entry.value) ? 'true' : 'false');
      button.addEventListener('click', () => {
        onSelect(entry.value);
        for (const other of container.querySelectorAll('button')) {
          other.setAttribute('aria-pressed', 'false');
        }
        button.setAttribute('aria-pressed', 'true');
      });
      container.append(button);
      buttons.push(button);
    }
    return buttons;
  }

  const viewButtons = addButtons(
    viewControls,
    VIEWS.map((entry) => ({ label: entry.label, value: entry.value })),
    (value) => value === debugView,
    (value) => {
      debugView = value;
    },
  );

  function syncViewButtons(): void {
    // G-buffer の可視化はディファードでしか意味がない(フォワードには G-buffer がない)
    for (const button of viewButtons) {
      button.disabled = mode === 'forward';
    }
  }

  addButtons(
    modeControls,
    [
      { label: 'ディファード', value: 'deferred' as const },
      { label: 'フォワード', value: 'forward' as const },
    ],
    (value) => value === mode,
    (value) => {
      mode = value;
      syncViewButtons();
    },
  );

  addButtons(
    lightControls,
    LIGHT_COUNTS.map((count) => ({ label: `光源 ${count}`, value: count })),
    (value) => value === lightCount,
    (value) => {
      lightCount = value;
    },
  );

  const formatButtons = addButtons(
    formatControls,
    [
      { label: '法線 RGBA16F', value: gl.RGBA16F },
      { label: 'RGBA8', value: gl.RGBA8 },
    ],
    (value) => value === normalFormat,
    (value) => {
      normalFormat = value;
      updateGBuffer();
    },
  );
  /** RGBA16F が使えないと分かったときに、切替ボタンを RGBA8 側へ固定する */
  function disableFloatFormat(): void {
    formatButtons[0].disabled = true;
    formatButtons[0].setAttribute('aria-pressed', 'false');
    formatButtons[1].setAttribute('aria-pressed', 'true');
  }

  if (!hasFloatColor) {
    // どちらの拡張も無い環境では RGBA16F のアタッチメントを作れない
    // (FRAMEBUFFER_INCOMPLETE_ATTACHMENT になる・第20章 4 節)。
    // normalFormat は最初から RGBA8 なので、押せないようにするだけでよい
    disableFloatFormat();
  }
  syncViewButtons();

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

  const eye = vec3.create();
  const model = mat4.create();
  const view = mat4.create();
  const projection = mat4.create();
  const viewProjection = mat4.create();
  const inverseViewProjection = mat4.create();
  const normalMatrix = mat3.create();

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

  function resizeIfNeeded(): void {
    // この章はフラグメント側が重いので、dpr の上限を他章より低くしている。
    // 解像度スケールを性能の道具として一般化するのは第35章
    const dpr = Math.min(window.devicePixelRatio, 1.5);
    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;
    }
  }

  // --- 描画 ---------------------------------------------------------------------

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

  function uploadLights(
    positionRadius: WebGLUniformLocation | null,
    colorIntensity: WebGLUniformLocation | null,
    count: WebGLUniformLocation | null,
  ): void {
    // プレーンな配列は配列名だけでロケーションが取れ、要素をまとめて送れる(第18章)
    gl.uniform4fv(positionRadius, lightPositionRadius.subarray(0, lightCount * 4));
    gl.uniform4fv(colorIntensity, lightColorIntensity.subarray(0, lightCount * 4));
    gl.uniform1i(count, lightCount);
  }

  /** シーンの全インスタンスを、いま useProgram されているプログラムで描く */
  function drawInstances(locations: {
    model: WebGLUniformLocation | null;
    normalMatrix: WebGLUniformLocation | null;
    baseColor: WebGLUniformLocation | null;
    metallic: WebGLUniformLocation | null;
    roughness: WebGLUniformLocation | null;
  }): void {
    const draw = (
      mesh: Mesh,
      modelMatrix: ReadonlyMat4,
      baseColor: ReadonlyVec3,
      metallic: number,
      roughness: number,
    ): void => {
      mat3.normalFromMat4(normalMatrix, modelMatrix); // 法線行列(第17章)
      gl.uniformMatrix4fv(locations.model, false, modelMatrix); // transpose は常に false
      gl.uniformMatrix3fv(locations.normalMatrix, false, normalMatrix);
      setVec3(locations.baseColor, baseColor);
      gl.uniform1f(locations.metallic, metallic);
      gl.uniform1f(locations.roughness, roughness);
      gl.bindVertexArray(mesh.vao);
      gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
    };

    // 床: XY 平面の板を X 軸まわりに -90° 回して床にする(第14章)
    mat4.fromTranslation(model, [0, FLOOR_Y, 0]);
    mat4.rotateX(model, model, -Math.PI / 2);
    draw(floorMesh, model, FLOOR_BASE_COLOR, 0.0, 0.75);

    for (const instance of instances) {
      mat4.fromTranslation(model, instance.position);
      if (instance.layFlat) {
        mat4.rotateX(model, model, -Math.PI / 2);
      }
      draw(instance.mesh, model, instance.baseColor, instance.metallic, instance.roughness);
    }
  }

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

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

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

  function updateReadout(): void {
    const pixels = gbuffer.width * gbuffer.height;
    const bytesPerPixel = gbufferBytesPerPixel(gl, gbuffer);
    const megabytes = (pixels * bytesPerPixel) / (1024 * 1024);
    const formatName = gbuffer.normalFormat === gl.RGBA16F ? 'RGBA16F' : 'RGBA8';
    // まだ 1 フレームも測っていないうちは 0.0 ms と出さない
    const ms = frameTimes.length === 0 ? '—' : `${averageFrameMs().toFixed(1)} ms`;
    const shading =
      mode === 'deferred'
        ? `ライティング ${pixels.toLocaleString('en-US')} px × ${lightCount} 灯`
        : `ライティング 描かれたフラグメント × ${lightCount} 灯`;
    const viewNote = mode === 'deferred' ? ` / 表示: ${VIEWS[debugView].note}` : '';
    readout.textContent =
      `${mode === 'deferred' ? 'ディファード' : 'フォワード'} / ${shading} / ` +
      `G-buffer ${gbuffer.width}×${gbuffer.height}: RGBA8 + ${formatName} + DEPTH_COMPONENT24 ` +
      `= ${bytesPerPixel} B/px(${megabytes.toFixed(1)} MB)/ ` +
      `rAF 間隔 ${ms}(CPU 側の計測。GPU 時間ではありません・第35章)${viewNote}`;
  }

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

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

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

    // カメラ(第15章)
    eye[0] = 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);
    // アスペクト比は描画先の幅 / 高さ。ここでは G-buffer も canvas も同じサイズ
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

    // 光源を動かす。位置だけ書き換え、色と強さは初期化時のまま
    for (let i = 0; i < lightCount; i++) {
      const light = lights[i];
      const angle = time * light.speed + light.phase;
      lightPositionRadius[i * 4] = light.origin[0] + Math.cos(angle) * light.orbitRadius;
      lightPositionRadius[i * 4 + 1] = light.origin[1] + Math.sin(angle * 1.7) * 0.25;
      lightPositionRadius[i * 4 + 2] = light.origin[2] + Math.sin(angle) * light.orbitRadius;
    }

    if (mode === 'forward') {
      bindRenderTarget(gl, null);
      gl.enable(gl.DEPTH_TEST);
      gl.clearColor(BACKGROUND_COLOR[0], BACKGROUND_COLOR[1], BACKGROUND_COLOR[2], 1.0);
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

      gl.useProgram(forwardProgram);
      gl.uniformMatrix4fv(forwardLocations.view, false, view);
      gl.uniformMatrix4fv(forwardLocations.projection, false, projection);
      setVec3(forwardLocations.cameraPosition, eye);
      setVec3(forwardLocations.ambientColor, AMBIENT_COLOR);
      uploadLights(
        forwardLocations.lightPositionRadius,
        forwardLocations.lightColorIntensity,
        forwardLocations.lightCount,
      );
      drawInstances(forwardLocations);
    } else {
      // --- パス 1: ジオメトリパス(G-buffer へ MRT で書き出す) -----------------
      bindGBuffer(gl, gbuffer);
      gl.enable(gl.DEPTH_TEST); // ここでは深度テストが要る(手前の材質だけを残す)
      // gl.clear は「有効なすべての描画バッファ」を同じ値で塗る。
      // アタッチメントごとに別の値で消したいときは clearBufferfv を使う
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

      gl.useProgram(geometryProgram);
      gl.uniformMatrix4fv(geometryLocations.view, false, view);
      gl.uniformMatrix4fv(geometryLocations.projection, false, projection);
      gl.uniform1i(geometryLocations.packNormal, gbuffer.normalFormat === gl.RGBA8 ? 1 : 0);
      drawInstances(geometryLocations);

      // --- パス 2: ライティングパス(全画面三角形 1 枚) -------------------------
      bindRenderTarget(gl, null);
      // 全画面 1 枚しか描かないので深度テストは要らない(第13章の伏線・本文 9 節)。
      // 既定のフレームバッファの深度は合成のたびに 1.0 へ戻るので、切らなくても絵は
      // 変わらない。それでも切るのは、画面側の深度状態に依存しないと明示するため。
      // 深度を使わないので、クリアも COLOR_BUFFER_BIT だけでよい
      gl.disable(gl.DEPTH_TEST);
      gl.clearColor(BACKGROUND_COLOR[0], BACKGROUND_COLOR[1], BACKGROUND_COLOR[2], 1.0);
      gl.clear(gl.COLOR_BUFFER_BIT);

      gl.useProgram(lightingProgram);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, gbuffer.baseColor);
      gl.activeTexture(gl.TEXTURE1);
      gl.bindTexture(gl.TEXTURE_2D, gbuffer.normal);
      gl.activeTexture(gl.TEXTURE2);
      gl.bindTexture(gl.TEXTURE_2D, gbuffer.depth);
      gl.uniform1i(lightingLocations.gBaseColor, 0);
      gl.uniform1i(lightingLocations.gNormal, 1);
      gl.uniform1i(lightingLocations.gDepth, 2);

      mat4.multiply(viewProjection, projection, view);
      mat4.invert(inverseViewProjection, viewProjection);
      gl.uniformMatrix4fv(lightingLocations.inverseViewProjection, false, inverseViewProjection);
      setVec3(lightingLocations.cameraPosition, eye);
      setVec3(lightingLocations.ambientColor, AMBIENT_COLOR);
      setVec3(lightingLocations.backgroundColor, BACKGROUND_COLOR);
      gl.uniform1i(lightingLocations.packNormal, gbuffer.normalFormat === gl.RGBA8 ? 1 : 0);
      gl.uniform1i(lightingLocations.debugView, debugView);
      gl.uniform1f(lightingLocations.depthViewRange, DEPTH_VIEW_RANGE);
      uploadLights(
        lightingLocations.lightPositionRadius,
        lightingLocations.lightColorIntensity,
        lightingLocations.lightCount,
      );

      // 全画面三角形は VBO も VAO も使わない(第6章)。3 頂点は CCW = 表向きなので
      // CULL_FACE を有効にしたままでよい
      gl.bindVertexArray(null);
      gl.drawArrays(gl.TRIANGLES, 0, 3);

      // 次のフレームでこのテクスチャは描画先に戻る。サンプル元として残さない
      for (let unit = 0; unit < 3; unit++) {
        gl.activeTexture(gl.TEXTURE0 + unit);
        gl.bindTexture(gl.TEXTURE_2D, null);
      }
    }

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

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

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

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

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

setup(gl, canvas, modeControls, viewControls, lightControls, formatControls, readout);
src/lessons/23-deferred-rendering/scene.vert
#version 300 es

// ジオメトリパスとフォワードパスで共用する頂点シェーダー。
// ディファードにしても頂点まわりの仕事は何も変わらない、というのがこの共用の意味でもある。

// 第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章の約束)。
// v_worldPosition を読むのはフォワードパスだけで、ジオメトリパスは受け取らない
// (頂点シェーダーの出力に対応する入力が無くてもリンクは通る)
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/23-deferred-rendering/geometry.frag
#version 300 es
precision highp float;

// ジオメトリパス: 光の計算はいっさいせず、「このピクセルの材質と幾何」だけを書き出す。
// 出力先は 2 枚のカラーアタッチメント(MRT)。深度は深度アタッチメントが自動で受け取る。

in vec3 v_normal;

uniform vec3 u_baseColor; // リニア空間の基本色(第22章の約束)
uniform float u_metallic;
uniform float u_roughness;
// 法線バッファが RGBA8 のときだけ true。符号付きの法線を 0〜1 に詰め直す(本文 4 節)
uniform bool u_packNormal;

// 出力が 2 つ以上あるときは、すべてに location を書かなければならない
// (GLSL ES 3.00 仕様 4.3.8.2「If there is more than one output, the location must be
//  specified for all outputs」)。location i が drawBuffers で指定した i 番目に対応する
layout(location = 0) out vec4 gBaseColor; // rgb: baseColor(リニア) / a: metallic
layout(location = 1) out vec4 gNormal; // rgb: ワールド法線 / a: roughness

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

  gBaseColor = vec4(u_baseColor, u_metallic);
  gNormal = vec4(u_packNormal ? N * 0.5 + 0.5 : N, u_roughness);
}
src/lessons/23-deferred-rendering/pbr.glsl
// 第22章の Cook-Torrance BRDF を、そのまま持ってきたチャンク。
// フォワードパス(forward.frag)とライティングパス(lighting.frag)の両方が
// `#include "pbr.glsl"` で差し込む(実体は main.ts の文字列置換)。
// 式の導出と意味は第22章の担当。D / G / F の 3 関数は第22章の pbr.frag から
// 1 文字も変えていない — それを見せるために 1 つのファイルにしてある。F0 と kD の作り方も
// 同じで、材質を uniform ではなく引数で受け取る点だけが違う。
// 変えたのは「どこで呼ぶか」と、その周りの光源ループ(方向光 1 灯 → 点光源の配列)だけ。

// 光源はプレーンな vec4 配列 2 本にパックして送る。struct の配列にすると
// 要素ごと・フィールドごとにロケーションを取ることになるが(第18章)、
// プレーンな配列なら配列名だけで先頭のロケーションが取れて uniform4fv で一括送信できる。
// 64 灯 × 2 = 128 vec4 で、MAX_FRAGMENT_UNIFORM_VECTORS の最小保証 224 の半分を超える
#define MAX_LIGHTS 64

// xyz = 光源のワールド座標 / w = 影響半径(これより遠いピクセルは計算しない)
uniform vec4 u_lightPositionRadius[MAX_LIGHTS];
// rgb = 光の色(リニア) / a = 強さ。第17章・第22章の u_lightColor(vec3・方向光 1 灯の色)とは
// 別物なので、パックしていることが分かる名前にしてある
uniform vec4 u_lightColorIntensity[MAX_LIGHTS];
uniform int u_lightCount;

const float PI = 3.141592653589793;

// D: 法線分布関数(GGX / Trowbridge-Reitz)
float distributionGGX(float NdotH, float roughness) {
  float a = roughness * roughness;
  float a2 = a * a;
  float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
  return a2 / (PI * d * d);
}

float geometrySchlickGGX(float NdotX, float k) {
  return NdotX / (NdotX * (1.0 - k) + k);
}

// G: 幾何減衰(Smith)。直接光の k = (roughness + 1)² / 8
float geometrySmith(float NdotV, float NdotL, float roughness) {
  float k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
  return geometrySchlickGGX(NdotV, k) * geometrySchlickGGX(NdotL, k);
}

// F: フレネル(Schlick 近似)
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
  // clamp するのは、丸め誤差で cosTheta が 1 をわずかに超えると
  // pow() の底が負になり、GLSL では結果が未定義になるため
  return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

/**
 * 1 点(N, V, worldPosition と材質)に、有効な光源をすべて足し込む。
 * フォワードパスは「三角形の表面の点」で、ライティングパスは「G-buffer から
 * 読み戻した点」で、まったく同じこの関数を呼ぶ。
 */
vec3 shadeLights(
  vec3 N, vec3 V, vec3 worldPosition, vec3 baseColor, float metallic, float roughness
) {
  vec3 F0 = mix(vec3(0.04), baseColor, metallic);
  float NdotV = max(dot(N, V), 0.0001); // 0 除算よけ
  vec3 color = vec3(0.0);

  for (int i = 0; i < u_lightCount; i++) {
    vec3 toLight = u_lightPositionRadius[i].xyz - worldPosition;
    float dist = length(toLight);
    float radius = u_lightPositionRadius[i].w;
    if (dist > radius) {
      // 影響半径の外。ディファードでもフォワードでも、ここで打ち切れる光源が
      // いちばん多い。「見えている点の近くの光源だけ計算する」は同じ発想の延長
      continue;
    }

    vec3 L = toLight / dist; // 面から光源へ向かう単位ベクトル(第17章の約束)
    float NdotL = dot(N, L);
    if (NdotL <= 0.0) {
      continue;
    }

    // 逆二乗 + 半径で 0 に落とす窓関数。第18章の (kc, kl, kq) を、
    // 「物理的な 1/d² に、有限の打ち切りを足したもの」に置き換えた形
    float window = clamp(1.0 - pow(dist / radius, 4.0), 0.0, 1.0);
    float attenuation = (window * window) / (dist * dist + 1.0);
    vec3 radiance = u_lightColorIntensity[i].rgb * u_lightColorIntensity[i].a * attenuation;

    vec3 H = normalize(L + V);
    float D = distributionGGX(max(dot(N, H), 0.0), roughness);
    float G = geometrySmith(NdotV, NdotL, roughness);
    vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);

    vec3 specular = D * G * F / (4.0 * NdotV * NdotL + 0.0001);
    // 金属は拡散反射を持たない。kD = (1 - F)(1 - metallic)
    vec3 kD = (1.0 - F) * (1.0 - metallic);

    color += (kD * baseColor / PI + specular) * radiance * NdotL;
  }

  return color;
}
src/lessons/23-deferred-rendering/lighting.frag
#version 300 es
precision highp float;

// ライティングパス: 全画面三角形(src/lib/fullscreen.vert)を 1 枚描き、
// G-buffer から材質と幾何を読み戻して光を計算する。
// 三角形も法線行列も出てこない。ここにあるのは「画面 1 ピクセルぶんの陰影」だけ。

// サンプラの既定精度は lowp なので、深度と法線を読むここでは highp を明示する(第21章 6 節)
uniform highp sampler2D u_gBaseColor; // COLOR_ATTACHMENT0: rgb = baseColor / a = metallic
uniform highp sampler2D u_gNormal; // COLOR_ATTACHMENT1: rgb = 法線 / a = roughness
uniform highp sampler2D u_gDepth; // DEPTH_ATTACHMENT: 窓座標の深度(0〜1)

uniform mat4 u_inverseViewProjection; // 位置の復元に使う(本文 5 節)
uniform vec3 u_cameraPosition;
uniform vec3 u_ambientColor;
uniform vec3 u_backgroundColor;
uniform bool u_packNormal; // 法線バッファが RGBA8 のとき true
uniform int u_debugView; // 0 = 合成 / 1 = baseColor / 2 = 法線 / 3 = metallic-roughness / 4 = 深度
uniform float u_depthViewRange; // 深度表示で白〜黒に割り当てる距離(メートル相当)

#include "pbr.glsl"

out vec4 fragColor;

void main() {
  // G-buffer は画面と同じ解像度なので、1 テクセル : 1 ピクセルで読める。
  // 補間もラップも要らないので texture() ではなく texelFetch(第16章)
  ivec2 texel = ivec2(gl_FragCoord.xy);
  float depth = texelFetch(u_gDepth, texel, 0).r;

  // 深度がクリア値(1.0)のままなら、そのピクセルには何も描かれていない = 背景。
  // ディファードでは「物体があったかどうか」も深度から分かる
  if (depth >= 1.0) {
    fragColor = vec4(u_backgroundColor, 1.0);
    return;
  }

  vec4 g0 = texelFetch(u_gBaseColor, texel, 0);
  vec4 g1 = texelFetch(u_gNormal, texel, 0);

  vec3 baseColor = g0.rgb;
  float metallic = g0.a;
  vec3 N = normalize(u_packNormal ? g1.rgb * 2.0 - 1.0 : g1.rgb);
  float roughness = g1.a;

  // --- 位置の復元(本文 5 節) ------------------------------------------------
  // 窓座標 → NDC → 逆ビュー射影 → 同次除算 → ワールド座標。
  // gl_FragCoord.xy はピクセル中心が .5 なので、そのまま割れば UV の中心になる
  vec2 uv = gl_FragCoord.xy / vec2(textureSize(u_gDepth, 0));
  vec4 ndc = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
  vec4 world = u_inverseViewProjection * ndc;
  vec3 worldPosition = world.xyz / world.w;

  // --- G-buffer の可視化 -------------------------------------------------------
  if (u_debugView == 1) {
    fragColor = vec4(pow(baseColor, vec3(1.0 / 2.2)), 1.0); // リニア → 表示
    return;
  }
  if (u_debugView == 2) {
    fragColor = vec4(N * 0.5 + 0.5, 1.0); // 向きであって色ではないのでガンマは掛けない
    return;
  }
  if (u_debugView == 3) {
    // glTF の occlusion-roughness-metallic と同じ並び(G = roughness, B = metallic)
    fragColor = vec4(0.0, roughness, metallic, 1.0);
    return;
  }
  if (u_debugView == 4) {
    // 窓座標の深度をそのまま出すとほぼ真っ白になる(遠くに精度が寄っている・第13章)。
    // 復元したワールド位置からカメラまでの距離を線形に割り当てて表示する
    float linear = clamp(distance(u_cameraPosition, worldPosition) / u_depthViewRange, 0.0, 1.0);
    fragColor = vec4(vec3(1.0 - linear), 1.0);
    return;
  }

  // --- 合成: フォワードとまったく同じ関数を呼ぶ --------------------------------
  vec3 V = normalize(u_cameraPosition - worldPosition);
  vec3 color = u_ambientColor * baseColor;
  color += shadeLights(N, V, worldPosition, baseColor, metallic, roughness);

  fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);
}
src/lessons/23-deferred-rendering/forward.frag
#version 300 es
precision highp float;

// 比較用のフォワードパス。物体を描きながら、その場でライティングまで済ませる。
// 第17〜18章と同じ構造で、BRDF だけ第22章の Cook-Torrance に差し替えたもの。

in vec3 v_normal;
in vec3 v_worldPosition;

uniform vec3 u_cameraPosition;
uniform vec3 u_ambientColor;
uniform vec3 u_baseColor;
uniform float u_metallic;
uniform float u_roughness;

#include "pbr.glsl"

out vec4 fragColor;

void main() {
  vec3 N = normalize(v_normal);
  vec3 V = normalize(u_cameraPosition - v_worldPosition);

  // IBL はこの章では使わない(第22章)。隙間が真っ黒にならない程度の定数を足すだけ
  vec3 color = u_ambientColor * u_baseColor;
  color += shadeLights(N, V, v_worldPosition, u_baseColor, u_metallic, u_roughness);

  // 計算はリニア空間、画面へ出す直前にガンマを掛ける(第22章の約束。体系は第27章)
  fragColor = vec4(pow(color, vec3(1.0 / 2.2)), 1.0);
}

three.js との対応

three.js にディファードレンダラーはありませんWebGLRendererはフォワードで、物体ごとに「そのシーンの光源の数」をシェーダーソースに埋め込んでコンパイルします (第18章で見たreplaceLightNums)。r185 のソースツリーを検索してもDeferredを名前に含むファイルは 1 つも見つかりません。一方で、ディファードを自分で組むための部品は揃っています

three.jsこの章
new THREE.WebGLRenderTarget(w, h, { count: 2 })createGBuffer(gl, width, height, normalFormat)countはカラーアタッチメントの本数で、既定は 1
renderTarget.textures[i]gbuffer.baseColor/gbuffer.normal。three.js はアタッチメントを配列で持ち、内部でCOLOR_ATTACHMENT0 + iに割り当てる
WebGLState.drawBuffers()(内部)gl.drawBuffers([...])。three.js は毎フレーム状態を比較し、変わったときだけ呼び直している
WebGLMultipleRenderTargetsr162 で削除された旧 API。いまはcountに一本化されている
glslVersion: THREE.GLSL3この章のシェーダーはすべて#version 300 es。three.js で複数のoutを書くには GLSL3 の指定が要る。指定しないとWebGLProgramlayout(location = 0) out highp vec4 pc_fragColor;を勝手に足すので、location 0 が埋まってしまう
renderTarget.depthTexturegbuffer.depth。深度をレンダーバッファではなくテクスチャで受け取る構成
EffectComposerRenderPass/ShaderPassこの章の 2 パス構成を一般化したもの。FBO のチェーンとして自作するのは第33章
THREE.WebGPURendererWebGLRendererの後継として用意されているノードベースのレンダラー(WebGPU が使えなければ WebGL2 バックエンドに落ちる)。MRT の作例はwebgpu_mrtとして用意されているが、ディファードレンダラー本体は含まれない(r185 のソースツリーにDeferredという名前のファイルは 1 つもない)

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

コードはsrc/lessons/23-deferred-rendering/にあります。この章の失敗は「エラーは出ないのに、絵の一部だけがおかしい」という顔をしているので、 症状と原因の対応を先に体験しておくと効きます。

まとめ

これで第3部が終わります。第11章のベクトルと行列から始めて、座標変換、深度テスト、ジオメトリ 生成、カメラ、テクスチャ、ライティング、モデル読み込み、フレームバッファ、影、PBR、そして この章のディファードまで —ラスタライズパイプラインの主要な部品はひととおり自分の手で書きました。three.js がrenderer.render(scene, camera)の 1 行の裏でやっていることの地図は、もう手元にあるはずです。

次章第24章「2D SDF — 距離場で形を合成する」から、第4部が始まります。三角形も 頂点バッファも出てきません。第2部で作った全画面シェーダーの上に戻り、「この点は形の表面からどれだけ離れているか」を返す関数だけで形を描きます。第8章でlength(p) - rと書いて円を出したあの 1 行が、実は距離関数(signed distance function)という体系の入口でした。minで形を足し、maxで削り、なめらかな最小値で溶かし、modで無限に敷き詰める。ポリゴンでは面倒な操作が数行で書ける世界を、次章から見ていきます。