Babylon.jsのステンシルバッファで遊びたい
Babylon.jsで面白いことできそうなものを見つけたので調べるメモ
ステンシルバッファについて参考になりそうなリンク。Unityの記事はいっぱい出てくるので3D表現としては一般的?に使用されているみたい。
やっているのは、通常は手前にあるものが見えるけれども、マスクを掛けることで表示順を無視して描画する、みたいなことっぽい。
実際に触らないと分からないので、公式ドキュメントにあるサンプルを解読する。
この部分でステンシルバッファを適用するか設定していそう。最初の引数がレンダリンググループIDで、今回だと2種類のステンシルバッファを使って描画を制御している。あとでメッシュに対してsphere.renderingGroupId = 2;
みたいな感じでどのIDに属するかを設定している。
// Do not clear stencil/depth information between meshes.
scene.setRenderingAutoClearDepthStencil(1, false, false, false);
scene.setRenderingAutoClearDepthStencil(2, false, false, false);
これで穴の空いたキューブを作っている。四角を描画しているのではなく、平面を6つ作っている。そして穴の部分の球を6つ作っている。
makeSphereIntersect
とmakeSphere
で2つあるのは何だろ?交差部分を作らないと穴の表現ができない感じだろうか。
// Our built-in 'ground' shape. Params: name, width, depth, subdivs, scene
makePlane("x", 3);
makePlane("-x", 5);
makePlane("-z", 7);
makePlane("z", 9);
makePlane("y", 11);
makePlane("-y", 13);
makeSphereIntersect("x", 3);
makeSphereIntersect("-x", 5);
makeSphereIntersect("-z", 7);
makeSphereIntersect("z", 9);
makeSphereIntersect("y", 11);
makeSphereIntersect("-y", 13);
makeSphere("x", 3);
makeSphere("-x", 5);
makeSphere("-z", 7);
makeSphere("z", 9);
makeSphere("y", 11);
makeSphere("-y", 13);
一つの面だけににするとこうなった。
makeSphereIntersect
をコメントアウトしたらこうなった。
わかりにくいけど球の表示が全て手前側に来てしまっている。本来赤の部分は平面が見えている箇所。
平面生成部分のコード。最初の部分は配置設定とかだから、関係あるのはplane.material.stencil
の下5行。この設定をうまいことすることで描画の制御をしているっぽいけれども...
const makePlane = (axis, stencilRef) => {
var mul = axis.charAt(0) == '-' ? -1 : 1;
if (mul < 0) axis = axis.substring(1);
var plane = BABYLON.MeshBuilder.CreatePlane("plane" + axis + (mul < 0 ? "n" : "p"), {size: 2}, scene);
plane.material = matp.clone("matp" + axis);
plane.position[axis] = mul;
if (axis == 'x') plane.rotation.y = -Math.PI/2 * mul;
if (axis == 'y') plane.rotation.x = Math.PI/2 * mul;
if (axis == 'z' && mul > 0) plane.rotation.y = Math.PI * mul;
plane.material.stencil.enabled = true;
plane.material.stencil.opStencilDepthPass = BABYLON.Engine.REPLACE;
plane.material.stencil.mask = 0xFF;
plane.material.stencil.func = BABYLON.Engine.ALWAYS;
plane.material.stencil.funcRef = stencilRef;
};
こういうときはChatGPT。
このコードの部分は、Babylon.jsを使用して平面のマテリアルにステンシル設定を適用しています。以下に各行の詳細な説明を記載します。
plane.material.stencil.enabled = true;
この行はステンシル機能を有効にします。これにより、この平面のマテリアルに対してステンシル操作が行われるようになります。
plane.material.stencil.opStencilDepthPass = BABYLON.Engine.REPLACE;
この行は、ステンシルと深度テストの両方に合格した場合にステンシルバッファの値を置き換える操作を定義します。
BABYLON.Engine.REPLACE
はステンシルバッファの現在の値をfuncRef
(後述)で指定された値に置き換えることを意味します。plane.material.stencil.mask = 0xFF;
ステンシルバッファのどのビットがステンシル操作によって影響を受けるかを定義するマスクを設定します。
0xFF
はすべてのビットが操作の対象であることを意味します(つまり、8ビット全てが影響を受けます)。plane.material.stencil.func = BABYLON.Engine.ALWAYS;
ステンシルテストの条件を定義します。
BABYLON.Engine.ALWAYS
はステンシルテストを常に合格させることを意味し、これにより深度テストの結果に関わらず、ステンシル操作が常に実行されます。plane.material.stencil.funcRef = stencilRef;
ステンシルテストで使用される参照値を設定します。この値は
opStencilDepthPass
で指定された操作が実行される条件(この場合はALWAYS
なので常に実行されます)と、REPLACE
操作でステンシルバッファに設定される新しい値として使用されます。要約すると、このコードはステンシルテストを常に合格させ、深度とステンシルテストに合格したピクセルのステンシル値を
stencilRef
の値に置き換えるように平面のマテリアルを設定しています。これは、後に描画されるオブジェクトのレンダリングを制御するために使用されます。この技術は、特定の形状を「切り抜く」ために使用されることが多いです。
いまいちまだわかってないけど、前提として、描画する前に深度テストとステンシルテストを行っていて、両方に合格すると描画が行われているっぽい。深度テストは単純に手前にある物体が見えるという話。ステンシルテストが今回調べているマスク処理みたいな話。
ひとまずここでは、平面のステンシルは常にstencilRef
の値が設定されている、とだけ理解しておく。
次は makeSphereIntersect
ステンシルテストの合格条件が stencilRef
と同じであることで、そのとき stencilRef - 1
がステンシルの値に設定される、だと思う。
const makeSphereIntersect = (axis, stencilRef) => {
// 省略
sphereIntersect.material.stencil.enabled = true;
sphereIntersect.material.stencil.opStencilDepthPass = BABYLON.Engine.DECR;
sphereIntersect.material.stencil.func = BABYLON.Engine.EQUAL;
sphereIntersect.material.stencil.funcRef = stencilRef;
};
makeSphere
ステンシルテストの合格条件が stencilRefと同じであることで、ステンシルバッファの変更は行われない(mask = 0x0
)。ステンシルバッファの値は特に指定していないからおそらく0
。
この球についてはsideOrientation: BABYLON.Mesh.BACKSIDE
で球の内側だけ描画するようにしている。そうすることで穴の表現に使用している。
const makeSphere = (axis, stencilRef) => {
// 省略
var sphere = BABYLON.MeshBuilder.CreateSphere(
"sphere" + axis + (mul < 0 ? "n" : "p"),
{ diameter:1, segments: 16, arc: 0.5, sideOrientation: BABYLON.Mesh.BACKSIDE },
scene);
// 省略
sphere.material.stencil.enabled = true;
sphere.material.stencil.mask = 0x0;
sphere.material.stencil.func = BABYLON.Engine.EQUAL;
sphere.material.stencil.funcRef = stencilRef;
};
マテリアルの方で深度テストいじってた。
sphereIntersect
の方は色なしで見えないようにしている。やっぱりsphereIntersect
は描画制御にだけ使用しているっぽい。
両方の球で depthFunction = BABYLON.Engine.GEQUAL
を設定しているのは通常だと手前にあるものがみえる状態なのを、逆に奥側にあるときだけ描画するようにしている。これで平面の奥側にある球の内側が見えるようにしている。
// sphereIntersect
mat0.disableColorWrite = true;
mat0.depthFunction = BABYLON.Engine.GEQUAL;
// sphere
mat1.diffuseTexture = holeTexture;
mat1.depthFunction = BABYLON.Engine.GEQUAL;
ここまでをまとめると
- 平面 → 描画制御用の球 → 穴表現用の球 の3つを描画している
- 平面は常にステンシルバッファの値が
stencilRef
でステンシルテストは常に合格(つまりは深度テストのみで描画制御?) -
sphereIntersect
は描画の制御用の球でstencilRef
が同じときにステンシルテストが合格。-1してるのはよくわからない - 穴の球は、球の内側を描画するようにしていて、
stencilRef
が同じときにステンシルテストが合格 - 2つの球は物体の奥側にあると描画される
うーん?なんとなく雰囲気はわかったけれども...
sphereIntersect.material.stencil.opStencilDepthPass = BABYLON.Engine.DECR;
で-1しているのは、BABYLON.Engine.INCR
にしても同じ結果だった。
各面でstencilRef
の値が奇数に設定されているのは、-1したときに値がかぶらないようにしているのだと思う。つまり描画制御用の壁は他と値が違うことに意味があるみたい。
makePlane("x", 3);
makePlane("-x", 5);
makePlane("-z", 7);
makePlane("z", 9);
makePlane("y", 11);
makePlane("-y", 13);
ステンシルバッファって一つしかないのか。
https://wgld.org/d/webgl/w038.html のページのサンプルの補足を読んで気づいた。
コード見るとマテリアルごとに設定しているからてっきりそれぞれオブジェクトごとにステンシルバッファを持っているのかと勘違いしてた。
再度確認
-
plane
は常にステンシルテストをパスして、値をstencilRef
に設定する -
sphereIntersect
はplane
と重なっている部分について表示され、その部分のステンシルバッファはstencilRef - 1
になる -
sphere
はplane
と重なっていてsphereIntersect
と重なっていない部分が表示される
const makePlane = (axis, stencilRef) => {
// stencilRef の値をステンシルバッファに設定する
plane.material.stencil.opStencilDepthPass = BABYLON.Engine.REPLACE;
plane.material.stencil.funcRef = stencilRef;
// ステンシルテストは常にパスする(表示する)
plane.material.stencil.func = BABYLON.Engine.ALWAYS;
};
const makeSphereIntersect = (axis, stencilRef) => {
// 表示されていれば(ステンシルテストをパスしていれば)ステンシルバッファを -1 する
// -> planeと重なっている場所を区別して sphere の表示制御をするため
sphereIntersect.material.stencil.opStencilDepthPass = BABYLON.Engine.DECR;
// ステンシルバッファが stencilRef と同じであればステンシルテストをパスする(表示される)
// -> plane と重なっている部分が表示される
sphereIntersect.material.stencil.func = BABYLON.Engine.EQUAL;
sphereIntersect.material.stencil.funcRef = stencilRef;
};
const makeSphere = (axis, stencilRef) => {
// ステンシルバッファが stencilRef と同じであればステンシルテストをパスする(表示される)
// -> plane と重なっている部分が表示される
sphere.material.stencil.func = BABYLON.Engine.EQUAL;
sphere.material.stencil.funcRef = stencilRef;
};
そして renderingGroupId
の設定で plane
が0、sphereIntersect
が1、 sphere
が2なので上記の順にステンシルバッファの設定も行われている。
また、そのままだと平面が一番手前にあるので2つの球は表示されないため、depthFunction = BABYLON.Engine.GEQUAL
の設定を2つの球について行い、奥側にあるものを表示するようにしている。
この設定は、ステンシルの設定をしているのではなくて、レンダリンググループ間でステンシルバッファ(と深度バッファ)をクリアするかの設定をしているってことか。
この設定をしないと、グループ0のレンダリングが終わるとステンシルバッファをクリアしてからグループ1のレンダリング処理を行ってしまう。おなじステンシルバッファを継続して使用するためにこの設定が必要。
scene.setRenderingAutoClearDepthStencil(1, false, false, false);
scene.setRenderingAutoClearDepthStencil(2, false, false, false);
まとめると多分こう。 stencilRef =3
の例。
- 平面の描画
- 表示制御用の球の描画
わかりやすいようにdisableColorWrite = true
をコメントアウトして表示
- 穴用の球の描画
plane.material.stencil.mask = 0xFF;
についてはコメントアウトしても結果が変わらなかった。
0x0
にすると平面のみ描画されたので、おそらくステンシルバッファの書き込みをするかの判定をしているっぽい。それでデフォルトが0xFF
なんだと思う。そのため今回のコードだとmask
の設定は特に意味はなさそう。
遊んでるんだけど球が重なった部分に表面が表示されてしまう。
ステンシルバッファの設定の仕方でどうにかできないかと思って mask
をもう少し詳しく見てた。
mask
はステンシルバッファの書き込み判定をしているけど、それはビット単位で制御しているっぽい。
たとえば 0x3
だったら下2ビットのみが書き込み処理される。
ステンシルテストの方ではmask
は使用していない。
うーん
記事も書いたことだし楽しめたので終わり!