はじめに
wgldさんの記事:
ステンシルバッファでアウトライン
を参考にしてアウトラインの描画をします。webGLです。アウトラインとは立体の輪郭のことです。上記のサイトでは、法線を用いたアウトラインの描画方法が紹介されているので、これをやろうと思います。上記のサイトではトーラスで紹介されているのでトーラスでやります。
方針
法線方向に描画したい立体を膨張させてべた塗りで描画します。色は輪郭の色とします。黒とします。で、そのあと深度値をクリアします。クリアしてからメインの描画をします。そうするとアウトラインっぽくなります。トーラスは4つ描画しましょう。
コード全文(ナイーブな実装)
let loopFunction = () => {};
function setup() {
createCanvas(400, 400, WEBGL);
const gl = this._renderer.GL;
const sh = baseMaterialShader().modify({
'vertexDeclarations':`
uniform float uExpand;
`,
'vec3 getLocalPosition':`(vec3 p){
p += aNormal * uExpand;
return p;
}`
});
noStroke();
const positions = [[100,0,0],[0,100,0],[-100,0,0],[0,-100,0]];
const axes = [];
for(let i=0; i<4; i++){ axes.push(p5.Vector.random3D()); }
const drawGeometry = () => {
push();
for(let i=0; i<4; i++){
push();
translate(...positions[i]);
rotate(frameCount*TAU/80, axes[i]);
torus(40, 20, 60, 24);
pop();
}
pop();
}
loopFunction = () => {
orbitControl(1,1,1,{freeRotation:true});
background(255);
shader(sh);
fill(0);
sh.setUniform("uExpand", 0.08);
noLights();
drawGeometry();
gl.clear(gl.DEPTH_BUFFER_BIT);
resetShader();
ambientLight(128);
directionalLight(128,128,128,-1,-1,-1);
directionalLight(128,128,128,1,1,1);
specularMaterial(32);
fill("gold");
drawGeometry();
}
}
function draw() {
loopFunction();
}
実装の内容(ナイーブな実装)
baseMaterialShaderを使って、aPositionをaNormalでその方向にちょっとだけ膨らませる処理をしています。これはとても便利ですね。もう使い方には慣れたでしょうか(色々記事書いたので)。で、位置と回転はまとめて配列の形で用意しています。それを使ってジオメトリー描画部分を関数の形でまとめています。特に難しいことはしていないですね。
最初の描画ではnoLights()を用いることで単色描画をしています。色は黒です。Expandの割合を0.08としました。これで膨らみますね。描画した後、レンダラーを使って深度値をクリアしています。これをやらないと最初のトーラスに本体がすっぽりかぶさってしまうので注意しましょう。
gl.clear(gl.DEPTH_BUFFER_BIT);
それからメインの描画を実行しています。できました。
できました。上記の記事の方法はこれで実現できます。単独なら問題ないし、複数でも、このようにアウトラインがつながってしまいますが、まあできています。内容的には上記の記事と全く同じことをしています。何が言いたいかというと、
ステンシルバッファは要らない
です。
複数のオブジェクトが重なる場合にアウトラインをどう考えるのかはそれ自体答えのない問題です。手前のオブジェクトの輪郭がきちんと区別して見えるべきなのか、それとも全体がくっついてもいいのかということです。別にくっついていてもいいはずですが、分かれている方がいいなら、ステンシルバッファを使うのは一つの方法としてありだと思います。
後半では、そのやり方を説明します。
コード全文(ステンシルを使う)
// stencil outline
// https://wgld.org/d/webgl/w039.html
let loopFunction = () => {};
function setup() {
createCanvas(400, 400, WEBGL);
const gl = this._renderer.GL;
const sh = baseMaterialShader().modify({
'vertexDeclarations':`
uniform float uExpand;
`,
'vec3 getLocalPosition':`(vec3 p){
p += aNormal * uExpand;
return p;
}`
});
noStroke();
const setStencil = (i) => {
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
gl.stencilFunc(gl.ALWAYS, i, ~0);
}
const useStencil = (i) => {
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
gl.stencilFunc(gl.EQUAL, i, ~0);
}
const positions = [[100,0,0],[0,100,0],[-100,0,0],[0,-100,0]];
const axes = [];
for(let i=0; i<4; i++){ axes.push(p5.Vector.random3D()); }
const drawGeometry = (stencil) => {
push();
for(let i=0; i<4; i++){
push();
translate(...positions[i]);
rotate(frameCount*TAU/80, axes[i]);
stencil(i+1);
torus(40, 20, 60, 24);
pop();
}
pop();
}
gl.clearColor(1,1,1,1);
loopFunction = () => {
orbitControl(1,1,1,{freeRotation:true});
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT|gl.STENCIL_BUFFER_BIT);
gl.enable(gl.STENCIL_TEST); // p5なら必須
shader(sh);
fill(0);
sh.setUniform("uExpand", 0.08);
noLights();
drawGeometry(setStencil);
gl.clear(gl.DEPTH_BUFFER_BIT);
resetShader();
ambientLight(128);
directionalLight(128,128,128,-1,-1,-1);
directionalLight(128,128,128,1,1,1);
specularMaterial(32);
fill("gold");
drawGeometry(useStencil);
}
}
function draw() {
loopFunction();
}
実装の内容(ステンシルを使う)
drawGeometryに引数を用意しました。引数はステンシルをいじる関数です。具体的には評価の方法を決めるstencilFuncと、処理の内容を決めるstencilOpを実行するものです。処理の内容が整数に依っているのでこのような形になっています。
具体的な内容は上の記事の方が詳しいかもしれないので、ざっと説明します。まずsetStencilですが、これは描画範囲を指定された整数のステンシル値で埋め尽くすものです。ただし「ステンシルテストと深度テストを両方クリアした場合だけ」です。ただこのコードの場合ステンシルテストはALWAYSなので何もしていません。深度だけです。それで第三引数だけREPLACE(その数にせよ)になっています。その数とはstencilFuncの第二引数です。第三引数はオール1のデフォルトです。あんま気にしなくていいです。
つぎにuseStencilですが、これはステンシル値をいじっちゃまずいのですべてデフォルトのKEEPにしていますね。んでEQUALを使うことでその値のところにだけ描画されるようにしています。その値というのはstencilOpの第二引数で決めています。対応する同じ値ということですね。たとえば3を描き込むのに使ったトーラスの輪郭に3番トーラスを描いています。
インターバルで深度値をクリアしているのはさっきと一緒です。
gl.clear(gl.DEPTH_BUFFER_BIT);
なお上記のwgldの記事ではこの直後のタイミングで0のところにだけ背景を描画していますね。
実行結果:
このようにアウトラインを分けることができました。なおトーラス同士は意図的に重ならないようにしてあります。めんどくさいからです。
重ねて言いますが、別にどっちが正解とかではないでしょう。全部まとめて一つのアウトラインにしたい場合もあるはずです。選択肢が多いのは良い事だというだけの話です。
おわりに
wgldの記事が深度値や重なりについて全く考慮しておらず、自分もそれを素直に信じて以前コードを書いてたんですが、これステンシルじゃなくてもできるじゃんって疑念がずっと消えなくてもやもやしていたのでした。今回それを解消できたのでよかったですね。
ここまでお読みいただいてありがとうございました。
注意:enable(gl.STENCIL_TEST)は省略できない
なお、ステンシルテストを恒常ループ内で有効化していますが...
gl.enable(gl.STENCIL_TEST);
これは外で実行することはできません。なぜなら、
p5の恒常ループではループの最初に常にstencil testをdisableにしているから
です。ゆえに、毎フレーム使いたければ一回実行するだけではダメで、毎フレーム実行する必要があります。仕組みを知っている人ほど引っかかりやすいポイントなので、押さえておきましょう。
追記:clearDepth
p5にはclearDepthという深度値をクリアする関数があるそうです。ただまあ、深度値知ってるならわざわざこんなもの使わなくてもコンテキスト使えばいいと思います。関数はどんなに小さくてもブラックボックスですから、コンテキストで出来ることはできるだけそうしたいところです。
応用:輪郭だけ別画像で色を付ける
応用として、輪郭部分だけ色を変える方法を紹介します。こういうことをやるのであれば、くっつき版のアウトラインでもステンシルを使う必要性が出てくる可能性があります(どっちもステンシルで出来るので)。
stencil outline 2
let loopFunction = () => {};
function setup() {
const vs =
`#version 300 es
const vec2[4] cPos = 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 = cPos[gl_VertexID];
vUv = vec2(0.5 + p.x*0.5, 0.5 - p.y*0.5);
gl_Position = vec4(p, -1.0, 1.0);
}
`;
const fs =
`#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;
void main(){
fragColor = vec4(vUv, 1.0, 1.0);
}
`;
createCanvas(400, 400, WEBGL);
const gl = this._renderer.GL;
const pg = createShaderProgram(gl, {vs:vs, fs:fs});
const sh = baseMaterialShader().modify({
'vertexDeclarations':`
uniform float uExpand;
`,
'vec3 getLocalPosition':`(vec3 p){
p += aNormal * uExpand;
return p;
}`
});
noStroke();
const setStencil = (i) => {
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
gl.stencilFunc(gl.ALWAYS, 8+i, ~0);
}
const useStencil = (i) => {
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
gl.stencilFunc(gl.EQUAL, i, 7);
}
const positions = [[100,0,0],[0,100,0],[-100,0,0],[0,-100,0]];
const axes = [];
for(let i=0; i<4; i++){ axes.push(p5.Vector.random3D()); }
const drawGeometry = (stencil) => {
push();
for(let i=0; i<4; i++){
push();
translate(...positions[i]);
rotate(frameCount*TAU/80, axes[i]);
stencil(i+1);
torus(40, 20, 60, 24);
pop();
}
pop();
}
gl.clearColor(1,1,1,1);
loopFunction = () => {
orbitControl(1,1,1,{freeRotation:true});
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT|gl.STENCIL_BUFFER_BIT);
gl.enable(gl.STENCIL_TEST);
shader(sh);
fill(0);
sh.setUniform("uExpand", 0.12);
noLights();
drawGeometry(setStencil);
gl.clear(gl.DEPTH_BUFFER_BIT);
resetShader();
ambientLight(128);
directionalLight(128,128,128,-1,-1,-1);
directionalLight(128,128,128,1,1,1);
specularMaterial(32);
fill("silver");
drawGeometry(useStencil);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
gl.stencilFunc(gl.LEQUAL, 8, ~0);
gl.useProgram(pg);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.useProgram(null);
}
}
function draw() {
loopFunction();
}
// ------------------------ common webGL util ------------------------ //
function createShaderProgram(gl, params = {}){
/* 省略 */
}
// 以下省略
後半で描画しているのは画面全体を覆う板ポリです。
gl.useProgram(pg);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.useProgram(null);
ノンアトリビュート描画です。自由にやりたかったので汎用のwebGL utilを使いました。自分がwebGLの解説で使いまわしてるやつです。リンク先にも貼ってあるので詳細は割愛します。基本を知っていれば何でもできます。ドローコール一本で板ポリが出現するのかっこよくないですか?
本題に入ります。変更点は一部です。まずsetStensil:
const setStencil = (i) => {
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
gl.stencilFunc(gl.ALWAYS, 8+i, ~0);
}
トーラスの個数が7個以下なので使える手法ということを注意しておきます。1,2,3,4の代わりに8を足して9,10,11,12を置くわけです。ついでuseStencil:
const useStencil = (i) => {
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
gl.stencilFunc(gl.EQUAL, i, 7);
}
7とmaskを取って判定することで1,2,3,4との判定になるので1,2,3,4をrefに使えます。またこれにより描画部分を1,2,3,4で置き換えています。こうすることで輪郭部分の9,10,11,12が据え置きとなります。この時点で輪郭部分は8以上であることが保証されています。
そこで最後の板ポリ描画の前に「8以上なら描画せよ」とステンシル命令を出します。
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
gl.stencilFunc(gl.LEQUAL, 8, ~0);
この場合はLESSでもいいです。問題ないと思います。これで輪郭だけ描画するようになります。結果:
このように、輪郭だけ板ポリ芸の色になりました、面白いですね。
ステンシルバッファの「発明」は他にもたくさんあるらしいんですが、ググってもなかなかヒットしないですね...暇があったら研究できればと思います。今回のこれは自分で発見しましたが、他人のコードを参考にした方が勉強になると思います。
さらなる追記:一つ目の例ではdepthMaskを使えない
二つ目のステンシルを駆使する方法ではdepthが効果的に使われるので、depthMaskを使う必要はないんですが、一つ目に関してはdepthを記録する必要がないため、途中でクリアしなくてもdepthMaskを使えばいいだろうと思う人がいるかもしれません。wgldのやってることは一つ目まんまですからそういうやり方もありです。しかしp5の場合それはできない仕組みになっています。なぜかというと、
どういうわけかカスタムフィルシェーダではdepthMaskを強制的にtrueにされる
からです。具体的にはここです:
https://github.com/processing/p5.js/blob/main/src/webgl/material.js#L3218
これが導入されたのはだいぶ前です。読んでも導入理由がさっぱり分かりませんでした。
fix rendering bug with texture and alpha #2082
そういうわけでカスタムシェーダを使う限りdepthMaskが機能することはありません。まあ、p5の限界なので、使うのはやめておきましょう。カスタムじゃなければ機能するようです。通常描画という意味です。