Closed22

Babylon.jsのステンシルバッファで遊びたい

kiyukakiyuka

ステンシルバッファについて参考になりそうなリンク。Unityの記事はいっぱい出てくるので3D表現としては一般的?に使用されているみたい。

やっているのは、通常は手前にあるものが見えるけれども、マスクを掛けることで表示順を無視して描画する、みたいなことっぽい。

https://wgld.org/d/webgl/w038.html
https://zenn.dev/kento_o/articles/dd99c6c9e98d4a
https://nn-hokuson.hatenablog.com/entry/2017/05/02/185320

kiyukakiyuka

実際に触らないと分からないので、公式ドキュメントにあるサンプルを解読する。
https://playground.babylonjs.com/#CW5PRI#10

この部分でステンシルバッファを適用するか設定していそう。最初の引数がレンダリンググループ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);
kiyukakiyuka

これで穴の空いたキューブを作っている。四角を描画しているのではなく、平面を6つ作っている。そして穴の部分の球を6つ作っている。
makeSphereIntersectmakeSphereで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);
kiyukakiyuka

makeSphereIntersectをコメントアウトしたらこうなった。
わかりにくいけど球の表示が全て手前側に来てしまっている。本来赤の部分は平面が見えている箇所。

kiyukakiyuka

平面生成部分のコード。最初の部分は配置設定とかだから、関係あるのは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;
    };
kiyukakiyuka

こういうときは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の値が設定されている、とだけ理解しておく。

kiyukakiyuka

次は 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;
    };
kiyukakiyuka

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;
    };
kiyukakiyuka

マテリアルの方で深度テストいじってた。
sphereIntersectの方は色なしで見えないようにしている。やっぱりsphereIntersectは描画制御にだけ使用しているっぽい。

両方の球で depthFunction = BABYLON.Engine.GEQUAL を設定しているのは通常だと手前にあるものがみえる状態なのを、逆に奥側にあるときだけ描画するようにしている。これで平面の奥側にある球の内側が見えるようにしている。

    // sphereIntersect
    mat0.disableColorWrite = true;
    mat0.depthFunction = BABYLON.Engine.GEQUAL;

    // sphere 
    mat1.diffuseTexture = holeTexture;
    mat1.depthFunction = BABYLON.Engine.GEQUAL;
kiyukakiyuka

ここまでをまとめると

  • 平面 → 描画制御用の球 → 穴表現用の球 の3つを描画している
  • 平面は常にステンシルバッファの値が stencilRef でステンシルテストは常に合格(つまりは深度テストのみで描画制御?)
  • sphereIntersectは描画の制御用の球でstencilRef が同じときにステンシルテストが合格。-1してるのはよくわからない
  • 穴の球は、球の内側を描画するようにしていて、stencilRef が同じときにステンシルテストが合格
  • 2つの球は物体の奥側にあると描画される

うーん?なんとなく雰囲気はわかったけれども...

kiyukakiyuka

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);
kiyukakiyuka

ステンシルバッファって一つしかないのか。
https://wgld.org/d/webgl/w038.html のページのサンプルの補足を読んで気づいた。
コード見るとマテリアルごとに設定しているからてっきりそれぞれオブジェクトごとにステンシルバッファを持っているのかと勘違いしてた。

kiyukakiyuka

再度確認

  • planeは常にステンシルテストをパスして、値を stencilRef に設定する
  • sphereIntersectplaneと重なっている部分について表示され、その部分のステンシルバッファは stencilRef - 1 になる
  • sphereplaneと重なっていて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つの球について行い、奥側にあるものを表示するようにしている。

kiyukakiyuka

この設定は、ステンシルの設定をしているのではなくて、レンダリンググループ間でステンシルバッファ(と深度バッファ)をクリアするかの設定をしているってことか。
この設定をしないと、グループ0のレンダリングが終わるとステンシルバッファをクリアしてからグループ1のレンダリング処理を行ってしまう。おなじステンシルバッファを継続して使用するためにこの設定が必要。

    scene.setRenderingAutoClearDepthStencil(1, false, false, false);
    scene.setRenderingAutoClearDepthStencil(2, false, false, false);
kiyukakiyuka

まとめると多分こう。 stencilRef =3 の例。

  1. 平面の描画

  1. 表示制御用の球の描画
    わかりやすいようにdisableColorWrite = trueをコメントアウトして表示

  1. 穴用の球の描画

kiyukakiyuka

plane.material.stencil.mask = 0xFF; についてはコメントアウトしても結果が変わらなかった。
0x0にすると平面のみ描画されたので、おそらくステンシルバッファの書き込みをするかの判定をしているっぽい。それでデフォルトが0xFFなんだと思う。そのため今回のコードだとmaskの設定は特に意味はなさそう。

kiyukakiyuka

ステンシルバッファの設定の仕方でどうにかできないかと思って mask をもう少し詳しく見てた。
maskはステンシルバッファの書き込み判定をしているけど、それはビット単位で制御しているっぽい。
たとえば 0x3 だったら下2ビットのみが書き込み処理される。

ステンシルテストの方ではmaskは使用していない。

このスクラップは2023/12/21にクローズされました