はじめに
はじめましての人ははじめまして。そうでない人はこんばんは。週刊p5.jsのお時間です。
p5.jsは日々進化しており、いろんな機能が追加されていますが、実は1系の時点で導入されていたのに見過ごされている機能もあるのです。それを紹介したいと思います。computeNormalsの追加オプション(SMOOTH/FLAT)です。p5.Geometryで作ったジオメトリに法線を付与する機能ですが、SMOOTHを使うことで従来の処理とは異なる方法で法線が付与される、というお話です。
computeNormals:
場合によっては重いかもしれないので注意して閲覧してください。
実装された経緯は次のissueが参考になります。
issue:
pull request:
よくわかんないですが、おそらく正確な法線計算をするには重複する頂点を排除しなければならないと、そういうことになったのでしょう。それで、そういう処理です。
見た方が早い
というわけで早速見てみたいんですが、法線を可視化するにあたりWebGLの線描画、要するにlinesを使いたいんですね。しかしあれはp5には実装されていません。p5で線を引こうとすると、拡大縮小で大きさが変わってしまう上に激重の例の「線」(という名の複雑怪奇な「面」)を描画しないといけなくなってしまいます。そういうわけなので、今回はこっちのライブラリを援用して中身に迫ることにしましょう。
buildGeometry
build...bouyild...bouyzel...ぽこぽけ...ブイゼル...会いたい...やりたい...
で、buildGeometryです。これを使うと、p5のプリミティブからp5.Geometryをサクッと生成できるので、使います。
例を見ると分かりますがp5のconeをベースにしたp5.Geometryが生成されてますね。まあこんな感じですね。boxでいいですね。早速作ってみましょう。
const m = buildGeometry(()=>{
box(1);
});
これでboxになります。ずいぶん簡単です。それで、中身を見てみます。具体的には頂点の個数と法線の個数とuvの個数です。uvは列挙で2個ずつ入っているので長さを2で割ります。
// 個数
console.log(m.vertices.length); // 24
console.log(m.vertexNormals.length); // 24
console.log(m.uvs.length/2); // 24
すべて24です。この24というのは面の数x4という認識で大丈夫です。つまりすべての面は4つの頂点からなります。
これをこっちの機構で描画するにはVAOWrapperのscanとかいう裏技を使います。こうするとそのときのvertexAttributeArrayからたちどころにVAOができます。それで描画します。
それと同時に、法線も白い線で描画します。これはp5の機構ではどうしようもないので、NoLightRender3Dっていうこっちのクラスで頂点と法線でサクッと作ります。こんな感じになりました。
頂点から3本の線が出ていますね。面ごとに法線を計算しているので、こうなります。フラットというのはそういう意味ですね。実はcomputeNormalsのデフォルトはFLATなので、普通にcomputeNormalsで計算するとこうなります。FLATと名前は付いていますが、面同士の隣接は普通に計算に考慮されています。要するにFLATと名前が付いただけの「従来の処理」です。
それではSMOOTHを見てみましょう。
m.computeNormals(SMOOTH);
実行結果:
3本あった法線が1本になっています。きれいですね。このような見た目になります。これがFLATとSMOOTHの違いです。
トーラスの場合はこんな感じですね。
これがFLATの場合です。隅っこから4本の線が伸びているのが分かるでしょうか。実はトーラスのジオメトリは境界を接合していません。接合していないがゆえに、法線を面に基づいてスムースに計算しようとするとこのようにずれが出ます。この点はsayoさんも次の記事で指摘されていますね。
そこでSMOOTHです。
すべて1本になりました。このように、頂点をまとめることで法線が綺麗に計算できるようになりますね。
uvはどこへいった?
この処理の概要は次の通りです。まず特定の閾値(レファレンスにはroundToPreci...とかなんとか)に基づいて頂点をまとめて1つにします。これにより頂点が減ります。重複頂点がまとめられることで、面に基づいた法線の計算を正確に実行することができるようになります。上記のsayoさんの記事でもそうやっていますね。
しかし頂点がこれによりまとめられると、同じ位置の他のアトリビュートはどうなるでしょうか?p5.Geometryのアトリビュートはindexによって間接的に結び付けられているため、並び順がすべてです。並びが異なれば、異なるジオメトリになってしまいます。法線は良いでしょう。再計算するわけですから。他のアトリビュートは?たとえば同じ位置のUVが(0,0)と(1,0)で異なる場合どうなるのでしょうか。(0.5,0)になるのか?
ここ、どうしているかというと、実は、無視しています。
...
console.log(m.vertices.length); // 8
console.log(m.vertexNormals.length); // 8
console.log(m.uvs.length/2); // 24
マージしたboxで調べてみると、頂点と法線は8に減っていますが、uvは24のままです。この場合どうなるかというと、uvはマージする前の8つが順番に新しい8つに結び付けられます。その結果UVがどうなるかは、あまり想像したくないところです。
とりあえず雑にuvチェック用の画像を作ろう。
// テクスチャ作っといてよ。
const offscreen = createOffscreen(512,512);
const ctx = offscreen.getContext('2d');
const gd0 = ctx.createLinearGradient(0,0,512,0);
const gd1 = ctx.createLinearGradient(0,0,0,512);
gd0.addColorStop(0,'red');
gd0.addColorStop(1,'blue');
gd1.addColorStop(0,`rgb(180,180,180)`);
gd1.addColorStop(1,'black');
ctx.fillStyle = gd1;
ctx.fillRect(0,0,512,512);
ctx.fillStyle = gd0;
ctx.globalCompositeOperation = 'overlay';
ctx.fillRect(0,0,512,512);
ctx.font = 'italic 24px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'white';
ctx.globalCompositeOperation = 'source-over';
for(let y=0;y<8;y++){
for(let x=0;x<8;x++){
ctx.fillText(`${x}${y}`, x*64+32, y*64+32);
}
}
// 4番でいいか。
gl.activeTexture(gl.TEXTURE4);
const tx = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tx);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 512, 512, 0, gl.RGBA, gl.UNSIGNED_BYTE, offscreen);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
オフスクリーンキャンバスという概念があります。キャンバスエレメントの描画用バッファとしての機能だけを抽出して作られているもので、DOM系の機能は一切持ちません。ただのバッファです。コンテキストを取得でき、描画も普通にできますが、DOMではないので、可視化するにはdrawImage(2D)やtexImage2D(WebGL)が必要です。とりあえず雑に2Dで作って4番にぶち込んでおきました。さて、とりあえずFLATで作ったキューブで様子を見てみましょう。
draw = () => {
background(0);
cs.update();
l3d.useProgram('texture');
l3d.setUniform('uTex', 4);
l3d.setMatrices();
l3d.lightOff();
l3d.setLights();
vao.bind();
vao.drawElements('triangles');
vao.unbind();
n3d.useProgram();
n3d.setMatrices();
vaoLine.bind().drawElements('lines').unbind();
l3d.flush();
}
上下が逆ですがp5のuvの付け方のせいなのでとりあえず無視です。きちんと全部の面でただしく描画されています。p5のboxのuvは実はすべての面に同じ4種類のuvが付与されています。ちょっと考えればわかりますが、この条件ですべての同じ位置の頂点グループに同じUVを割り当てるのは不可能です。それはそれとして、SMOOTHで見てみます。
当然壊れてしまいます。
以上のスケッチは次のコードで遊んでいます。
こっちのシェーダーだから問題が発生するんだろうという考えを持ってしまう人のためにp5-Onlyのサンプルを用意しました。
UVだけでなく、頂点色も実は無視されています。法線計算のために頂点をマージするという考え方は、法線計算が目的であれば合理的です。しかしUV計算のためには同じ位置の頂点が異なるUVの値を持つ必要がありますから、UV彩色しようというのであれば合理的ではありません。頂点色であれば、同じ位置の頂点に同じ色を付与することでギリギリこの問題を回避できる可能性がありますが、UVはそうはいかないのです。
もちろん、正確な法線計算のためにUVを犠牲にするとか、そもそもUV彩色で法線を使う必要は無いというなら、何の問題も起きません。問題は常に、ユースケースベースで起きるので、ユースケースが無ければ、議論の余地はないですね。
議論の余地が無いなら、イシューもコミットも不要ですね。
おわりに
今回もすっきりまとめることができました。ここまでお読みいただいてありがとうございました。






