はじめに
p5.jsでレンダラーを使ってwebglを独自に構築するシリーズの第二弾です。前回:
p5.jsでwebglのshader programを自分で書いてみる
のコードを踏襲します。今回はこれを40x40のキャンバスに適用して、ラスタライズを可視化し、フラグメントシェーダの理解を深めようと思います。もちろんp5のwebglでやってもいいんですが、余計なコードが多すぎてwebglの本質的な理解につながらないので採用できません。
コード全文
// ラスタライズの可視化
// 40x40でRGB三角形を描画してピクセル採取で800x800に落とす
// 色の補間の可視化
function setup() {
createCanvas(800, 800);
const gr = createGraphics(40, 40, WEBGL);
// grにRGB三角形を描画
// getでpixelを採取して20x20に色を置いて並べる
// 3つの頂点は(-1+1/80, 1/80), (1/80, -1+1/80, 1-1/80, 1-1/80)とする。
// これらにR,G,Bを付与し、補間の様子を観察する。
// 1.レンダラーの取得
const gl = gr._renderer.GL;
// 2.shaderProgramの用意
const pg = createShaderProgram(gl);
if(pg === null){
return;
}
// 3.ドローコール(今回はgl_VertexIDを使用)
gl.clearColor(0.5, 0.5, 0.5, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(pg);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.flush();
// 描画結果の可視化
for(let x=0; x<40; x++){
for(let y=0; y<40; y++){
const c = gr.get(x,y);
fill(c[0],c[1],c[2],c[3]);
rect(x*20,y*20,20);
}
}
}
function createShaderProgram(gl){
const vs =
`#version 300 es
const float TAU = 6.28318;
const vec3 RED = vec3(1.0, 0.0, 0.0);
const vec3 GREEN = vec3(0.0, 1.0, 0.0);
const vec3 BLUE = vec3(0.0, 0.0, 1.0);
const vec2 P0 = vec2(-1.0 + 1.0/80.0, 1.0/80.0);
const vec2 P1 = vec2(1.0/80.0, -1.0 + 1.0/80.0);
const vec2 P2 = vec2(1.0 - 1.0/80.0, 1.0 - 1.0/80.0);
out vec3 vColor;
void main(){
int i = gl_VertexID;
vec2 p = (i == 0 ? P0 : (i == 1 ? P1 : P2));
vColor = (i == 0 ? RED : (i == 1 ? GREEN : BLUE));
gl_Position = vec4(p, 0.0, 1.0);
}
`;
const fs =
`#version 300 es
precision highp float;
in vec3 vColor;
out vec4 fragColor;
void main(){
fragColor = vec4(vColor, 1.0);
}
`;
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;
}
実行結果
三角形の位置
前回同様、正規化デバイス座標系において三角形の頂点を反時計回りに決めています。もちろん$x$軸右、$y$軸上の高校以来おなじみの座標系における反時計回りです。それらの頂点を、
P_0=(-1+\frac{1}{80},\frac{1}{80}),~~~P_1=(\frac{1}{80},-1+\frac{1}{80}),~~~P_2=(1-\frac{1}{80},1-\frac{1}{80})
で定義しています。
#version 300 es
const float TAU = 6.28318;
const vec3 RED = vec3(1.0, 0.0, 0.0);
const vec3 GREEN = vec3(0.0, 1.0, 0.0);
const vec3 BLUE = vec3(0.0, 0.0, 1.0);
const vec2 P0 = vec2(-1.0 + 1.0/80.0, 1.0/80.0);
const vec2 P1 = vec2(1.0/80.0, -1.0 + 1.0/80.0);
const vec2 P2 = vec2(1.0 - 1.0/80.0, 1.0 - 1.0/80.0);
out vec3 vColor;
void main(){
int i = gl_VertexID;
vec2 p = (i == 0 ? P0 : (i == 1 ? P1 : P2));
vColor = (i == 0 ? RED : (i == 1 ? GREEN : BLUE));
gl_Position = vec4(p, 0.0, 1.0);
}
今回はgl_VertexIDを整数値でそのまま使って決めています。描画先は40x40のキャンバスです。なお、レンダラーはあんな感じで取得しています。前回と違うのはキャンバスの大きさと三角形の位置だけであとは全部一緒です。
const gr = createGraphics(40, 40, WEBGL);
// grにRGB三角形を描画
// getでpixelを採取して20x20に色を置いて並べる
// 3つの頂点は(-1+1/80, 1/80), (1/80, -1+1/80, 1-1/80, 1-1/80)とする。
// これらにR,G,Bを付与し、補間の様子を観察する。
// 1.レンダラーの取得
const gl = gr._renderer.GL;
// 2.shaderProgramの用意
// ... あとは全部一緒
ラスタライズの可視化
以上のコードで、40x40のキャンバスにそういう三角形が描画されます。それを眺めていても何にも分かんないので、色を取得して正方形を描画しています。これにより得られるのがあの画像です。
// 描画結果の可視化
for(let x=0; x<40; x++){
for(let y=0; y<40; y++){
const c = gr.get(x,y);
fill(c[0],c[1],c[2],c[3]);
rect(x*20,y*20,20);
}
}
見ての通り、赤、緑、青の色が補間されているのが分かるでしょうか。境界部分に灰色みがかかっているのは、アンチエイリアスで地の色に近くなっているからです。たとえば赤と青の真ん中くらいは紫っぽくなっています。フラグメントシェーダでは、アトリビュートの位置の色をまず決め、三角形の周および内部に該当するすべてのピクセルについて、描画の際の色を線形補間で決めています。そうするとこのようになります。すべてのピクセルでこれを並列で実行しています。なので高速です。
球やトーラスの場合であっても、一度に描画する三角形が多いだけで、やってることは同じです。もっともあれらの場合、そもそも大量の平面上の三角形という形に落とさないといけないんですが(射影......)、長くなるのでここでは解説しません。一つ言えることは、球にせよ、トーラスにせよ、ティーポットやウサギやドラゴンにせよ、三角形を大量に描画することで描画されている、ということです。
補足:三角形の位置と色について
補足:varyingについて
バーテックスシェーダでout vec3として定義されているのがvaryingです。同じ値がin vec3の形でフラグメントシェーダにおいて定義されています。
out vec3 vColor;
// ------- //
in vec3 vColor;
これはバーテックスシェーダの方で頂点ごとにその値が決められて、補間でフラグメントシェーダでの値が決まります。フラグメントシェーダはピクセルシェーダという別名を持ちます。これはピクセルごとに実行されるからです。補間はどのようにやるかというと、バーテックスシェーダで決めた位置に対する重心座標を計算します。
このように、三角形の面積比で重心座標が出ます。面積は足し算引き算掛け算で出るので、それと割り算ですね。平方根などは出てこないので高速です。こうして得られた重心座標を元に、それぞれの座標に割り当てられた、この場合は色ですが、それが補間されます。
P=\lambda_0 P_0 + \lambda_1P_1+\lambda_2P_2~~~~(\lambda_0+\lambda_1+\lambda_2=1),
補間後の色を$C$(vec3の値)とし、各座標の色を$C_0,C_1,C_2$とすると、
C=\lambda_0 C_0 + \lambda_1C_1+\lambda_2C_2
となります。全部これで補間されます。重心座標の計算は高校生でもできますが、高校数学がラスタライズという形でwebglに関わってくるのは面白いです。
なお、頂点の位置をいじってうち二つが重なるようにすると面積が消えて描画がされなくなるので試してみてください。三角形ができないのでは補間のしようがないです。
座標から面積を出す方法については割愛します。行列式で書くと分かりやすいです。調べれば色々出てきます。
ちなみに、ピクセルシェーダが実行されるのは三角形の周及び内部だけです。外側では実行されません。また、三角形になるのはドローコールがTRIANGLESだからです。この命令は最初から3つずつ点を取ってそれらの位置に基づいて一つずつ三角形をつくりなさいという命令です。ドローコールにもいろんなものがありますが、一番わかりやすいのでこれを採用しました。たとえばLINESの場合、二つずつ取って線を引きます。
アンチエイリアスしない場合
grの生成直後にこれを実行するとアンチエイリアスが実行されないです。
const gr = createGraphics(40, 40, WEBGL);
gr.setAttributes('antialias',false);
// grにRGB三角形を描画
// .........
その場合の描画結果はこちらです:
この場合、境界の背景との同化処理がされないので色がそのまま補間された状態で描画されます。この方が分かりやすいかもしれないです。こんな感じで各ピクセルの色が決まり、最後にアンチエイリアスで境界の透明度を調整しているようです。$P_0,P_1$についてはギリギリアウトで色が設定されていないですね。$P_2$はセーフのようです。興味深いです。ちなみに$P_0$は$(-1+1/80,1-1/80)$だとセーフになります:
蛇足ですが$1/80$というのは要するに今40x40でやっているので、マスの大きさの半分です。セルの真ん中が指定されるように、サイズの半分だけずらしているわけです。
おわりに
ここまでお読みいただいてありがとうございました。