【Babylon.js 2022】#BabylonJS で SVGフィルターを用いた溶けるような見た目のエフェクト（Gooey Effect ）を試してみる

## はじめに
この記事は、[Babylon.js Advent Calendar 2022](https://qiita.com/advent-calendar/2022/babylonjs) の 19日目の記事です。
（自分が今月に入ってから、アドベントカレンダー用に書いた記事としては、33記事目となる記事だったりします）

この記事では、SVGフィルターを用いた溶けるような見た目のエフェクト（Gooey Effect）を扱います。

## Gooey Effect とは？
Gooey Effect　とは、以下のページなどでも紹介されている、溶けるような見た目のエフェクトです。「Gooey」という単語の意味は、「ねばねばした、べたべたした、くっつく」というもののようです。

●The Gooey Effect | CSS-Tricks - CSS-Tricks
　https://css-tricks.com/gooey-effect/
![Gooey Effect](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/2fd5cdb3-a8ec-32cc-bc33-03de1845f1da.png)

ちなみに、上記のものが動いてる様子をアニメーションにしたものはこちらです。
![Gooey Effect　のアニメーション](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/a160f29d-c226-cef9-4b25-115add01f0a6.gif)

### 自分が p5.js と組み合わせた使った過去事例（一部抜粋）
この Gooey Effect を、以前、ブラウザ上の描画に適用したことがあります。
自分が普段、ブラウザ上のキャンバスへの描画によく用いるライブラリに「p5.js」があるのですが、その p5.js を使った描画で試したことがありました。

実装方法は、上記の「CSS-Tricks」でも書かれていた、「SVGフィルターの feGaussianBlur・feColorMatrix を組み合わせる」というやり方です。事例の一部を以下に動画で示します（※ 途中、エフェクトの効果が分かりやすいよう、意図的にエフェクトを解除している部分を作っているものがあります）。

https://twitter.com/youtoy/status/1551231753091977216

https://twitter.com/youtoy/status/1564087430147612674

https://twitter.com/youtoy/status/1568761911751344128

その際に用いた「SVGフィルター」は、キャンバス要素にフィルターをかける形で使っていました。

Babylon.js もキャンバス要素を使った描画を行っているので、このエフェクトが適用できるはず！？

## Babylon.js で　Gooey Effect
Babylon.js で　Gooey Effect を試してみます。

### 元にする内容
この Gooey Effect を適用する対象を、選定しします。

以下の公式ドキュメントの「An Introduction To The Solid Particle System」に掲載されているサンプルが、試すのにはちょうど良さそうでした。

https://doc.babylonjs.com/features/featuresDeepDive/particles/solid_particle_system/sps_intro

https://playground.babylonjs.com/#GLZ1PX#2
![playground の #GLZ1PX#2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/f601f88a-5db3-959c-9d6c-79b2766e24d2.png)

こちらを使っていくことにします。
個人的には、今回くらいの規模感だと、1つの HTML のファイルにまとまっていたほうが扱いやすい場合があるため、その形にまとめることにします。
上記サンプルに関して、コメントは除いてしまったり、「var」が使われている部分は「const」や「let」を使うような形に書きかえたりなどもして、以下を準備しました。

```html
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Babylon.js sample code</title>
    <script src="https://preview.babylonjs.com/babylon.js"></script>
    <script src="https://code.jquery.com/pep/0.4.1/pep.js"></script>
    <style>
      html,
      body {
        width: 100%;
        height: 100%;
        overflow: hidden;
      }

      #renderCanvas {
        width: 100%;
        height: 100%;
        touch-action: none;
      }
    </style>
  </head>
  <body>
    <canvas id="renderCanvas"></canvas>
    <script>
      const canvas = document.getElementById("renderCanvas");

      const startRenderLoop = function (engine, canvas) {
        engine.runRenderLoop(function () {
          if (sceneToRender && sceneToRender.activeCamera) {
            sceneToRender.render();
          }
        });
      };

      let engine = null;
      let scene = null;
      let sceneToRender = null;
      const createDefaultEngine = function () {
        return new BABYLON.Engine(canvas, true, {
          preserveDrawingBuffer: true,
          stencil: true,
          disableWebGL2Support: false,
        });
      };
      const createScene = () => {
        const scene = new BABYLON.Scene(engine);

        const camera = new BABYLON.ArcRotateCamera(
          "ArcRotateCamera",
          -Math.PI / 2,
          Math.PI / 2.2,
          50,
          new BABYLON.Vector3(0, 0, 0),
          scene
        );
        camera.attachControl(canvas, true);
        const light = new BABYLON.HemisphericLight(
          "light",
          new BABYLON.Vector3(0, 1, 0),
          scene
        );

        const SPS = new BABYLON.SolidParticleSystem("SPS", scene);
        const sphere = BABYLON.MeshBuilder.CreateSphere("s", {});
        const poly = BABYLON.MeshBuilder.CreatePolyhedron("p", { type: 2 });
        SPS.addShape(sphere, 20);
        SPS.addShape(poly, 120);
        SPS.addShape(sphere, 80);
        sphere.dispose();
        poly.dispose();

        const mesh = SPS.buildMesh();

        SPS.initParticles = () => {
          for (let p = 0; p < SPS.nbParticles; p++) {
            const particle = SPS.particles[p];
            particle.position.x = BABYLON.Scalar.RandomRange(-20, 20);
            particle.position.y = BABYLON.Scalar.RandomRange(-20, 20);
            particle.position.z = BABYLON.Scalar.RandomRange(-20, 20);
          }
        };

        SPS.initParticles();
        SPS.setParticles();

        const mat = new BABYLON.StandardMaterial("mat");
        mat.diffuseColor = BABYLON.Color3.Green();

        mesh.material = mat;

        return scene;
      };
      window.initFunction = async function () {
        const asyncEngineCreation = async function () {
          try {
            return createDefaultEngine();
          } catch (e) {
            console.log(
              "the available createEngine function failed. Creating the default engine instead"
            );
            return createDefaultEngine();
          }
        };

        engine = await asyncEngineCreation();
        if (!engine) throw "engine should not be null.";
        startRenderLoop(engine, canvas);
        scene = createScene();
      };
      initFunction().then(() => {
        sceneToRender = scene;
      });

      window.addEventListener("resize", function () {
        engine.resize();
      });
    </script>
  </body>
</html>
```

この内容の HTMLファイルであれば、サーバーが必須の処理を含んでいないこともあり、HTMLファイルをそのままブラウザにドラッグアンドドロップするだけで、ローカルサーバーなどを用意することなく実行できます。
※ URL が「file:///...」となる形で、実行できます
![HTMLをそのまま開く](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/9b1aa3e4-bd85-f89c-a3d3-664d9f6f999a.png)

上記の HTML がブラウザ上で動作するのを確認した上で、次へ進みます。

### 背景を透過させて Gooey Effect を適用する
先ほど用意した内容に、Gooey Effect を適用します。
その際に、1つ注意する点があります。この Gooey Effect を以前試していた最初のころ、失敗をしたことがありました。

それは、画面全体に何らかの描画がされている状態（背景が塗りつぶされた上に、オブジェクトなどが描画されている状態）だと、うまく溶け合う見た目にならないというものです。

現状用意したものは、そのうまくいかない状態になっています（背景が描画されているため）。そこで、 Gooey Effect用の処理を追加するのに合わせて背景を透過する処理も加えます。

追加する内容は、具体的には以下の通りです。

- SVGフィルターを用意
- SVGフィルターをキャンバス要素に適用する（CSS や JavaScript にて）
- Babylon.js の描画部分の背景は透過させる

また、フィルターの効果が分かりやすく示せるように、キー操作でフィルターを外したり適用したりする切り替えができるようにもしてみます。

一連の対応を完了させたソースコードは、以下の通りです。
上で掲載していたソースコードに、処理を追加した部分は「追加1 〜 追加4」というコメントをつけています。

```html
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Babylon.js sample code</title>
    <script src="https://preview.babylonjs.com/babylon.js"></script>
    <script src="https://code.jquery.com/pep/0.4.1/pep.js"></script>
    <style>
      html,
      body {
        width: 100%;
        height: 100%;
        overflow: hidden;
      }

      #renderCanvas {
        width: 100%;
        height: 100%;
        touch-action: none;
        /* 追加1： フィルターを適用 */
        filter: url("#gooey");
      }
    </style>
  </head>
  <body>
    <canvas id="renderCanvas"></canvas>
    <!-- 追加2： フィルターを準備 -->
    <svg>
      <filter id="gooey">
        <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" />
        <feColorMatrix
          in="blur"
          mode="matrix"
          values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 10 -5"
        />
      </filter>
    </svg>
    <script>
      const canvas = document.getElementById("renderCanvas");

      const startRenderLoop = function (engine, canvas) {
        engine.runRenderLoop(function () {
          if (sceneToRender && sceneToRender.activeCamera) {
            sceneToRender.render();
          }
        });
      };

      let engine = null;
      let scene = null;
      let sceneToRender = null;
      const createDefaultEngine = function () {
        return new BABYLON.Engine(canvas, true, {
          preserveDrawingBuffer: true,
          stencil: true,
          disableWebGL2Support: false,
        });
      };
      const createScene = () => {
        const scene = new BABYLON.Scene(engine);

        const camera = new BABYLON.ArcRotateCamera(
          "ArcRotateCamera",
          -Math.PI / 2,
          Math.PI / 2.2,
          50,
          new BABYLON.Vector3(0, 0, 0),
          scene
        );
        camera.attachControl(canvas, true);
        const light = new BABYLON.HemisphericLight(
          "light",
          new BABYLON.Vector3(0, 1, 0),
          scene
        );

        const SPS = new BABYLON.SolidParticleSystem("SPS", scene);
        const sphere = BABYLON.MeshBuilder.CreateSphere("s", {});
        const poly = BABYLON.MeshBuilder.CreatePolyhedron("p", { type: 2 });
        SPS.addShape(sphere, 20);
        SPS.addShape(poly, 120);
        SPS.addShape(sphere, 80);
        sphere.dispose();
        poly.dispose();

        const mesh = SPS.buildMesh();

        SPS.initParticles = () => {
          for (let p = 0; p < SPS.nbParticles; p++) {
            const particle = SPS.particles[p];
            particle.position.x = BABYLON.Scalar.RandomRange(-20, 20);
            particle.position.y = BABYLON.Scalar.RandomRange(-20, 20);
            particle.position.z = BABYLON.Scalar.RandomRange(-20, 20);
          }
        };

        SPS.initParticles();
        SPS.setParticles();

        const mat = new BABYLON.StandardMaterial("mat");
        mat.diffuseColor = BABYLON.Color3.Green();

        mesh.material = mat;

        // 追加3： 背景透過
        scene.clearColor = new BABYLON.Color4(0, 0, 0, 0);

        // 追加4： フィルターの ON/OFF をキー操作で行うためのもの
        scene.onKeyboardObservable.add((kbInfo) => {
          switch (kbInfo.type) {
            case BABYLON.KeyboardEventTypes.KEYDOWN:
              switch (kbInfo.event.key) {
                case "f":
                case "F":
                  console.log("KEYDOWN_F");
                  document.getElementById("renderCanvas").style.filter =
                    'url("#gooey")';
                  break;
                case "d":
                case "D":
                  console.log("KEYDOWN_d");
                  document.getElementById("renderCanvas").style.filter =
                    'url("")';
                  break;
              }
              break;
          }
        });

        return scene;
      };
      window.initFunction = async function () {
        const asyncEngineCreation = async function () {
          try {
            return createDefaultEngine();
          } catch (e) {
            console.log(
              "the available createEngine function failed. Creating the default engine instead"
            );
            return createDefaultEngine();
          }
        };

        engine = await asyncEngineCreation();
        if (!engine) throw "engine should not be null.";
        startRenderLoop(engine, canvas);
        scene = createScene();
      };
      initFunction().then(() => {
        sceneToRender = scene;
      });

      window.addEventListener("resize", function () {
        engine.resize();
      });
    </script>
  </body>
</html>
```

これをブラウザ上で実行すると、意図通りの動作となるのを確認できました。
以下に、エフェクトを適用した時と外した時を、それぞれ順番に画像で掲載してみます。

![エフェクトあり](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/e085cc55-96d2-9a7b-99b1-6104853e9f22.png)

![エフェクトなし](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/8663250c-1b76-fda7-91bf-1d18e8624ea6.png)

最後に、Solid Particle System のサンプルを見る視点を変えたりしつつ、今回の  Gooey Effect を適用したりなどした時の動画を掲載します。

https://twitter.com/youtoy/status/1604520230486937601