はじめに
フレームバッファの理解を深める企画。
フレームバッファとは
トランスフォームフィードバックはwebGLバッファにドローコールで書き込みを実行するための仕組みである。フレームバッファは、それと似ていて、こちらはドローコールでテクスチャに描き込みを実行するための仕組みである。
フレームバッファはバッファじゃないです。ただのインタフェース。
バッファではなく、テクスチャへの描き込みをするためのインタフェース。でも一応バッファと名前が付いている。あんま気にしない方がいい。
ビューポート
その前にちょっとビューポートについて触れておく必要があると思う。
ちょっとwebGLの復習。
viewport
/*
ビューポート
gl_Positionをデバイス座標という
wで割って正規化デバイス座標になる
さらに0.5倍して0.5を足すと正規化ビューポート座標となり
最終的にビューポートパラメータに基づいてピクセルが決まる
なおz座標はそのまま深度値である
*/
function setup() {
createCanvas(400, 400, WEBGL);
// 習うより慣れろ
// 1.レンダラーの取得
const gl = this._renderer.GL;
// 2.shaderProgramの用意
const vs =
`#version 300 es
const vec2[4] poses = vec2[](
vec2(-1.0,-1.0), vec2(1.0,-1.0), vec2(-1.0,1.0), vec2(1.0,1.0)
);
out vec2 vUv;
void main(){
vec2 p = poses[gl_VertexID];
vUv = p*0.5 + 0.5;
gl_Position = vec4(p, 0.0, 1.0);
}
`;
const fs =
`#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;
void main(){
fragColor = vec4(vec3(vUv.y), 1.0);
}
`;
const pg = createShaderProgram(gl, {vs, fs});
if(pg === null){
return;
}
// 3.ドローコール(今回はgl_VertexIDを使用)
gl.clearColor(0.5, 0.5, 0.5, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// ビューポート指定
gl.viewport(0,100,200,300);
gl.useProgram(pg);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.flush();
}
function createShaderProgram(gl, params = {}){
/* webGLのユーティリティ(省略) */
実行結果:
内容的にはconstで4つの頂点を指定し、STRIPで長方形を描くだけのものである...が、画面全体ではなく、一部のみである。この範囲はどう指定しているかというと、
gl.viewport(0,100,200,300);
ここで決めている。
描画範囲がどのように決められるのかはラスタライズで説明したが、ビューポートについては触れなかった(面倒だったので)。実はそこまで触れないと片手落ちである。まずgl_Positionから。これで決まるのがデバイス座標(仮)。これに対し、$x,y,z$成分を$w$で割ると正規化デバイス座標になる。例の単位立方体である。その中に落ちるというわけ。これの$x$と$y$がそのまま左から右、下から上だと思うでしょう。実はさらに処理が続く。
正規化デバイス座標に対して、これの0.5倍に0.5を足したものを正規化ビューポート座標(仮)という。これは$x,y,z$すべて0~1の範囲に収まっている。このときの$z$がいわゆるwebGL深度値である。0が手前で1が一番奥。なので何らかの理由で一番手前に置きたい場合は0になるようにgl_Positionの$z$を-1にして$w$を1にしたりする(なんかそういうことをやった気がしますね)。しかしこのときの$x,y$ではまだ決まらない。だって0~1だからね。そこでビューポートパラメータである。それを決めるのがgl.viewportである。
gl.viewport(a,b,c,d)を実行すると、左下の頂点が$(a,b)$で、右方向に$c$まで、上方向に$d$までの範囲が描画範囲になる。要するにこういう計算をしている。その結果、最終的な描画対象のピクセルが決まる。
この$VX,VY$がビューポート座標である。そして$D$は深度値で、これらの$VX,VY,D$がgl_FragCoordの内容である。ゆえに深度値は実はgl_FragCoordのz座標から簡単に取得できる。たとえばfragColorの取得部分を
void main(){
fragColor = vec4(vec3((gl_FragCoord.y-100.0)/300.0), 1.0);
}
とすれば同じ結果になる。$y$座標が100~400の範囲で描画しているからですね。さらに、
gl_Position = vec4(p, 0.0, 1.0);
の0.0を0.5や-0.5にすると、もし出力が、
fragColor = vec4(vec3(gl_FragCoord.z), 1.0);
であるなら、いろいろ変化する。たとえば-0.5なら深度値が0.25になるので
このようにグレーになるわけ。ビューポートについてはこれくらいで。
なお、正規化ビューポート座標は下から上に向かって大きくなっている。当然か。
何でビューポートを説明したかというと、
これを説明しないとテクスチャのどこに描画されるのか説明できないから
です。なおビューポートのデフォルトはキャンバスのサイズに応じた横幅と縦幅に基づいて0,0,横幅、縦幅となっている。ところでp5にはpixelDensityという概念があり、devicePixelRatioが1より大きいと(要するに自分のスマホ)、キャンバスを大きく取る。そのため、ビューポートの初期設定もそれに応じて大きいものになっている。それを、見た目上はキャンバスサイズより小さい範囲に置くので、上のコードは例えば自分のスマホとかだと長方形領域が小さくなる。同じ見た目にしたい場合はpixelDensity(1)を実行すればいいが、devicePixelRatioより小さい範囲に描画すると内容によっては見栄えが悪くなるので注意である。
このようにビューポートはキャンバスのどこに描画するのかを決める。フレームバッファはテクスチャに描画するので、場合によってはビューポートをいじる必要が出てくる。
フレームバッファを使ってみる
/*
ビューポート
gl_Positionをデバイス座標という
wで割って正規化デバイス座標になる
さらに0.5倍して0.5を足すと正規化ビューポート座標となり
最終的にビューポートパラメータに基づいてピクセルが決まる
なおz座標はそのまま深度値である
フレームバッファ
bindFramebuffer()の役割
globalStateのFRAMEBUFFER枠にfboをアタッチする
アタッチしている間にできること
テクスチャとレンダーバッファの紐付け
ドローコールやclearの対象を紐付けられたテクスチャにする
など(カラーバッファのみ、ですね。多分。)
*/
function setup() {
createCanvas(400, 400, WEBGL);
pixelDensity(1);
const gl = this._renderer.GL;
const tex = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 200, 200, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
const vs =
`#version 300 es
const vec2[4] poses = vec2[](
vec2(-1.0,-1.0), vec2(1.0,-1.0), vec2(-1.0,1.0), vec2(1.0,1.0)
);
out vec2 vUv;
void main(){
vec2 p = poses[gl_VertexID];
vUv = p*0.5 + 0.5;
gl_Position = vec4(p, 0.0, 1.0);
}
`;
const fsFBO =
`#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;
void main(){
fragColor = vec4(vec3(vUv.y), 1.0);
}
`;
const fsDisplay =
`#version 300 es
precision highp float;
in vec2 vUv;
uniform sampler2D uTex;
out vec4 fragColor;
void main(){
fragColor = texture(uTex, vUv);
}
`
const pgFBO = createShaderProgram(gl, {vs:vs, fs:fsFBO});
const pgDisplay = createShaderProgram(gl, {vs:vs, fs:fsDisplay});
// まずfboに焼く
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.viewport(100,100,100,100);
gl.clearColor(0.1, 0.5, 0.1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(pgFBO);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0,0,400,400);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.clearColor(0.5, 0.5, 0.5, 1.0);
gl.useProgram(pgDisplay);
setUniformValue(gl, pgDisplay, "1i", "uTex", 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.flush();
}
function createShaderProgram(gl, params = {}){
/* webGLのユーティリティ(省略) */
とりあえずテクスチャを用意した。なお、見た目が変わるとめんどくさいので今回はpixelDensity(1)を使わせていただいてる。テクスチャのサイズは200x200とする。なおソースにはnullが指定されている。空っぽという意味。別に空っぽである必要はなく、何かしら入っていても全く問題ない。今回はfboを使ってまっさらな状態で描き込みたいのでこうしているだけ。TFFのときに領域だけ確保するようなことをしたが、あれと同じ。
フレームバッファ、というかframebufferObjectを作る。作る関数はcreateFramebuffer.
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
bindFramebufferでFRAMEBUFFERを指定すると、ARRAY_BUFFERの時と同じように、globalStateのFRAMEBUFFER枠にfboがアタッチされる。以降はこのFRAMEBUFFERを指定すると、そこにアタッチされたfboでいろいろよろしく実行される。
framebufferTexture2Dはフレームバッファにテクスチャをアタッチするための関数。これでテクスチャを紐付けるとドローコールの対象がそのテクスチャになる。第一引数はそのときglobalStateのFRAMEBUFFER枠にあるfboで、第二引数は今回色として使うので色の0を指定する。3つ目は今回TEXTURE_2Dをアタッチするのでこれで。キューブマップは、やっていないが、その場合それを指定することになる。6面あるが、面ごとにアタッチの際の定数が決まっていて、それぞれに描画できる。いわゆる動的キューブマッピングだが、非常に難しい。
第四引数はそのテクスチャ。なお、このときアタッチするテクスチャがアクティブである必要はない(gl.activeTexture(gl.TEXTURE1)のような処理をしても問題なくアタッチできる)。もちろんtargetの整合性は必要だが、それさえ守ればいつでも自由にアタッチできる。この辺はあれですね、じゃあfboもそうしろよって話だったりするんですが、こういうのはどうしても気になってしまいますね。テクスチャは一応0番に入れておく。
まずどこに描画されるのかから説明する。
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.viewport(100,100,100,100);
gl.clearColor(0.1, 0.5, 0.1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(pgFBO);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
描画するには、テクスチャが紐付けられたフレームバッファをバインドする。これにより、ドローコールやclearの対象がそのテクスチャになる。そのうえでビューポート関数を実行する。ビューポート座標が決まる。このビューポート領域がすなわちテクスチャ上の描画領域である。ゆえに今100,100,100,100を実行しているが、これはテクスチャの$x$が100~200で、$y$が100~200の範囲に描き込みますよ、という意味である。例えばgl_Positionで-1,-1に相当する位置がテクスチャの0,0に相当することとなる。いわゆる「上」は下である。テクスチャの$y$が小さい所は下側、という意味。そうなるとこの図にあるように、右上のスペースに描画される。
描画結果を確認するため、もう一つのプログラムを走らせる。バインドするfboをnullにすることで、ふたたびキャンバスへの描画になる。
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0,0,400,400);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.clearColor(0.5, 0.5, 0.5, 1.0);
gl.useProgram(pgDisplay);
setUniformValue(gl, pgDisplay, "1i", "uTex", 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.flush();
0番にテクスチャが入ってるんで、uTexに0を割り当てて利用する。描画の際、特に工夫はしていなくて、正規化ビューポート座標をそのまま使っている。
#version 300 es
precision highp float;
in vec2 vUv;
uniform sampler2D uTex;
out vec4 fragColor;
void main(){
fragColor = texture(uTex, vUv);
}
そうすると、そのまま描画される。当然ですね。なぜならこの場合の(0,0)は左下として描画したので。とても自然ですね。すなわち(0,0)が左下に来るように描画される。しかし、たとえばロードした画像などでは(0,0)は見た目上、左上である。ゆえにロードした画像をこのコードで表示すると上下がさかさまになって表示される。一方、フレームバッファの方でたとえば3D描画などをした結果をそのまま焼く場合は、これでいい。ちょっとややこしいですね。どういうことが起きるのかきちんと把握しておく必要があります。
(その流れでキャンバスに描画し、それをsave関数などでセーブする場合、キャンバスに落ちた時点で格納が上下さかさまになる。なのでそのまま保存しても何の問題も無い。しかしテクスチャのまま保存するとちょっとまずいことになる...)
実は、このキャンバスへの描画に際しても、見かけ上の左上が(0,0)になるように格納されていたりする。ビューポート完全無視である。そもそもキャンバスはエレメントであってテクスチャでは無いので、ルールが違う。webGL関係ないし(2Dでも使うし)。それで不具合が生じてしまっているが、webGLにおいて上が正であることは自然なので、仕方ないと思います。
最後にちょっとだけ注意。このnullのところをfboにするとエラーになる。
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.viewport(0,0,400,400);
/* 以下略 */
GL_INVALID_OPERATION: Feedback loop formed between Framebuffer and active Texture.
バインドしたフレームバッファにアタッチされたテクスチャを読み込みで使うことはできない。使おうとするとこのフィードバックエラーをくらってしまう。テクスチャ管理が雑だったり、描き込みと表示で同じプログラムを使ってたりするとこういうエラーになりやすいらしい。気を付けましょう。普通だったら起きないんですが...
テクスチャはとっかえひっかえできる
フレームバッファはただのインタフェースなので、テクスチャを自由にアタッチして描き込みができる。複数のテクスチャを同じフレームバッファにとっかえひっかえして描き込んだりできる。たとえば動的キューブマッピングではまさにそれを使って6面分のテクスチャをとっかえひっかえして描画する(毎フレーム)。知っておくといい場合がある。1:1ではないということ。もちろんサイズが違う場合、そのたびにビューポートをいじらないときちんと描画されないので注意である。
おわりに
基本的なことだけ説明しました。深度値やステンシルも扱えるんですが、理解が足りてないので端折りました。ちなみにデフォルトのキャンバスの方では深度値を24bit,ステンシルを8bitで処理していて、これと同じことをフレームバッファの方でも実行できるらしいんですが、試したこと無いのでわかんないです。いずれやりたいですね。
ここまでお読みいただいてありがとうございました。
追記:p5のフレームバッファ
Each p5.Framebuffer object provides a dedicated drawing surface called a framebuffer. They're similar to p5.Graphics objects but can run much faster. Performance is improved because the framebuffer shares the same WebGL context as the canvas used to create it.
p5.Framebuffer
p5.Framebuffer objects have all the drawing features of the main canvas. Drawing instructions meant for the framebuffer must be placed between calls to myBuffer.begin() and myBuffer.end(). The resulting image can be applied as a texture by passing the p5.Framebuffer object to the texture() function, as in texture(myBuffer). It can also be displayed on the main canvas by passing it to the image() function, as in image(myBuffer, 0, 0).
class Framebuffer