はじめに
ときどき第四成分って何。
今回はp5.jsの枠組みを間借りして、深度値、というかバーテックスシェーダのgl_Position,あれの理解度をちょっとだけ深める企画です。
バーテックスシェーダで描画する仕組みですが、まずドローコールでたとえば
gl.drawArrays(gl.TRIANGLES, 0, 3);
とかすると、これは「0から3つ」という意味です。つまり0,1,2ですね。もし「4,8」だった場合4から8つで
4,5,6,7,8,9,10,11
に対して処理することになります。何を?それはそのうちアトリビュートで説明するかもしれないです。バーテックスシェーダで最近書いてる記事ではアトリビュートを使わないでgl.VertexIDという整数を使っていますがこれはその整数です。アトリビュートとはざっくり言うと、この整数の代わりに、その番号に相当するデータを使う仕組みです。つまりデータが配列かなんかに入っていて、それを取り出すわけですね。4,5,...,11だったらそういう順番のところにある塊をチェックしてデータを取り出すんですね。そのデータから最終的に、番号ごとに位置を決めます。
gl_Position = vec4(x, y, z, w); // ただしzは0.0でwは1.0
このときの$(x,y)$がいわゆる正規化デバイス座標、-1~1で左から右、下から上の範囲内の座標値です。$z$は今、いつも$0$です。$w$は今、いつも$1$です。今回はそこら辺を掘り下げようと思います。座標が決まったら、例えば今回はTRIANGLESなので、3つずつチェックしてそれらに対応する座標で三角形を作るわけですね。たとえば「3,9」の場合、
3,4,5~~~~~~6,7,8~~~~~~9,10,11
でそれぞれ位置に対して三角形を作るんですね。3つできます。それらについてラスタライズ、データの補間、ピクセルごとに位置を決めて描画します。TRIANGLE_STRIPならまた別の処理をします。「4,8」でLINESの場合は、
4,5~~~~6,7~~~~8,9~~~~10,11
で位置に対して線を引いて、結局4本の線分ができるわけですね。
コード全文
// cf:シェーダーのコンパイル:https://wgld.org/d/webgl/w011.html
// depthで遊ぼう
// 上向きの赤い三角形
const vsTriangle0 =
`#version 300 es
const float TAU = 6.28318;
const vec3 RED = vec3(1.0, 0.0, 0.0);
out vec3 vColor;
void main(){
float i = float(gl_VertexID);
vec2 p = vec2(-sin(TAU*i/3.0), cos(TAU*i/3.0));
vColor = RED;
// zは-0.5にする(深度値0.25)
gl_Position = vec4(p, -0.5, 1.0);
}
`;
// 下向きの青い三角形
const vsTriangle1 =
`#version 300 es
const float TAU = 6.28318;
const vec3 BLUE = vec3(0.0, 0.0, 1.0);
out vec3 vColor;
void main(){
float i = float(gl_VertexID);
// x,y座標の符号を変える
vec2 p = vec2(sin(TAU*i/3.0), -cos(TAU*i/3.0));
vColor = BLUE;
// zは0.5にする(深度値0.75)
gl_Position = vec4(p, 0.5, 1.0);
}
`;
// fsは一緒
const fsTriangle =
`#version 300 es
precision highp float;
in vec3 vColor;
out vec4 fragColor;
void main(){
fragColor = vec4(vColor, 1.0);
}
`;
function setup() {
createCanvas(400, 400, WEBGL);
// 1.レンダラーの取得
const gl = this._renderer.GL;
// 2.shaderProgramの用意
const pg0 = createShaderProgram(gl, {vs:vsTriangle0, fs:fsTriangle});
const pg1 = createShaderProgram(gl, {vs:vsTriangle1, fs:fsTriangle});
if(pg0 === null || pg1 === null){
return;
}
// 3.ドローコール(今回はgl_VertexIDを使用)
gl.clearColor(0.5, 0.5, 0.5, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(pg0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
//gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(pg1);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.flush();
}
function createShaderProgram(gl, params = {}){
const {vs, fs} = params;
const vsShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vsShader, vs);
gl.compileShader(vsShader);
if(!gl.getShaderParameter(vsShader, gl.COMPILE_STATUS)){
console.log("vertex shaderの作成に失敗しました");
console.error(gl.getShaderInfoLog(vsShader));
return null;
}
const fsShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fsShader, fs);
gl.compileShader(fsShader);
if(!gl.getShaderParameter(fsShader, gl.COMPILE_STATUS)){
console.log("fragment shaderの作成に失敗しました");
console.error(gl.getShaderInfoLog(fsShader));
return null;
}
const program = gl.createProgram();
gl.attachShader(program, vsShader);
gl.attachShader(program, fsShader);
gl.linkProgram(program);
if(!gl.getProgramParameter(program, gl.LINK_STATUS)){
console.log("programのlinkに失敗しました");
console.error(gl.getProgramInfoLog(program));
return null;
}
return program;
}
実行結果:
二つのシェーダー
いつものcreateShaderProgramはちょっと内容をいじっています。シェーダー文字列をグローバルにしました。文字列部分を切り離さないと複数のシェーダを作る際に不便だからですね。取得のところは、
const {vs, fs} = params;
となっていますがこれは「params.vsとしてvsをconstで定義する、fsも同様」という意味です。もし
const {vs:myVS, fs:myFS} = params;
と書かれている場合、「params.vsとしてmyVSをconstで定義する、params.fsとしてmyFSを定義する」のような意味になります。さらに
const {vs:myVS, fs:myFS, hoge=1, hoho:gege=2} = params;
のような場合、「params.hogeとしてhogeをconstで定義するが、params.hogeが未定義ならばhogeは1とする」とか、「params.hohoとしてgegeをconstで定義するが、params.hohoが未定義の場合gegeは2とする」とかそういう意味になります。便利なので知らない人は覚えるといいです。自分は数年前にこれを知りました。
これで二つのシェーダができます。それぞれの内容は
p5.jsでwebglのshader programを自分で書いてみる
とほぼ同じです。違うのは三角形が単色(vsTriangle0は赤、vsTriangle1は青)であることと、gl_Positionのz座標の値が違うことだけです。それと向きが逆です。赤いのは上向き(もともとのコード)、青いのは下向きです。フェッチの順番(時計・反時計回り)が逆になってしまうので、x座標の符号も変えています。
#version 300 es
const float TAU = 6.28318;
const vec3 RED = vec3(1.0, 0.0, 0.0);
out vec3 vColor;
void main(){
float i = float(gl_VertexID);
vec2 p = vec2(-sin(TAU*i/3.0), cos(TAU*i/3.0));
vColor = RED;
// zは-0.5にする(深度値0.25)
gl_Position = vec4(p, -0.5, 1.0);
}
#version 300 es
const float TAU = 6.28318;
const vec3 BLUE = vec3(0.0, 0.0, 1.0);
out vec3 vColor;
void main(){
float i = float(gl_VertexID);
// x,y座標の符号を変える
vec2 p = vec2(sin(TAU*i/3.0), -cos(TAU*i/3.0));
vColor = BLUE;
// zは0.5にする(深度値0.75)
gl_Position = vec4(p, 0.5, 1.0);
}
これらを順繰りに描画してあの実行結果を得るんですが、順番は赤で、次いで青です。つまり、青が後で描画されています。後で描画されているのに上に来ているのは赤です。不思議ですね...?
描画順と深度テスト
ドローコール部分:
// 3.ドローコール(今回はgl_VertexIDを使用)
gl.clearColor(0.5, 0.5, 0.5, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(pg0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
//gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(pg1);
gl.drawArrays(gl.TRIANGLES, 0, 3);
webglの描画では、色を格納するバッファ(データの格納領域)とは別に、そのピクセルの位置の深さのようなものを格納するバッファが存在します。それが深度バッファです。ざっくり言うとこう:
まずgl_Position(x,y,z,1.0)において、この$z$は-1~1の値なんですが(それより外の場合は描画に失敗する仕組み)、これを0~1の値にします。単純に0.5倍して0.5を足すだけです。vsTriangle0の場合これは0.25で、vsTriangle1の場合これは0.75です。その値を書き込む領域が存在します。それがデプスバッファです。これがデフォルトで与えられています。あんな感じ...
初期状態では1で初期化されています。1がびっしり書き込まれているイメージです。そこにpg0の描画をします。深度値は0.25なので、通常なら赤い三角形のところに0.25が書き込まれるはずです。ここで判定をしています。その判定方法は、「1と同じかそれより小さければ描画可能」というものです。これを深度テストといいます。
深度バッファの初期値はすべて1であるため、このテストには普通に合格でき、合格すると色の描画が認められ、さらに深度バッファが書き換えられます。つまり、0.25で置き換えられます。ためしに-0.5のところを1.0にしてみてください。イコールなので書き込まれます。そして1.1で書き込まれないことを確認してください。灰色になったままです。
この判定条件は関数でいじることができます。depthFuncといいます。
depthFunc
同じかそれより小さければ描画される、というのは、次の命令によります。
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
まずenable関数でDEPTH_TEST,深度テストを有効化します。そのうえで、depthFuncをLEQUALに設定します。これは「デスティネーションの深度値と同じかそれより小さい深度値なら描画してOKです、値も書き換えてください」という意味です。
実は、通常のwebglのレンダラーでは深度テストは有効になっていません。つまり全ピクセルに普通に描画されるようになっています。すべての描画内容が消滅します。上書きです(もっとも、ビューポート、...面倒なことはここでは割愛します。あともちろんドローコールで決定される描画領域だけです)。
これが有効になったとしても、デフォルトでの深度テスト関数は実はLESSです。デスティネーションの深度値より小さい深度値の場合に限り、描画させる処理です。つまり、同じ深度値の場合は描画させません。これだと2Dと同じ挙動になってくれないので、p5では深度テストの有効化とともにLEQUALに設定しています。自分のライブラリでもここは特にいじってません。まあその方が便利ですよね。
そこで次のpg1の描画です。青い下向きの三角形の描画において深度値は0.75です。もちろん三角形を用意してそこからはみ出すところには描画しません。つまり深度テストもしないわけですが、書き込む範囲において、0.75です。なので、1と比較すれば勝ちますが、0.25では勝てないので描画できません。結果的に、赤い三角形のところには描画できず、下に来てしまうわけですね。
ゆえに、後で書いた青い三角形が赤い三角形の下に来ます。
深度値のクリア
ここでコメントアウトを外してみます。
// 3.ドローコール(今回はgl_VertexIDを使用)
gl.clearColor(0.5, 0.5, 0.5, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(pg0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(pg1);
gl.drawArrays(gl.TRIANGLES, 0, 3);
この場合、青い三角形が上に来ます。
なぜかというと深度値がリセットされているからです。以前、clear命令はいろんなものをクリアするといいましたが、色のクリアと深度のクリアは別の処理になっています。このフラグによるクリア:
gl.clear(gl.DEPTH_BUFFER_BIT);
では、深度値がクリアされます。具体的には1で初期化されます。この「1」もいじれますが普通はいじらないですね。全部1になるんで、当然テストは通り、三角形が描画され、この位置には0.75が付与されます。
もちろん、テスト自体しなければいい、というのでも描画されます。
// 3.ドローコール(今回はgl_VertexIDを使用)
gl.clearColor(0.5, 0.5, 0.5, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(pg0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
//gl.clear(gl.DEPTH_BUFFER_BIT);
gl.disable(gl.DEPTH_TEST);
gl.useProgram(pg1);
gl.drawArrays(gl.TRIANGLES, 0, 3);
実行結果は一緒です。見た目上は、です。disableはenableで有効になった機能を無効にできます。DEPTH_TESTを無効にすると、深度テスト、深度バッファの更新が両方とも実行されなくなります。故に描画がなされますが、0.75は書き込まれません。そのまんまです。つまり0.25は残っています。そういう違いがあります。
毎フレームの描画において、深度値をそのままにすることがいい場合もあれば悪い場合もあり、それはスケッチ次第ですが、もし色と深度値を両方クリアしたいのであれば、フラグをunionすればOKです。
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
第四成分について
深度値についてはこれくらいにして、ついでにgl_Positionの第四成分に触れておきます。今までgl_Positionの$x,y$が正規化デバイス座標(-1~1)で$z$が深度値(-1~1,ただし深度テストや書き込みに使うのは線形変換で0~1にした値)としてきましたが、それは第四成分である$w$が1.0だったからでした。実はちょっとうそをついていて、最終的に得られる正規化デバイス座標や深度値は、この$w$で割った値です。
(\frac{x}{w},~ \frac{y}{w},~\frac{z}{w})
というわけですね。この$(x/w,~y/w)$が正規化デバイス座標としてあの領域におかれ、ラスタライズ云々となります。また$z/w$は0.5倍して0.5を足して深度テストやら、書き込みやら、なんやらをします。実際、vsTriangle0で$w$のところを0.5にすると、全体が2倍されて、
// (深度値0.0)
gl_Position = vec4(p, -0.5, 0.5);
ばかでかい三角形が現れます。逆に2.0とすれば小さくなるわけですね。
// (深度値0.375)
gl_Position = vec4(p, -0.5, 2.0);
深度値も変化しています。結果には影響ないですが。単に割り算しているだけです。
この割り算するという処理を最後にやっているわけですが、これは実は3D描画で非常に役に立つ仕様です(射影行列の話になります。難しいです...説明できません...)。
はみ出す場合
深度値が0~1に収まらない場合、描画は実行されません。具体的には、ドローコールが成立せず、三角形が生成されなかったりします。このコードの場合、たとえばvsTriangle0で第3座標を-1.1や1.1にするとテスト云々に依らず描画は失敗します。これも3D描画の土俵で考えるとわかりやすいです。クリッピングと言います。詳しい説明は難しいので割愛します。wgldのサイトが参考になると思います。
補間と割り算について
割り算の結果として正規化デバイス座標や深度値を計算しているわけですが、補間と割り算はどっちが先かというと、実は補間が先です。補間したうえで、第四成分で割って、そのあと正規化デバイス座標、ないしは深度値を出しています。割ってから補間しているわけではないです。これも3D描画が関係しています。たとえば空間内の2点に対して、それらの中間点を取ってからそれをキャンバスに落とす場合と、2点ともキャンバス上に落とし、それらの中点を取るのとは違う操作なわけですが、そういう話です。この辺を理解しておかないと影描画や射影テクスチャの処理でちょっと面倒なことになったりします(実際になりました)。
(ざっくりいうと線形代数が関係しています。モデル座標で補間したいわけですが、ビュー変換と射影変換が線形変換なので、結果的に中点のモデル座標にビュー変換、射影変換を施した値が得られるわけですね。分かんない人は読み飛ばしてください...)
おわりに
この$w$をいじってどうこうっていうのはp5がきっかけでwebglをかじり始めたころに遊んでたので感慨深いんですが、当時は仕様がさっぱりだったので何がなんだかさっぱりでした。3D描画の仕組みを知らないことにはわけわからなくて当然かと思います。
深度値ですが、これも3D描画において奥と手前の概念を反映させるための仕様です。ですがそれはおいておいて、描画の際にテストをしているという側面だけ見れば、たとえば3D描画のキャンバスに(いわゆるHUD的な)文字の付いた画像の貼り付けなんかにも使えたりできます。カメラが関係していなくても深度値は機能するということは覚えておいて損はないです。
ここまでお読みいただいてありがとうございました。