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

フレームバッファ — オフスクリーン描画とレンダーターゲット

第3章から第19章まで、描いたものはすべて同じ場所へ行きました — canvas の描画バッファです。drawArraysdrawElementsを呼べば、結果は画面に出る。あたりまえのことのようですが、これは「描画先が最初から 1 つ用意されていて、それが選ばれたままだった」というだけの話です。この章では、その描画先を自分で用意したテクスチャに差し替えます

差し替えて何が嬉しいのかというと、描いた結果をもう一度読めることです。画面に 出してしまった絵は、それ以上どうにもできません。しかしテクスチャに描いたなら、それは第16章で 学んだただの画像なので、シェーダーからサンプリングして加工できます。ぼかす、光らせる、歪める — ポストプロセス(第33章)はここから始まります。影(第21章)も、複数の出力を持つディファード レンダリング(第23章)も、前フレームを読むフィードバック(第29章)も、全部この章の道具の上に 乗っています。three.js でいえばWebGLRenderTargetrenderer.setRenderTarget()にあたる部分を自作する章です。

3D シーン(床・回転するトーラス・その周りを回る球)を、まずオフスクリーンのテクスチャへ描き、 そのテクスチャを画面上の板に貼っています。板そのものもゆっくり首を振るので、映っている絵が 「板に貼られた 1 枚の画像」であることが分かります。「画面に直接描く」に切り替えると、同じ シーンが FBO を経由せずそのまま画面に出ます。「FBO の深度: なし」にするとオフスクリーン側だけ前後関係が壊れます(3 節)。「FBO 解像度」は、オフスクリーンの解像度が画面と独立して選べることの確認です(1/4 にするとピクセル数は 1/16 になります)。読み出し行には FBO のサイズ・アタッチメントの構成と、checkFramebufferStatusの結果が出ています(4 節)。

この章で学ぶこと:

1. 描画先を差し替える

これまでの描画先には名前があります。既定のフレームバッファ (default framebuffer)です。canvas からコンテキストを取得した時点で用意されていて、色の領域を持ち、第2章のコンテキスト属性depth: true(既定)のおかげで深度の領域も持っています。第13章で深度テストが 何もせずに効いたのは、この既定の深度領域があったからでした。

これに対して、自分で作る描画先がフレームバッファオブジェクト (FBO)です。名前に反して、FBO 自体はピクセルを 1 つも持ちません。「どの画像に書き込むか」を並べておくだけの、空の入れ物です。実際にピクセルを持つのは、そこに取り付けるアタッチメントのほう — テクスチャやレンダーバッファです。この構造は次の節で見ます。

描画先の切り替えは 1 行です。第3章から親しんできた「バインドして状態を切り替える」やり方が、 ここでも同じように出てきます。

描画先の切り替え(イメージ)
// 描画先を自作の FBO にする
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);

// 画面(既定のフレームバッファ)へ戻す。null がその合図
gl.bindFramebuffer(gl.FRAMEBUFFER, null);

nullを渡すと既定のフレームバッファに戻ります。以降のcleardrawElementsも、いまバインドされている描画先に対して働きます。状態機械らしい、素直な設計です。

2. アタッチメント — テクスチャとレンダーバッファ

FBO にはアタッチメントポイントという取り付け口が並んでいます。この章で使うのは 2 つです。

そして、それぞれの口に取り付けられるものが 2 種類あります。テクスチャレンダーバッファ (renderbuffer)です。レンダーバッファは、この章で初めて出てくるオブジェクトです。テクスチャとの違いは ひとことで言えます —レンダーバッファは描き込み専用で、シェーダーから読めません。 サンプリングできない代わりに、フィルタもラップもミップマップも持たない、描画先に特化した 単純な画素の置き場です。

フレームバッファオブジェクト画素は持たない「取り付け口の束」COLOR_ATTACHMENT0DEPTH_ATTACHMENTテクスチャ(RGBA8)あとでシェーダーから読めるframebufferTexture2Dレンダーバッファ(DEPTH_COMPONENT24)描き込み専用。読めないframebufferRenderbufferアタッチメントの幅と高さは、すべて同じでなければならない(WebGL2)
FBO とアタッチメントの関係。FBO は取り付け口の束にすぎず、実際に画素を持つのは テクスチャとレンダーバッファのほうです。

どちらを使うかの判断は単純で、あとで読むかどうかだけで決まります。

付けるもの向いている場面
テクスチャ結果をシェーダーで読み返す。この章のカラー、シャドウマップの深度(第21章)、G-buffer の各出力(第23章)など。フィルタ・ラップの設定を持てる
レンダーバッファ描画中は要るが、あとで読まない。典型は深度・ステンシル。読めない代わりに素直で、 マルチサンプル(MSAA)を持てるのもこちらだけです

まずカラー側から作ります。テクスチャの作り方そのものは第16章のままで、違うのは「中身を アップロードしない」ことだけです。これから GPU が書き込む場所なので、置き場だけ確保します。

src/lib/framebuffer.ts(抜粋)
const texture = gl.createTexture();
// bindTexture は「いま選ばれているテクスチャユニット」に効く(第16章)。
// 呼び出し元がどのユニットを選んでいても結果が変わらないよう、作業台を 0 番に固定する
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
// texStorage2D はサイズと形式が固定(immutable)な領域を 1 回で確保する(第16章)。
// レベル数 1 = ミップマップなし。中身は 0 で初期化される
gl.texStorage2D(gl.TEXTURE_2D, 1, resolved.internalFormat, width, height);
// 不変ストレージのテクスチャは、宣言したレベル数までが「全部」なので、levels = 1 でも
// ミップマップ完備として扱われる(OpenGL ES 3.0 §3.8.10.4: 完備判定の最大レベルが
// levels - 1 にクランプされる)。つまり MIN_FILTER が既定の NEAREST_MIPMAP_LINEAR でも
// 第16章のような「真っ黒」にはならない。それでも明示するのは、既定のままだと縮小時に
// 段の中が最近傍で読まれ、意図しないギザギザが出るため
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, resolved.filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, resolved.filter);
// 画面に貼り直す用途では端の折り返しは邪魔にしかならないので CLAMP_TO_EDGE
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);

そして FBO を作り、バインドして、テクスチャをCOLOR_ATTACHMENT0に取り付けます。最後の引数0はミップマップのレベル番号で、レベル 0(いちばん大きい画像)に描く、という意味です。

src/lib/framebuffer.ts(抜粋)
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);

3. 深度アタッチメントは自分で用意する

第13章の最後に、こう書きました — 「第20章で自作のフレームバッファ (FBO) を作るときには、この深度領域も自分でアタッチすることになります」。その回収です。

深度は読み返さないので、レンダーバッファを使います。作り方はテクスチャとよく似ていて、renderbufferStorageで「この形式でこのサイズの領域を確保する」と宣言し、framebufferRenderbufferで取り付けるだけです。

src/lib/framebuffer.ts(抜粋)
let depth: WebGLRenderbuffer | null = null;
if (resolved.depth) {
  depth = gl.createRenderbuffer();
  gl.bindRenderbuffer(gl.RENDERBUFFER, depth);
  // DEPTH_COMPONENT24 は OpenGL ES 3.0 がレンダーバッファでのサポートを必須にしている形式
  gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, width, height);
  gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth);
  gl.bindRenderbuffer(gl.RENDERBUFFER, null);
}

内部フォーマットに選んだDEPTH_COMPONENT24は、OpenGL ES 3.0 がレンダーバッファでのサポートを必須と定めている形式のひとつです(ほかにDEPTH_COMPONENT16DEPTH_COMPONENT32F、深度とステンシルを まとめたDEPTH24_STENCIL8/DEPTH32F_STENCIL8、そしてSTENCIL_INDEX8があります)。16 ビットでも動きますが、第13章で触れた深度の精度の話がそのまま効くので、 奥行きのあるシーンでは 24 ビットのほうが安全です。

4. completeness を必ず確かめる

アタッチメントの組み合わせには制約があります。条件を満たした FBO を完全 (framebuffer complete)といい、満たさない FBO への描画や読み取りはINVALID_FRAMEBUFFER_OPERATIONエラーになります。ここでもJavaScript の例外は飛びません。第3章のシェーダー コンパイルとまったく同じ構図で、自分から状態を問い合わせない限り、症状は「何も描かれない」 だけです。

src/lib/framebuffer.ts(抜粋・簡略化)
// 不完全な FBO への描画は INVALID_FRAMEBUFFER_OPERATION になるだけで例外は飛ばない。
// 「何も描かれない」という症状だけが残るので、作った直後に必ず確かめる
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
  throw new Error(`フレームバッファが不完全です: ${framebufferStatusName(gl, status)}`);
}

戻り値は数値の定数です。主なものの意味を並べます。

戻り値意味と、よくある原因
FRAMEBUFFER_COMPLETE使える。これ以外はすべて描画できない状態
FRAMEBUFFER_INCOMPLETE_ATTACHMENT付いている画像のどれかが条件を満たしていない。幅か高さが 0、あるいはその内部フォーマットが描画先として使えない(color-renderable でない)。拡張なしでRGBA16Fを付けたときがこれです(8 節)
FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT何も付いていない。FBO を作ってバインドしただけでframebufferTexture2Dを呼び忘れた、という取り違えが典型
FRAMEBUFFER_INCOMPLETE_DIMENSIONSアタッチメントどうしのサイズが揃っていない。WebGL 固有の条件です(下記)
FRAMEBUFFER_INCOMPLETE_MULTISAMPLEマルチサンプルのサンプル数が揃っていない。テクスチャは常にサンプル数 0 扱いなので、MSAA レンダーバッファとテクスチャを同じ FBO に混ぜるとこれになります
FRAMEBUFFER_UNSUPPORTED実装がその組み合わせをサポートしていない。深度とステンシルに別々の画像を付けた場合や、 同じ画像を 2 つのカラーアタッチメントに付けた場合もこれです

5. render-to-texture の一巡と、viewport

道具が揃ったので、1 フレームの流れをまとめます。

パス 1: オフスクリーンbindFramebuffer(FRAMEBUFFER, fbo)viewport(0, 0, fboW, fboH)clear(COLOR | DEPTH)3D シーンを描くカラーテクスチャ画面には出ていない1 枚の画像として残るサンプリングするパス 2: 画面へbindFramebuffer(FRAMEBUFFER, null)viewport(0, 0, canvasW, canvasH)clear → テクスチャを貼って描く既定のフレームバッファ= canvas に見える絵
描画先の付け替え。オレンジの 2 行がviewportで、描画先を切り替えるたびに必ずセットで書き換えます。

デモの描画ループが、そのままこの図の形をしています。

src/lessons/20-framebuffers/main.ts(抜粋)
if (mode === 'direct') {
  // 比較用: FBO を経由せず、既定のフレームバッファへそのまま描く
  bindRenderTarget(gl, null);
  gl.clearColor(0.06, 0.07, 0.09, 1.0);
  drawScene(time, gl.drawingBufferWidth, gl.drawingBufferHeight);
} else {
  // パス 1: オフスクリーン。bindRenderTarget が viewport も FBO のサイズに合わせる
  bindRenderTarget(gl, target);
  // FBO のクリア色だけ少し明るくして、画面上で板の範囲が分かるようにしている
  gl.clearColor(0.1, 0.11, 0.14, 1.0);
  drawScene(time, target.width, target.height);

  // パス 2: 画面へ戻して、パス 1 の結果を板に貼る
  bindRenderTarget(gl, null);
  gl.clearColor(0.06, 0.07, 0.09, 1.0);
  drawPanel(time);
}

もう 1 つ、忘れやすいのが射影行列のアスペクト比です。第3部の約束ではgl.drawingBufferWidth / gl.drawingBufferHeightから計算していましたが、これは「描画先 = canvas」を前提にした式でした。FBO へ描くパスでは、アスペクト比もFBO の幅と高さから計算しなければなりません。この章のデモでは、シーンを描く関数が 描画先のサイズを引数で受け取るようにして、両方のパスで使い回しています。

src/lessons/20-framebuffers/main.ts(抜粋)
// 3D シーンを、いまバインドされている描画先に描く。
// アスペクト比は「描画先の幅 / 高さ」— FBO のときは canvas のサイズではない
function drawScene(time: number, width: number, height: number): void {
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

  gl.useProgram(sceneProgram);
  mat4.lookAt(view, SCENE_EYE, ORIGIN, UP);
  mat4.perspective(projection, FOVY, width / height, NEAR, FAR);

パス 2 でテクスチャを使う側は、第16章のとおりです。FBO のカラーアタッチメントだったテクスチャも、読むときはただの画像でしかありません。

src/lessons/20-framebuffers/main.ts(抜粋)
// テクスチャユニット 0 に FBO のカラーテクスチャを載せる(第16章)。
// このとき同じテクスチャが描画先になっていないことが重要(前フレーム参照は第29章)
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, target.texture);
gl.uniform1i(panelTextureLocation, 0);

UV の向きも、この組み合わせでは何もしなくて合います。createPlane(第14章)の v は下が 0 で、フラグメントシェーダーが書き込む座標系(gl_FragCoord)も左下原点 だからです。画像ファイルを読み込むときに出てくる上下反転の問題(第16章)は、ここでは起きません。

6. 3 度目の共通化 — src/lib/framebuffer.ts

ここまでで新しい API は出尽くしました。createFramebuffer/bindFramebuffer/framebufferTexture2D/createRenderbuffer/renderbufferStorage/framebufferRenderbuffer/checkFramebufferStatusの 7 つです。そして第6章で決めたルールは「読者がまだ中身を知らないコードは、隠さない」でした。7 つとも、いま自分の手で一通り書いたので、隠しても失われるものはありません。第6章の 「この先の共通化の予定」で第20章のフレームバッファヘルパーを予告していた、その回収でもあります (第13〜14章のgeometry.tsに続いて 3 度目です)。

共通化する理由は、この章の場合はっきりしています。

そこで、FBO とアタッチメントをひとまとめにしたレンダーターゲット (render target)という型を置きます。three.js のWebGLRenderTargetと同じ考え方の、同じ名前です。

src/lib/framebuffer.ts(抜粋)
export interface RenderTarget {
  /** 描画先を差し替えるための FBO 本体 */
  framebuffer: WebGLFramebuffer;
  /** COLOR_ATTACHMENT0 に付いているテクスチャ。描き終えたらこれをサンプリングする */
  texture: WebGLTexture;
  /** DEPTH_ATTACHMENT のレンダーバッファ。options.depth が false のときは null */
  depth: WebGLRenderbuffer | null;
  /** アタッチメントの幅(ピクセル)。viewport とアスペクト比はこの値から決める */
  width: number;
  /** アタッチメントの高さ(ピクセル) */
  height: number;
  /** 既定値を埋めた生成時のオプション。resizeRenderTarget が同じ設定で作り直すために持つ */
  options: Required<RenderTargetOptions>;
}

幅と高さを持たせているのが要点です。viewport にも射影行列のアスペクト比にも、この 2 つが要ります。そして描画先の切り替えは、次の 1 つの関数に集約します。

src/lib/framebuffer.ts(抜粋)
export function bindRenderTarget(gl: WebGL2RenderingContext, target: RenderTarget | null): void {
  if (target === null) {
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    return;
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
  gl.viewport(0, 0, target.width, target.height);
}

nullを渡したときにgl.drawingBufferWidth/gl.drawingBufferHeightを使っているのは、第2章以来の「viewport は描画バッファのピクセルで指定する。CSS サイズではない」という約束をそのまま守るためです。canvas の要素を引数で受け取る必要はありません。

7. リサイズ — 作り直して、古いものを消す

canvas の表示サイズが変わったら、FBO のテクスチャも作り直す必要があります。「変える」ではなく「作り直す」なのは、texStorage2Dで確保したテクスチャもrenderbufferStorageで確保したレンダーバッファも、後からサイズを変えられないからです。やることは 2 つ — 新しいサイズでもう 1 つ作ってから、古いほうを消す

src/lib/framebuffer.ts(抜粋)
export function resizeRenderTarget(
  gl: WebGL2RenderingContext,
  target: RenderTarget,
  width: number,
  height: number,
): RenderTarget {
  if (target.width === width && target.height === height) {
    return target;
  }
  // 先に作り、成功してから古いものを消す。逆順にすると、作成に失敗したときに
  // 呼び出し側が「解放済みのレンダーターゲット」を握ったまま残ってしまう
  // (WebGL は解放済みハンドルを黙って無視するので、原因の分かりにくい不具合になる)
  const next = createRenderTarget(gl, width, height, target.options);
  deleteRenderTarget(gl, target);
  return next;
}

この順番には理由があります。逆にして「消してから作る」と、createRenderTargetが途中で失敗して例外を投げたとき、呼び出し側には解放済みのレンダーターゲットだけが残ります。WebGL は削除済みのハンドルを渡されても黙って無視するので、症状は 「エラーは出ないのに何も描かれない」という、この章でいちばん追いにくい形になります。先に作れば、 失敗しても古いほうが生き残り、呼び出し側の変数はそのまま使い続けられます — 小さいですが、 これは例外安全の考え方そのものです。

サイズが同じなら何もせずに返すのが大事なところです。毎フレーム作り直してはいけません。 FBO・テクスチャ・レンダーバッファの生成は GPU のメモリ確保を伴う重い操作で、毎フレーム捨てて作れば、そのぶん確実に遅くなります。第4章以来の 「ループの先頭でサイズを確認し、変わっていたときだけ作り直す」という型が、そのまま使えます。

src/lessons/20-framebuffers/main.ts(抜粋)
// 深度の有無やサイズが変わったら作り直す。古いものは必ず解放する
function updateRenderTarget(): void {
  const width = targetWidth();
  const height = targetHeight();
  if ((target.depth !== null) !== depthEnabled) {
    // オプションが変わるので resize では足りない。自分で消してから作り直す
    deleteRenderTarget(gl, target);
    target = createRenderTarget(gl, width, height, { depth: depthEnabled });
    statusName = checkStatus();
    return;
  }
  // サイズが同じなら渡した target がそのまま返り、違えば作り直した別物が返る
  // (古いほうは中で解放済み)。だから戻り値は必ず受け取り直す。
  // 「別物が返ってきた = 作り直した」なので、ステータスの問い合わせもそのときだけでよい
  const resized = resizeRenderTarget(gl, target, width, height);
  if (resized !== target) {
    target = resized;
    statusName = checkStatus();
  }
}

呼び出し側が戻り値を受け取り直しているのも、この作り直しがあるからです。別のオブジェクトが 返ってきたということは「作り直した = 渡したほうはもう解放済み」なので、古い参照を握り続けると 存在しないテクスチャを貼ることになります。逆に同じオブジェクトが返ってきたなら何も起きて いないので、checkFramebufferStatusの問い合わせもそのときは要りません。

そして作り直したら必ず古いものを消します。このサイトではこれまで GPU リソースの解放を第35章送りにしてきましたが、この章は事情が違います。リサイズのたびに新しい FBO を作る以上、消さなければ、ウィンドウをドラッグでリサイズしただけでテクスチャが 何十枚も GPU に積み上がるからです。

src/lib/framebuffer.ts(抜粋)
/** FBO・テクスチャ・レンダーバッファをまとめて解放する。作り直すときは必ず先に呼ぶ */
export function deleteRenderTarget(gl: WebGL2RenderingContext, target: RenderTarget): void {
  gl.deleteFramebuffer(target.framebuffer);
  gl.deleteTexture(target.texture);
  if (target.depth !== null) {
    gl.deleteRenderbuffer(target.depth);
  }
}

8. float バッファへ描くには拡張が要る

この章のカラーアタッチメントはRGBA8— 1 成分 8 ビット、0〜1 に収まる値です。ところが計算の途中結果を残したい用途では、これでは 足りないことがあります。1.0 を超える明るさ(HDR)を保ちたいブルーム(第33章)、位置や速度を そのまま格納したいフィードバック(第29章)などです。そこでRGBA16F(半精度 float)やRGBA32F(単精度 float)を使いたくなります。

ここに、WebGL2 で最も間違われやすい線引きがあります。float テクスチャを「読む」のは WebGL2 の標準機能ですが、「描く」のは標準機能ではありません。OpenGL ES 3.0 の内部フォーマット表(Table 3.13)では、RGBA16FRGBA32Fcolor-renderable ではないと定められています。カラーアタッチメントに使えるのは color-renderable な形式だけなので、そのまま付ければ 4 節のFRAMEBUFFER_INCOMPLETE_ATTACHMENTになります。

これを可能にするのが拡張EXT_color_buffer_floatです。有効にするとR16F/RG16F/RGBA16F/R32F/RG32F/RGBA32F/R11F_G11F_B10Fが color-renderable になります(RGB16F含まれないことが拡張仕様に明記されています)。ほかの拡張と同じくgetExtensionで有効化し、返り値がnullなら諦めて別の形式にフォールバックします。

使い方(イメージ)
// float テクスチャを「読む」のは WebGL2 の標準機能だが、
// 「描く」= カラーアタッチメントにするには拡張が要る
const hasFloatColor = gl.getExtension('EXT_color_buffer_float') !== null;
const target = createRenderTarget(gl, width, height, {
  internalFormat: hasFloatColor ? gl.RGBA16F : gl.RGBA8,
});

半精度と単精度の選び分けは、ふつう半精度で足ります。

形式性質
RGBA8拡張不要(WebGL2 のコア)。1 成分 8 ビット・0〜1。迷ったらこれ。この章のデモもこれです
RGBA16F1 成分 16 ビット。メモリと帯域はRGBA32Fの半分で、HDR には十分な精度。LINEARフィルタも WebGL2 のコアで使えます
RGBA32F1 成分 32 ビット。位置や速度を誤差なく置きたいとき。ただしリニアフィルタは別の拡張(OES_texture_float_linear)が必要で、無いときはNEARESTしか使えません

9. ここから先 — ポストプロセスの入口

この章のデモは、オフスクリーンの結果を 3D の板に貼りました。しかし「板」である必然性はどこにもありません。第6章で作った全画面三角形に同じテクスチャを貼れば、画面いっぱいにそのまま出せます。そして貼るときのフラグメント シェーダーは自由に書けるので、そこで色を変えたりぼかしたりできます。

これがポストプロセス (post-processing)です。3D のシーンを一度テクスチャにしてしまえば、あとは第2部でやってきた 2D の画像処理の世界に持ち込める、という筋道です。具体的なエフェクト — ブルーム、分離可能 ガウシアン、FXAA、色収差 — は第33章でまとめて扱います。この章で用意したのは、その全部が 立つ土台のほうです。

この土台の上に建つものを、先に挙げておきます。

コード全文

この章で導入した共通ヘルパーと、それを使うデモの 5 ファイルです。第6章・第14章と同じく、src/lib/に置くコードも全文を掲載します — 隠すためではなく、繰り返し書かないための共通化だからです。

scene.vert/scene.fragは第17章の方向光 1 灯の Blinn-Phong をそのまま使っています。この章の主題は「どこに描くか」なので、陰影は前後関係が読み取れれば 十分という判断です。

src/lib/framebuffer.ts(この章で導入した共通ヘルパー)
// 共通モジュール: レンダーターゲット(FBO)の生成と切り替え(第20章で導入)
//
// 「フレームバッファオブジェクトを作る → カラーテクスチャを作ってアタッチする →
//  深度レンダーバッファを作ってアタッチする → completeness を確かめる」という、
// オフスクリーン描画のたびにまったく同じ 20 行あまりを 1 か所にまとめたもの。
// 中身の API は第20章の本文で 1 つずつ生のまま書いてから、ここへ移している。
//
// 使う側の約束:
// - 描画先の切り替えは bindRenderTarget() を通す(viewport の切り替え忘れを防ぐため)
// - サイズが変わったら resizeRenderTarget() で作り直す(毎フレームではなく、変わったときだけ)
// - 要らなくなったら deleteRenderTarget() で GPU 上のリソースを解放する
//
// 第21章で DepthTarget(カラーを持たず、深度だけをテクスチャに描く FBO)を追加した。
// シャドウマップのように「深度を読み返したい」場合は RenderTarget ではなくこちらを使う。

export interface RenderTargetOptions {
  /** 深度レンダーバッファをアタッチするか(既定 true)。false にすると FBO 内で深度テストが効かない */
  depth?: boolean;
  /**
   * カラーテクスチャの拡大・縮小フィルタ(既定 gl.LINEAR)。gl.NEAREST でドット感を残せる。
   * ミップマップは作らないので、指定できるのは gl.NEAREST か gl.LINEAR だけ
   * (MAG_FILTER にミップ系の値を渡すと INVALID_ENUM になる)
   */
  filter?: GLenum;
  /**
   * カラーテクスチャのサイズ付き内部フォーマット(既定 gl.RGBA8)。
   * gl.RGBA16F / gl.RGBA32F へ「描く」には拡張 EXT_color_buffer_float が要る(第20章 8 節)
   */
  internalFormat?: GLenum;
}

export interface RenderTarget {
  /** 描画先を差し替えるための FBO 本体 */
  framebuffer: WebGLFramebuffer;
  /** COLOR_ATTACHMENT0 に付いているテクスチャ。描き終えたらこれをサンプリングする */
  texture: WebGLTexture;
  /** DEPTH_ATTACHMENT のレンダーバッファ。options.depth が false のときは null */
  depth: WebGLRenderbuffer | null;
  /** アタッチメントの幅(ピクセル)。viewport とアスペクト比はこの値から決める */
  width: number;
  /** アタッチメントの高さ(ピクセル) */
  height: number;
  /** 既定値を埋めた生成時のオプション。resizeRenderTarget が同じ設定で作り直すために持つ */
  options: Required<RenderTargetOptions>;
}

/**
 * checkFramebufferStatus の戻り値を、そのまま読める名前に変換する。
 * 数値のまま投げられても原因が分からないので、エラーメッセージと読み出し行で使う
 */
export function framebufferStatusName(gl: WebGL2RenderingContext, status: GLenum): string {
  switch (status) {
    case gl.FRAMEBUFFER_COMPLETE:
      return 'FRAMEBUFFER_COMPLETE';
    case gl.FRAMEBUFFER_INCOMPLETE_ATTACHMENT:
      return 'FRAMEBUFFER_INCOMPLETE_ATTACHMENT';
    case gl.FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT:
      return 'FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT';
    case gl.FRAMEBUFFER_INCOMPLETE_DIMENSIONS:
      return 'FRAMEBUFFER_INCOMPLETE_DIMENSIONS';
    case gl.FRAMEBUFFER_INCOMPLETE_MULTISAMPLE:
      return 'FRAMEBUFFER_INCOMPLETE_MULTISAMPLE';
    case gl.FRAMEBUFFER_UNSUPPORTED:
      return 'FRAMEBUFFER_UNSUPPORTED';
    default:
      return `不明なステータス(0x${status.toString(16)})`;
  }
}

/**
 * オフスクリーン描画用の FBO を 1 つ作る。
 * カラーは常にテクスチャ(後で読むため)、深度はレンダーバッファ(読まないため)。
 * 完成しなかった場合は原因の分かるエラーを投げ、作りかけのリソースは解放する
 */
export function createRenderTarget(
  gl: WebGL2RenderingContext,
  width: number,
  height: number,
  options: RenderTargetOptions = {},
): RenderTarget {
  if (width < 1 || height < 1) {
    // 幅か高さが 0 のアタッチメントは completeness に落ちる(仕様上「非ゼロ」が条件)。
    // その手前で、サイズ計算の誤りだと分かるエラーにしておく
    throw new Error(`レンダーターゲットのサイズは 1 以上にしてください(${width}×${height})`);
  }
  const resolved: Required<RenderTargetOptions> = {
    depth: options.depth ?? true,
    filter: options.filter ?? gl.LINEAR,
    internalFormat: options.internalFormat ?? gl.RGBA8,
  };

  // --- カラーアタッチメント: テクスチャ -------------------------------------
  const texture = gl.createTexture();
  // bindTexture は「いま選ばれているテクスチャユニット」に効く(第16章)。
  // 呼び出し元がどのユニットを選んでいても結果が変わらないよう、作業台を 0 番に固定する
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, texture);
  // texStorage2D はサイズと形式が固定(immutable)な領域を 1 回で確保する(第16章)。
  // レベル数 1 = ミップマップなし。中身は 0 で初期化される
  gl.texStorage2D(gl.TEXTURE_2D, 1, resolved.internalFormat, width, height);
  // 不変ストレージのテクスチャは、宣言したレベル数までが「全部」なので、levels = 1 でも
  // ミップマップ完備として扱われる(OpenGL ES 3.0 §3.8.10.4: 完備判定の最大レベルが
  // levels - 1 にクランプされる)。つまり MIN_FILTER が既定の NEAREST_MIPMAP_LINEAR でも
  // 第16章のような「真っ黒」にはならない。それでも明示するのは、既定のままだと縮小時に
  // 段の中が最近傍で読まれ、意図しないギザギザが出るため
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, resolved.filter);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, resolved.filter);
  // 画面に貼り直す用途では端の折り返しは邪魔にしかならないので CLAMP_TO_EDGE
  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);
  // ユニット 0 のバインドを外して返す。描画前には呼び出し側が
  // activeTexture + bindTexture でバインドし直すこと(第16章)
  gl.bindTexture(gl.TEXTURE_2D, null);

  // --- FBO を作ってアタッチする ---------------------------------------------
  const framebuffer = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);

  // --- 深度アタッチメント: レンダーバッファ ---------------------------------
  // 既定フレームバッファの深度はコンテキスト属性 depth が用意してくれるが(第2章)、
  // FBO の深度は誰も用意してくれない。付けなければ深度テストは素通りする(第13・20章)
  let depth: WebGLRenderbuffer | null = null;
  if (resolved.depth) {
    depth = gl.createRenderbuffer();
    gl.bindRenderbuffer(gl.RENDERBUFFER, depth);
    // DEPTH_COMPONENT24 は OpenGL ES 3.0 がレンダーバッファでのサポートを必須にしている形式
    gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, width, height);
    gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth);
    gl.bindRenderbuffer(gl.RENDERBUFFER, null);
  }

  // --- completeness チェック -------------------------------------------------
  // 不完全な FBO への描画は INVALID_FRAMEBUFFER_OPERATION になるだけで例外は飛ばない。
  // 「何も描かれない」という症状だけが残るので、作った直後に必ず確かめる
  const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null); // 描画先は既定のフレームバッファへ戻しておく
  if (status !== gl.FRAMEBUFFER_COMPLETE) {
    deleteRenderTarget(gl, { framebuffer, texture, depth, width, height, options: resolved });
    const hint =
      status === gl.FRAMEBUFFER_INCOMPLETE_ATTACHMENT
        ? '(内部フォーマットが color-renderable でない可能性があります。' +
          'float バッファには拡張 EXT_color_buffer_float が要ります)'
        : '';
    throw new Error(
      `フレームバッファが不完全です: ${framebufferStatusName(gl, status)} ` +
        `— ${width}×${height}, internalFormat=0x${resolved.internalFormat.toString(16)}${hint}`,
    );
  }

  return { framebuffer, texture, depth, width, height, options: resolved };
}

/**
 * 描画先を切り替える。target に null を渡すと画面(既定フレームバッファ)へ戻る。
 * viewport も一緒に切り替えるのがこの関数の存在意義 —
 * viewport は FBO のバインドでは変わらない GL の状態で、切り替え忘れが最頻出の罠
 */
export function bindRenderTarget(gl: WebGL2RenderingContext, target: RenderTarget | null): void {
  if (target === null) {
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    return;
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
  gl.viewport(0, 0, target.width, target.height);
}

/**
 * サイズを変える。テクスチャもレンダーバッファもサイズは後から変えられないので、
 * 中身は「同じオプションで作り直す」だけ。サイズが同じなら何もせず、渡された target を返す。
 *
 * **戻り値を必ず受け取り直すこと**。サイズが変わった場合、渡した target は解放済みになる。
 */
export function resizeRenderTarget(
  gl: WebGL2RenderingContext,
  target: RenderTarget,
  width: number,
  height: number,
): RenderTarget {
  if (target.width === width && target.height === height) {
    return target;
  }
  // 先に作り、成功してから古いものを消す。逆順にすると、作成に失敗したときに
  // 呼び出し側が「解放済みのレンダーターゲット」を握ったまま残ってしまう
  // (WebGL は解放済みハンドルを黙って無視するので、原因の分かりにくい不具合になる)
  const next = createRenderTarget(gl, width, height, target.options);
  deleteRenderTarget(gl, target);
  return next;
}

/** FBO・テクスチャ・レンダーバッファをまとめて解放する。作り直すときは必ず先に呼ぶ */
export function deleteRenderTarget(gl: WebGL2RenderingContext, target: RenderTarget): void {
  gl.deleteFramebuffer(target.framebuffer);
  gl.deleteTexture(target.texture);
  if (target.depth !== null) {
    gl.deleteRenderbuffer(target.depth);
  }
}

// ---------------------------------------------------------------------------
// 深度ターゲット(第21章で追加)
//
// シャドウマップは「カラーを 1 枚も持たず、深度だけをテクスチャに描く FBO」。
// createRenderTarget はカラーテクスチャ必須・深度はレンダーバッファ(= 読めない)
// なので、この用途にはそのまま使えない。深度アタッチメントをレンダーバッファから
// テクスチャに差し替えたものを、別の型として足している。
// ---------------------------------------------------------------------------

export interface DepthTargetOptions {
  /**
   * 深度のサイズ付き内部フォーマット(既定 gl.DEPTH_COMPONENT24)。
   * gl.DEPTH_COMPONENT16 / gl.DEPTH_COMPONENT32F も OpenGL ES 3.0 の必須形式
   */
  internalFormat?: GLenum;
  /**
   * ハードウェアの深度比較を有効にするか(既定 false)。
   *
   * false: TEXTURE_COMPARE_MODE は NONE のまま。GLSL 側は sampler2D で読み、
   *        記録された深度が vec4(depth, 0, 0, 1) の r 成分で返る。比較は自分で書く
   * true : TEXTURE_COMPARE_MODE = COMPARE_REF_TO_TEXTURE + TEXTURE_COMPARE_FUNC = LEQUAL。
   *        GLSL 側は sampler2DShadow で読み、texture() が比較結果(0.0 / 1.0)を返す。
   *        比較モードのときだけ深度テクスチャでも LINEAR が許され、複数テクセルの
   *        比較結果が混ぜられうる(= 2×2 の PCF)。ただし実際に補間するかどうかは
   *        実装依存で、仕様は保証していない(第21章 6 節)
   *
   * 同じテクスチャを「比較モードのまま sampler2D で読む」「比較モードなしで
   * sampler2DShadow で読む」のは、どちらも OpenGL ES 3.0 仕様が結果を未定義と
   * 定めている。この設定と GLSL 側のサンプラの型は必ずセットで合わせること
   */
  compare?: boolean;
}

export interface DepthTarget {
  /** 描画先を差し替えるための FBO 本体。カラーアタッチメントは持たない */
  framebuffer: WebGLFramebuffer;
  /** DEPTH_ATTACHMENT に付いている深度テクスチャ。描き終えたらこれをサンプリングする */
  texture: WebGLTexture;
  /** 深度テクスチャの幅(ピクセル)。viewport とアスペクト比はこの値から決める */
  width: number;
  /** 深度テクスチャの高さ(ピクセル) */
  height: number;
  /** 既定値を埋めた生成時のオプション */
  options: Required<DepthTargetOptions>;
}

/**
 * 深度だけを描く FBO(シャドウマップ用)を 1 つ作る。カラーアタッチメントを持たない。
 * 完成しなかった場合は原因の分かるエラーを投げ、作りかけのリソースは解放する
 */
export function createDepthTarget(
  gl: WebGL2RenderingContext,
  width: number,
  height: number,
  options: DepthTargetOptions = {},
): DepthTarget {
  if (width < 1 || height < 1) {
    throw new Error(`深度ターゲットのサイズは 1 以上にしてください(${width}×${height})`);
  }
  const resolved: Required<DepthTargetOptions> = {
    internalFormat: options.internalFormat ?? gl.DEPTH_COMPONENT24,
    compare: options.compare ?? false,
  };

  // --- 深度アタッチメント: レンダーバッファではなくテクスチャ -----------------
  const texture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0); // 作業台を 0 番に固定する(createRenderTarget と同じ)
  gl.bindTexture(gl.TEXTURE_2D, texture);
  // カラーと同じく texStorage2D で不変ストレージを確保する(第16・20章)
  gl.texStorage2D(gl.TEXTURE_2D, 1, resolved.internalFormat, width, height);

  // フィルタは必ず明示する。TEXTURE_MIN_FILTER の既定 NEAREST_MIPMAP_LINEAR のままだと、
  // 下の「NEAREST でなければ不完全」の条件に引っかかり、読んだ結果が 0 になる
  if (resolved.compare) {
    // 比較モード: texture() が「参照値 <= 記録された深度」の判定結果を返すようになる。
    // OpenGL ES 3.0 の texture completeness は「深度形式 + COMPARE_MODE が NONE」の
    // ときだけ NEAREST を要求するので、比較モードでは LINEAR を指定しても完全のまま
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  } else {
    // 比較モードなし: MAG は NEAREST、MIN は NEAREST か NEAREST_MIPMAP_NEAREST でないと
    // テクスチャが不完全になり、sampler2D で読んだ結果が (0, 0, 0, 1) になる
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  }
  // 光源の視錐台の外を読んだときに、反対側の端が折り返して出てこないようにする。
  // WebGL2 には CLAMP_TO_BORDER が無いので、範囲外はシェーダー側で弾く(第21章 9 節)
  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);

  // --- FBO を作って、深度テクスチャだけをアタッチする -------------------------
  const framebuffer = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, texture, 0);

  // カラーアタッチメントが 1 つも無いことを、描画先の指定にも反映しておく。
  // completeness の条件ではない(OpenGL ES 3.0 の条件は「画像が 1 枚以上付いていること」で、
  // カラーの有無は問われない)し、アタッチメントの無い draw buffer への出力は
  // エラーにならず捨てられるだけ。それでも書くのは、WebGL 2.0 側に
  // 「フラグメントシェーダーに対応する出力が無い draw buffer」を INVALID_OPERATION に
  // しうる規定があり、NONE と明示しておけばその条件から確実に外れるため
  gl.drawBuffers([gl.NONE]);

  const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  if (status !== gl.FRAMEBUFFER_COMPLETE) {
    deleteDepthTarget(gl, { framebuffer, texture, width, height, options: resolved });
    throw new Error(
      `深度フレームバッファが不完全です: ${framebufferStatusName(gl, status)} ` +
        `— ${width}×${height}, internalFormat=0x${resolved.internalFormat.toString(16)}`,
    );
  }

  return { framebuffer, texture, width, height, options: resolved };
}

/**
 * 描画先を深度ターゲットに切り替える。viewport も一緒に切り替える。
 * 画面へ戻すときは bindRenderTarget(gl, null) を使う(戻り先の処理は 1 か所でよい)
 */
export function bindDepthTarget(gl: WebGL2RenderingContext, target: DepthTarget): void {
  gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
  gl.viewport(0, 0, target.width, target.height);
}

/** FBO と深度テクスチャをまとめて解放する。作り直すときは必ず先に呼ぶ */
export function deleteDepthTarget(gl: WebGL2RenderingContext, target: DepthTarget): void {
  gl.deleteFramebuffer(target.framebuffer);
  gl.deleteTexture(target.texture);
}
src/lessons/20-framebuffers/main.ts
// 第20章: フレームバッファ — オフスクリーン描画とレンダーターゲット
// これまで描画結果は canvas の描画バッファ(既定のフレームバッファ)へ直接書き込まれていた。
// この章では書き込み先を自分で用意したテクスチャに差し替え、その結果を画面上の板に貼る。
//
// 1 フレームの流れ(FBO 経由モード):
//   パス 1: FBO をバインド → viewport を FBO のサイズへ → クリア → 3D シーンを描く
//   パス 2: 画面へ戻す    → viewport を canvas のサイズへ → クリア → 板にテクスチャを貼る
//
// FBO の生成・切り替え・作り直しは共通ヘルパー src/lib/framebuffer.ts(この章で導入)が担当する。

import { mat3, mat4, type ReadonlyMat4, type ReadonlyVec3, vec3 } from 'gl-matrix';
import {
  bindRenderTarget,
  createRenderTarget,
  deleteRenderTarget,
  framebufferStatusName,
  resizeRenderTarget,
} from '../../lib/framebuffer';
import {
  createPlane,
  createSphere,
  createTorus,
  type Geometry,
  interleave,
} from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import panelFragmentSource from './panel.frag?raw';
import panelVertexSource from './panel.vert?raw';
import sceneFragmentSource from './scene.frag?raw';
import sceneVertexSource from './scene.vert?raw';

// ---------------------------------------------------------------------------
// メッシュ: ジオメトリを VAO に配線する(第13〜15章の手順そのまま)
// ---------------------------------------------------------------------------

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

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

  // 1 頂点 = 8 float(位置 3 + 法線 3 + UV 2)。stride / offset はバイト単位で、
  // 数値を直書きせず BYTES_PER_ELEMENT から組み立てる(第13章)
  const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
  const stride = 8 * FLOAT_BYTES;
  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);

  // インデックスバッファのバインドは VAO に記録される(第13章)
  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;

// FBO の解像度を画面の何倍にするか。1 未満にすると「オフスクリーンの解像度は
// 画面から独立している」ことが見える(面積では 1/4・1/16 になる)
const SCALES = [1, 0.5, 0.25] as const;

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  modeControls: HTMLParagraphElement,
  depthControls: HTMLParagraphElement,
  scaleControls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  // --- プログラム 2 本 -------------------------------------------------------
  // scene: FBO(または画面)に 3D シーンを描く / panel: その結果を板に貼る
  const sceneProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, sceneVertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, sceneFragmentSource),
  );
  const sceneModelLocation = gl.getUniformLocation(sceneProgram, 'u_model');
  const sceneViewLocation = gl.getUniformLocation(sceneProgram, 'u_view');
  const sceneProjectionLocation = gl.getUniformLocation(sceneProgram, 'u_projection');
  const sceneNormalMatrixLocation = gl.getUniformLocation(sceneProgram, 'u_normalMatrix');
  const sceneLightDirectionLocation = gl.getUniformLocation(sceneProgram, 'u_lightDirection');
  const sceneCameraPositionLocation = gl.getUniformLocation(sceneProgram, 'u_cameraPosition');
  const sceneBaseColorLocation = gl.getUniformLocation(sceneProgram, 'u_baseColor');

  const panelProgram = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, panelVertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, panelFragmentSource),
  );
  const panelModelLocation = gl.getUniformLocation(panelProgram, 'u_model');
  const panelViewLocation = gl.getUniformLocation(panelProgram, 'u_view');
  const panelProjectionLocation = gl.getUniformLocation(panelProgram, 'u_projection');
  const panelTextureLocation = gl.getUniformLocation(panelProgram, 'u_texture');

  const torusMesh = createMesh(gl, createTorus(0.9, 0.32, 64, 32));
  const sphereMesh = createMesh(gl, createSphere(0.3, 24, 16));
  const floorMesh = createMesh(gl, createPlane(12, 12));
  // 板は FBO と同じ 3:2。比を変えるとオフスクリーンの絵が引き伸ばされる
  const panelMesh = createMesh(gl, createPlane(2.4, 1.6));

  gl.enable(gl.DEPTH_TEST); // 深度テスト(第13章)。FBO 側で効くかは深度アタッチメント次第
  gl.enable(gl.CULL_FACE);

  // --- シーンの配置 -----------------------------------------------------------

  // 「面から光源へ向かう」単位ベクトル(第17章の約束)
  const LIGHT_DIRECTION: ReadonlyVec3 = vec3.normalize(vec3.create(), [0.45, 0.8, 0.4]);
  const SCENE_EYE: ReadonlyVec3 = vec3.fromValues(0, 1.6, 4.2); // FBO の中のカメラ
  const PANEL_EYE: ReadonlyVec3 = vec3.fromValues(0, 0.5, 3.4); // 板を見るカメラ
  const ORIGIN: ReadonlyVec3 = vec3.fromValues(0, 0, 0);
  const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
  const FLOOR_POSITION: ReadonlyVec3 = vec3.fromValues(0, -1.2, 0);
  const TORUS_COLOR: ReadonlyVec3 = vec3.fromValues(0.85, 0.45, 0.25);
  const SPHERE_COLOR: ReadonlyVec3 = vec3.fromValues(0.45, 0.7, 0.95);
  const FLOOR_COLOR: ReadonlyVec3 = vec3.fromValues(0.3, 0.33, 0.38);

  // 毎フレーム使い回す入れ物(フレームごとに new しない)
  const model = mat4.create();
  const view = mat4.create();
  const projection = mat4.create();
  const normalMatrix = mat3.create();
  const spherePosition = vec3.create();

  // --- レンダーターゲット -----------------------------------------------------
  // 画面と同じ 3:2 で作る。サイズが変わったときだけ作り直す(毎フレームではない)。
  // 先に canvas を実サイズへ合わせてから作る。canvas の既定サイズ(300×150)のまま作ると、
  // 最初のフレームの resizeIfNeeded で必ずサイズが変わり、FBO を 1 つ無駄に捨てることになる
  resizeIfNeeded();
  let scale: number = SCALES[0];
  let depthEnabled = true;
  let target = createRenderTarget(gl, targetWidth(), targetHeight(), { depth: depthEnabled });
  let statusName = checkStatus();
  let mode: 'fbo' | 'direct' = 'fbo';

  function targetWidth(): number {
    // 表示サイズ 0 でも 0 除算・0 サイズのアタッチメントにならないよう 1px を下限にする
    return Math.max(1, Math.floor(gl.drawingBufferWidth * scale));
  }
  function targetHeight(): number {
    return Math.max(1, Math.floor(gl.drawingBufferHeight * scale));
  }

  // 読み出し行に出すために、ヘルパーの中とは別にもう一度自分で問い合わせる。
  // 結果は作り直すまで変わらないので、毎フレーム問い合わせる意味がない
  function checkStatus(): string {
    bindRenderTarget(gl, target);
    const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
    bindRenderTarget(gl, null);
    return framebufferStatusName(gl, status);
  }

  // 深度の有無やサイズが変わったら作り直す。古いものは必ず解放する
  function updateRenderTarget(): void {
    const width = targetWidth();
    const height = targetHeight();
    if ((target.depth !== null) !== depthEnabled) {
      // オプションが変わるので resize では足りない。自分で消してから作り直す
      deleteRenderTarget(gl, target);
      target = createRenderTarget(gl, width, height, { depth: depthEnabled });
      statusName = checkStatus();
      return;
    }
    // サイズが同じなら渡した target がそのまま返り、違えば作り直した別物が返る
    // (古いほうは中で解放済み)。だから戻り値は必ず受け取り直す。
    // 「別物が返ってきた = 作り直した」なので、ステータスの問い合わせもそのときだけでよい
    const resized = resizeRenderTarget(gl, target, width, height);
    if (resized !== target) {
      target = resized;
      statusName = checkStatus();
    }
  }

  // --- 切替ボタン(描画先 / 深度 / 解像度の 3 系統) --------------------------

  function addButtons<T>(
    container: HTMLParagraphElement,
    entries: readonly { label: string; value: T }[],
    isActive: (value: T) => boolean,
    onSelect: (value: T) => void,
  ): void {
    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);
    }
  }

  addButtons(
    modeControls,
    [
      { label: 'FBO → 板に貼る', value: 'fbo' as const },
      { label: '画面に直接描く', value: 'direct' as const },
    ],
    (value) => value === mode,
    (value) => {
      mode = value;
    },
  );

  addButtons(
    depthControls,
    [
      { label: 'FBO の深度: あり', value: true },
      { label: 'なし', value: false },
    ],
    (value) => value === depthEnabled,
    (value) => {
      depthEnabled = value;
      updateRenderTarget();
    },
  );

  addButtons(
    scaleControls,
    [
      { label: 'FBO 解像度: 等倍', value: SCALES[0] },
      { label: '1/2', value: SCALES[1] },
      { label: '1/4', value: SCALES[2] },
    ],
    (value) => value === scale,
    (value) => {
      scale = value;
      updateRenderTarget();
    },
  );

  // --- リサイズ: ループ先頭で「変わっていれば作り直す」(第4章と同じ) --------
  // ここで gl.viewport を呼ばないのがこの章の変更点。描画先を切り替えるたびに
  // bindRenderTarget が viewport も合わせるので、リサイズ側で触る必要がない

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }
  }

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

  function drawMesh(mesh: Mesh, modelMatrix: ReadonlyMat4, color: ReadonlyVec3): void {
    // 法線行列(第17章)。モデル行列の左上 3×3 の逆転置
    mat3.normalFromMat4(normalMatrix, modelMatrix);
    gl.uniformMatrix4fv(sceneModelLocation, false, modelMatrix); // transpose は常に false
    gl.uniformMatrix3fv(sceneNormalMatrixLocation, false, normalMatrix);
    gl.uniform3fv(sceneBaseColorLocation, color);
    gl.bindVertexArray(mesh.vao);
    gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
  }

  // 3D シーンを、いまバインドされている描画先に描く。
  // アスペクト比は「描画先の幅 / 高さ」— FBO のときは canvas のサイズではない
  function drawScene(time: number, width: number, height: number): void {
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    gl.useProgram(sceneProgram);
    mat4.lookAt(view, SCENE_EYE, ORIGIN, UP);
    mat4.perspective(projection, FOVY, width / height, NEAR, FAR);
    gl.uniformMatrix4fv(sceneViewLocation, false, view);
    gl.uniformMatrix4fv(sceneProjectionLocation, false, projection);
    gl.uniform3fv(sceneLightDirectionLocation, LIGHT_DIRECTION);
    gl.uniform3fv(sceneCameraPositionLocation, SCENE_EYE);

    // 描く順番は 床 → トーラス → 球。深度アタッチメントがないと、
    // この「あとから描いたものが必ず上」という順番がそのまま絵に出る
    mat4.fromTranslation(model, FLOOR_POSITION);
    mat4.rotateX(model, model, -Math.PI / 2); // XY 平面の板を床にする(第14章)
    drawMesh(floorMesh, model, FLOOR_COLOR);

    mat4.identity(model);
    mat4.rotateX(model, model, time * 0.35);
    mat4.rotateY(model, model, time * 0.5);
    drawMesh(torusMesh, model, TORUS_COLOR);

    // トーラスの手前と奥を行き来する球。深度なしでは、奥に回っても手前に見える
    vec3.set(spherePosition, Math.sin(time * 0.8) * 1.7, 0.15, Math.cos(time * 0.8) * 1.7);
    mat4.fromTranslation(model, spherePosition);
    drawMesh(sphereMesh, model, SPHERE_COLOR);
  }

  // FBO のカラーテクスチャを板に貼って画面に描く
  function drawPanel(time: number): void {
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    gl.useProgram(panelProgram);
    mat4.lookAt(view, PANEL_EYE, ORIGIN, UP);
    // 画面へ描くときのアスペクト比は描画バッファから(第3部の規約)
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);
    gl.uniformMatrix4fv(panelViewLocation, false, view);
    gl.uniformMatrix4fv(panelProjectionLocation, false, projection);

    mat4.identity(model);
    mat4.rotateY(model, model, Math.sin(time * 0.45) * 0.85);
    mat4.rotateX(model, model, Math.sin(time * 0.31) * 0.12);
    gl.uniformMatrix4fv(panelModelLocation, false, model);

    // テクスチャユニット 0 に FBO のカラーテクスチャを載せる(第16章)。
    // このとき同じテクスチャが描画先になっていないことが重要(前フレーム参照は第29章)
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, target.texture);
    gl.uniform1i(panelTextureLocation, 0);

    gl.bindVertexArray(panelMesh.vao);
    gl.drawElements(gl.TRIANGLES, panelMesh.indexCount, gl.UNSIGNED_SHORT, 0);

    // 描き終えたらテクスチャユニットを空けておく。次のフレームでこのテクスチャは
    // また描画先に戻るので、サンプル元としてバインドしたままにしない
    gl.bindTexture(gl.TEXTURE_2D, null);
  }

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

  let lastReadout = '';

  function updateReadout(): void {
    const canvasSize = `canvas ${gl.drawingBufferWidth}×${gl.drawingBufferHeight}`;
    const text =
      mode === 'direct'
        ? `描画先: 既定のフレームバッファ / ${canvasSize} / FBO は未使用`
        : `描画先: FBO ${target.width}×${target.height} / RGBA8 + ` +
          `${target.depth ? 'DEPTH_COMPONENT24 レンダーバッファ' : '深度なし'} / ` +
          `${statusName} / ${canvasSize}`;
    if (text !== lastReadout) {
      readout.textContent = text;
      lastReadout = text;
    }
  }

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

  function frame(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();
    updateRenderTarget(); // 画面サイズが変わったときだけ FBO を作り直す
    const time = timestamp / 1000;

    if (mode === 'direct') {
      // 比較用: FBO を経由せず、既定のフレームバッファへそのまま描く
      bindRenderTarget(gl, null);
      gl.clearColor(0.06, 0.07, 0.09, 1.0);
      drawScene(time, gl.drawingBufferWidth, gl.drawingBufferHeight);
    } else {
      // パス 1: オフスクリーン。bindRenderTarget が viewport も FBO のサイズに合わせる
      bindRenderTarget(gl, target);
      // FBO のクリア色だけ少し明るくして、画面上で板の範囲が分かるようにしている
      gl.clearColor(0.1, 0.11, 0.14, 1.0);
      drawScene(time, target.width, target.height);

      // パス 2: 画面へ戻して、パス 1 の結果を板に貼る
      bindRenderTarget(gl, null);
      gl.clearColor(0.06, 0.07, 0.09, 1.0);
      drawPanel(time);
    }

    updateReadout();
    requestAnimationFrame(frame);
  }

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

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

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

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

setup(gl, canvas, modeControls, depthControls, scaleControls, readout);
src/lessons/20-framebuffers/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;
// この章のシーンはテクスチャを貼らないので UV は使わない。それでも宣言だけ残しているのは、
// 頂点バッファのインターリーブ配置(位置 3 + 法線 3 + UV 2)を、シェーダー側からも
// 読み取れるようにするためです(panel.vert が location = 2 を使うのと同じ番号割り当て)
layout(location = 2) in vec2 a_uv;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat3 u_normalMatrix;

out vec3 v_normal;
out vec3 v_worldPosition;

void main() {
  // ライティングはワールド空間で行うので、位置と法線をワールド空間へ運ぶ(第17章)
  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/20-framebuffers/scene.frag
#version 300 es

precision highp float;

in vec3 v_normal;
in vec3 v_worldPosition;

// u_lightDirection は「面から光源へ向かう」単位ベクトル(第17章)
uniform vec3 u_lightDirection;
uniform vec3 u_cameraPosition;
uniform vec3 u_baseColor;

out vec4 fragColor;

void main() {
  // 方向光 1 つの Blinn-Phong。式の導出は第17章。
  // この章の主題は「どこに描くか」なので、陰影は前後関係が読み取れれば十分
  vec3 N = normalize(v_normal);
  vec3 L = u_lightDirection;
  vec3 V = normalize(u_cameraPosition - v_worldPosition);
  vec3 H = normalize(L + V);

  float diffuse = max(dot(N, L), 0.0);
  float specular = pow(max(dot(N, H), 0.0), 48.0);

  vec3 color = u_baseColor * (0.18 + 0.82 * diffuse) + vec3(0.35) * specular;
  fragColor = vec4(color, 1.0);
}
src/lessons/20-framebuffers/panel.vert
#version 300 es

// 板を 1 枚描くだけなので、使うのは位置と UV。法線(location = 1)は宣言もしない
layout(location = 0) in vec3 a_position;
layout(location = 2) in vec2 a_uv;

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

out vec2 v_uv;

void main() {
  v_uv = a_uv;
  gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
}
src/lessons/20-framebuffers/panel.frag
#version 300 es

precision highp float;

in vec2 v_uv;

// FBO のカラーアタッチメントだったテクスチャ。ここではただの画像として読む(第16章)。
// createPlane の v は下が 0、FBO の書き込みも左下原点なので、上下の反転は要らない
uniform sampler2D u_texture;

out vec4 fragColor;

void main() {
  fragColor = vec4(texture(u_texture, v_uv).rgb, 1.0);
}

three.js との対応

three.js のレンダーターゲットまわりは、この章で書いたものとほぼ 1 対 1 で対応します(左列は three.js のRenderTarget/WebGLRendererのソースで確認した既定値です)。

three.jsこの章
new THREE.WebGLRenderTarget(width, height, options)createRenderTarget(gl, width, height, options)
オプションのdepthBuffer(既定 true)/ stencilBuffer(既定 false)オプションのdepth(既定 true)。ステンシルは扱っていないので、必要ならDEPTH24_STENCIL8のレンダーバッファに差し替える
minFilter / magFilter / wrapS ほかオプションのfilter。ラップはCLAMP_TO_EDGE固定にしている
renderer.setRenderTarget(target)/ setRenderTarget(null)bindRenderTarget(gl, target)/bindRenderTarget(gl, null)。viewport も一緒に切り替わるところまで同じ
target.texture をマテリアルの map に渡すtarget.texturegl.bindTexture+uniform1iでサンプラーに割り当てる(第16章)
target.setSize(width, height)resizeRenderTarget(gl, target, width, height)。中身は作り直し
target.dispose()deleteRenderTarget(gl, target)
type: THREE.HalfFloatType/ FloatTypeinternalFormat: gl.RGBA16F/gl.RGBA32F+EXT_color_buffer_float(8 節)
depthTexture(既定 null)深度をテクスチャで受け取る構成。第21章のシャドウマップで作る
samples(既定 0。MSAA)renderbufferStorageMultisample+blitFramebuffer(この章では扱わない)
count(既定 1。カラーアタッチメントの本数 = MRT)drawBuffers による MRT。第23章で自作
EffectComposer / RenderPass / ShaderPassFBO のチェーン。第33章で自作

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

デモのコードはsrc/lessons/20-framebuffers/、共通ヘルパーはsrc/lib/framebuffer.tsにあります。この章の失敗はどれも「エラーが出ないまま絵だけがおかしくなる」形をしているので、 症状と原因を先に体験しておく価値があります。

まとめ

次章第21章「シャドウマッピング」では、この章で作ったレンダーターゲットの 最初の本格的な応用を扱います。やることは「カメラを光源の位置に置いて、もう一度シーンを描く」 だけ — ただし色ではなく深度を記録し、それをテクスチャとして読み返します。 この章で「深度は読まないからレンダーバッファでいい」と言ったその判断を、次章はひっくり返す わけです。そして光源から見て手前にある面と、いま塗ろうとしている点の深度を比べれば、影に 入っているかどうかが分かります。原理は 2 行で言えますが、実際に美しい影を出すには、 シャドウアクネとピーターパンという 2 つの厄介な症状と付き合うことになります。次章では、 アクネを消すためのバイアスと、影のふちを柔らかくするPCF (percentage-closer filtering)まで扱います。