フレームバッファ — オフスクリーン描画とレンダーターゲット
第3章から第19章まで、描いたものはすべて同じ場所へ行きました — canvas の描画バッファです。drawArraysやdrawElementsを呼べば、結果は画面に出る。あたりまえのことのようですが、これは「描画先が最初から 1 つ用意されていて、それが選ばれたままだった」というだけの話です。この章では、その描画先を自分で用意したテクスチャに差し替えます。
差し替えて何が嬉しいのかというと、描いた結果をもう一度読めることです。画面に 出してしまった絵は、それ以上どうにもできません。しかしテクスチャに描いたなら、それは第16章で 学んだただの画像なので、シェーダーからサンプリングして加工できます。ぼかす、光らせる、歪める — ポストプロセス(第33章)はここから始まります。影(第21章)も、複数の出力を持つディファード レンダリング(第23章)も、前フレームを読むフィードバック(第29章)も、全部この章の道具の上に 乗っています。three.js でいえばWebGLRenderTargetとrenderer.setRenderTarget()にあたる部分を自作する章です。
checkFramebufferStatusの結果が出ています(4 節)。この章で学ぶこと:
- 既定のフレームバッファ(canvas の描画バッファ)と、自作のフレームバッファオブジェクト (framebuffer object, FBO)
- アタッチメント (attachment)— カラーはテクスチャ、深度はレンダーバッファ。テクスチャとレンダーバッファの使い分け
- FBO の深度バッファは自分で用意する。忘れると深度テストが黙って素通りする(第13章の宿題)
checkFramebufferStatusと completeness — 戻り値の意味と、必ず確かめるべき理由- render-to-texture の一巡と、viewport の切り替え忘れという最頻出の落とし穴
- リサイズへの追従(作り直し)と、古いリソースの削除
- float バッファへ「描く」には拡張
EXT_color_buffer_floatが要ること - 第6章以来 3 度目の共通化 —
src/lib/framebuffer.ts
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を渡すと既定のフレームバッファに戻ります。以降のclearもdrawElementsも、いまバインドされている描画先に対して働きます。状態機械らしい、素直な設計です。
2. アタッチメント — テクスチャとレンダーバッファ
FBO にはアタッチメントポイントという取り付け口が並んでいます。この章で使うのは 2 つです。
COLOR_ATTACHMENT0— 色の書き込み先。COLOR_ATTACHMENT1以降もあり、複数の色バッファへ同時に描く MRT は第23章で扱います(OpenGL ES 3.0 は最低 4 本を保証しています)DEPTH_ATTACHMENT— 深度の書き込み先。ステンシルの口も別にあります
そして、それぞれの口に取り付けられるものが 2 種類あります。テクスチャとレンダーバッファ (renderbuffer)です。レンダーバッファは、この章で初めて出てくるオブジェクトです。テクスチャとの違いは ひとことで言えます —レンダーバッファは描き込み専用で、シェーダーから読めません。 サンプリングできない代わりに、フィルタもラップもミップマップも持たない、描画先に特化した 単純な画素の置き場です。
どちらを使うかの判断は単純で、あとで読むかどうかだけで決まります。
| 付けるもの | 向いている場面 |
|---|---|
| テクスチャ | 結果をシェーダーで読み返す。この章のカラー、シャドウマップの深度(第21章)、G-buffer の各出力(第23章)など。フィルタ・ラップの設定を持てる |
| レンダーバッファ | 描画中は要るが、あとで読まない。典型は深度・ステンシル。読めない代わりに素直で、 マルチサンプル(MSAA)を持てるのもこちらだけです |
まずカラー側から作ります。テクスチャの作り方そのものは第16章のままで、違うのは「中身を アップロードしない」ことだけです。これから GPU が書き込む場所なので、置き場だけ確保します。
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(いちばん大きい画像)に描く、という意味です。
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で取り付けるだけです。
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_COMPONENT16、DEPTH_COMPONENT32F、深度とステンシルを まとめたDEPTH24_STENCIL8/DEPTH32F_STENCIL8、そしてSTENCIL_INDEX8があります)。16 ビットでも動きますが、第13章で触れた深度の精度の話がそのまま効くので、 奥行きのあるシーンでは 24 ビットのほうが安全です。
4. completeness を必ず確かめる
アタッチメントの組み合わせには制約があります。条件を満たした FBO を完全 (framebuffer complete)といい、満たさない FBO への描画や読み取りはINVALID_FRAMEBUFFER_OPERATIONエラーになります。ここでもJavaScript の例外は飛びません。第3章のシェーダー コンパイルとまったく同じ構図で、自分から状態を問い合わせない限り、症状は「何も描かれない」 だけです。
// 不完全な 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 フレームの流れをまとめます。
viewportで、描画先を切り替えるたびに必ずセットで書き換えます。デモの描画ループが、そのままこの図の形をしています。
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 の幅と高さから計算しなければなりません。この章のデモでは、シーンを描く関数が 描画先のサイズを引数で受け取るようにして、両方のパスで使い回しています。
// 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 のカラーアタッチメントだったテクスチャも、読むときはただの画像でしかありません。
// テクスチャユニット 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 度目です)。
共通化する理由は、この章の場合はっきりしています。
- オフスクリーン描画を 1 つ増やすたびに、まったく同じ 20 行あまりを書くことになる。 ポストプロセス(第33章)では FBO を 3 つも 4 つも並べます
- その 20 行の中に、忘れても叱ってもらえない手順が 2 つある — completeness チェックと、テクスチャのフィルタ設定です
- フレームバッファのバインドと
viewportは、常にセットで書き換えたい。関数にまとめれば、片方だけ書く事故が起きない
そこで、FBO とアタッチメントをひとまとめにしたレンダーターゲット (render target)という型を置きます。three.js のWebGLRenderTargetと同じ考え方の、同じ名前です。
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 つの関数に集約します。
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 つ作ってから、古いほうを消す。
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章以来の 「ループの先頭でサイズを確認し、変わっていたときだけ作り直す」という型が、そのまま使えます。
// 深度の有無やサイズが変わったら作り直す。古いものは必ず解放する
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 に積み上がるからです。
/** 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)では、RGBA16FもRGBA32Fもcolor-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。迷ったらこれ。この章のデモもこれです |
RGBA16F | 1 成分 16 ビット。メモリと帯域はRGBA32Fの半分で、HDR には十分な精度。LINEARフィルタも WebGL2 のコアで使えます |
RGBA32F | 1 成分 32 ビット。位置や速度を誤差なく置きたいとき。ただしリニアフィルタは別の拡張(OES_texture_float_linear)が必要で、無いときはNEARESTしか使えません |
9. ここから先 — ポストプロセスの入口
この章のデモは、オフスクリーンの結果を 3D の板に貼りました。しかし「板」である必然性はどこにもありません。第6章で作った全画面三角形に同じテクスチャを貼れば、画面いっぱいにそのまま出せます。そして貼るときのフラグメント シェーダーは自由に書けるので、そこで色を変えたりぼかしたりできます。
これがポストプロセス (post-processing)です。3D のシーンを一度テクスチャにしてしまえば、あとは第2部でやってきた 2D の画像処理の世界に持ち込める、という筋道です。具体的なエフェクト — ブルーム、分離可能 ガウシアン、FXAA、色収差 — は第33章でまとめて扱います。この章で用意したのは、その全部が 立つ土台のほうです。
この土台の上に建つものを、先に挙げておきます。
- 第21章 シャドウマッピング— 深度をレンダーバッファではなく深度テクスチャに描き、光源から見た深度として 読み返します。「読むならテクスチャ」の実例です
- 第23章 ディファードレンダリング—
COLOR_ATTACHMENT0だけでなく複数のカラーアタッチメントへ同時に描く MRT(drawBuffers)で、位置・法線・ 色を別々のテクスチャに書き出します - 第29章 フィードバック— FBO を 2 つ用意し、片方に描きながらもう片方を読む ping-pong で、前フレームの結果を参照します(同じテクスチャを描画先とサンプル元に同時に使うことはできないため、2 つ要ります)。第1章で「各要素は互いの結果を読めない」と書いた制約を、時間方向にずらして 回避する仕組みです
- 第33章 ポストプロセス— FBO を数珠つなぎにしたチェーン。この章のヘルパーがそのまま部品になります
コード全文
この章で導入した共通ヘルパーと、それを使うデモの 5 ファイルです。第6章・第14章と同じく、src/lib/に置くコードも全文を掲載します — 隠すためではなく、繰り返し書かないための共通化だからです。
scene.vert/scene.fragは第17章の方向光 1 灯の Blinn-Phong をそのまま使っています。この章の主題は「どこに描くか」なので、陰影は前後関係が読み取れれば 十分という判断です。
// 共通モジュール: レンダーターゲット(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);
}// 第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);#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;
}#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);
}#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);
}#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.textureをgl.bindTexture+uniform1iでサンプラーに割り当てる(第16章) |
target.setSize(width, height) | resizeRenderTarget(gl, target, width, height)。中身は作り直し |
target.dispose() | deleteRenderTarget(gl, target) |
type: THREE.HalfFloatType/ FloatType | internalFormat: 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 / ShaderPass | FBO のチェーン。第33章で自作 |
手元で動かして、壊してみる
デモのコードはsrc/lessons/20-framebuffers/、共通ヘルパーはsrc/lib/framebuffer.tsにあります。この章の失敗はどれも「エラーが出ないまま絵だけがおかしくなる」形をしているので、 症状と原因を先に体験しておく価値があります。
src/lib/framebuffer.tsのbindRenderTargetからgl.viewport(0, 0, target.width, target.height);の行を消す — 最頻出の失敗の再現です。「FBO 解像度」を 1/2 や 1/4 にすると、viewport が FBO より大きいままになるので、板にはシーンの左下の一部が拡大されて映ります。等倍のときは viewport がたまたま一致しているので何も起きません(だから気づきにくいのです)- 同じ関数の
null側のgl.viewport(...)を消す — 今度は 1/4 のときに画面側が壊れ、板が canvas の左下の小さな領域にだけ描かれます。viewport が「描画先ごと」ではなく 「コンテキストの状態」であることが、これで腑に落ちるはずです createRenderTargetのgl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, resolved.filter);をコメントアウトし、「FBO 解像度: 等倍」のまま眺める — 何も起きません。texStorage2Dの不変テクスチャはlevels = 1でも完備なので、第16章の「真っ黒」は再現しません(2 節)- 続けて
texStorage2Dの行をgl.texImage2D(gl.TEXTURE_2D, 0, resolved.internalFormat, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);に置き換える — 今度は真っ黒になります。可変テクスチャは level 1 以降がないと不完全だからです。不変ストレージが「完備を構造的に保証する」とはこういうことです (第16章 8 節) main.tsのdrawSceneに渡すサイズをdrawScene(time, target.width, target.height)からdrawScene(time, gl.drawingBufferWidth, gl.drawingBufferHeight)に変える — 今回のデモは FBO も canvas も 3:2 なので何も変わりません。板の形をcreatePlane(2.4, 1.6)からcreatePlane(2.4, 2.4)に変え、FBO のサイズも正方形にしてから同じ実験をすると、シーンが縦に潰れて「アスペクト比は 描画先から取る」の意味が見えますcreateRenderTargetのinternalFormatの既定をgl.RGBA16Fに変える —EXT_color_buffer_floatを有効にしていないので、FRAMEBUFFER_INCOMPLETE_ATTACHMENTでエラーが投げられ、コンソールにその名前が出ます。次にmain.tsの先頭でgl.getExtension('EXT_color_buffer_float')を呼んでから同じことをすると、今度は通ります(8 節)createRenderTargetに渡すオプションでfilterをgl.NEARESTにする — FBO 解像度を 1/4 にしたときの見え方が、ぼけた拡大からドットの拡大に変わります。オフスクリーンの 解像度が本当に落ちていることが、いちばんはっきり分かる設定ですsrc/lib/framebuffer.tsのresizeRenderTargetからdeleteRenderTarget(gl, target);を消して、ウィンドウの幅を何度もドラッグで変える — 見た目は何も変わりませんが、GPU 上に古いテクスチャとレンダーバッファが積み上がります。ブラウザの開発者ツールでメモリを 眺めながらやると、解放を忘れることの意味が分かります(main.tsのupdateRenderTargetにあるほうのdeleteRenderTargetは「深度の有無を切り替えたとき」専用なので、そちらを消したときに漏れるのは深度ボタンを 連打したときです)drawSceneの描画順を入れ替える(球を床とトーラスより先に描く)— 「FBO の深度: あり」ではまったく同じ絵のまま、「なし」では壊れ方が変わります。深度テストが「描画順から絵を独立させる仕組み」 だったことの確認です(第13章)
まとめ
- 描画先は差し替えられる。
bindFramebuffer(gl.FRAMEBUFFER, fbo)で自作の FBO へ、nullで既定のフレームバッファ(canvas)へ戻る - FBO 自体は画素を持たない取り付け口の束。実体はアタッチメント — あとで読むならテクスチャ、読まないならレンダーバッファ
- 深度は自分で付ける。付け忘れても深度テストは黙って素通りし(仕様の規定)、描画順まかせの 絵になる。コンテキスト属性の
depthは既定のフレームバッファの話で、FBO には無関係 - 作ったら
checkFramebufferStatusで completeness を確認する。不完全でも例外は飛ばず、「何も描かれない」だけ。WebGL2 では全アタッチメントのサイズを揃える必要がある(FRAMEBUFFER_INCOMPLETE_DIMENSIONS) - 描画先を切り替えたら
viewportも切り替える。viewport は描画先ではなくコンテキストの状態なので、自動では変わらない。 射影行列のアスペクト比も描画先のサイズから取る - リサイズは「新しく作ってから、古いものを消す」。ただし毎フレームではなく、変わったときだけ
- float テクスチャは読むだけならコア、描くには
EXT_color_buffer_floatが要る。RGBA8で足りるならRGBA8が最も安全 - 第6章以来 3 度目の共通化として
src/lib/framebuffer.tsを抽出した。理由は「同じ 20 行の繰り返し」「忘れやすい completeness チェック」「バインドと viewport をセットにしたい」の 3 つ
次章第21章「シャドウマッピング」では、この章で作ったレンダーターゲットの 最初の本格的な応用を扱います。やることは「カメラを光源の位置に置いて、もう一度シーンを描く」 だけ — ただし色ではなく深度を記録し、それをテクスチャとして読み返します。 この章で「深度は読まないからレンダーバッファでいい」と言ったその判断を、次章はひっくり返す わけです。そして光源から見て手前にある面と、いま塗ろうとしている点の深度を比べれば、影に 入っているかどうかが分かります。原理は 2 行で言えますが、実際に美しい影を出すには、 シャドウアクネとピーターパンという 2 つの厄介な症状と付き合うことになります。次章では、 アクネを消すためのバイアスと、影のふちを柔らかくするPCF (percentage-closer filtering)まで扱います。