React with Babylon.js での注意事項（随時更新）


お仕事で React と Babylon.js を組で使用することになり、色々調べて得られた知見をまとめます。
サンプルコードを [GitHub](https://github.com/dmystk/react-with-babylonjs) に置いたので、必要に応じてこちらも参照してみてください。

なお、この記事は随時更新予定です。


# Babylon.js コンポーネント実装の基本

React では `useRef` と `useEffect` を使って Babylon.js のためのコンポーネントを実装するのが王道のようです。

```jsx
import { useEffect, useRef } from "react";
import { Engine } from '@babylonjs/core';

const GameCanvas = () => {
  const renderCanvas = useRef(null);

  useEffect(() => {
    const engine = new Engine(renderCanvas.current);
    // ... 以下略 ...
  }, [renderCanvas]);

  return (
    <canvas ref={renderCanvas}></canvas>
  );
};

export default GameCanvas;
```


# HTML 要素と Babylon.js の連携

HTML で描画したボタンで Babylon.js の状態を変更するような場合、React Redux の導入がほぼ必須になります。

React Redux を導入しない場合、コンポーネントの単位を大きくするか、コールバックのバケツリレーを行うか、どちらかをしなければなりません。基本的にどちらもやりたくない作業なので、素直に React Redux を導入しましょう。

React Redux を導入する際に迷うのは、canvas 要素の無駄な再描画を回避するために、どのように状態を取得するべきかという点です。つまり、コンポーネントの中で、

```javascript
const something = useSelector(state => state.something);
game.setSomething(something);
```

のように `useSelector` で値を取得しても問題ないのか、あるいは、

```javascript
const store = useStore();
const state = store.getState();
store.subscribe(() => {
  game.setSomething(state.something);
});
```

のように Store に直接コールバックを設定する必要があるのか、です。
再描画の計算コストはそれなりに大きそうなので、できるだけ再描画されない方法で管理したいですよね。

結論ですが、React では DOM に差分がある時だけ再描画を行うので、Store から取得した値をコンポーネントから返却する JSX の中で使用しない限り、再描画は発生しません。なので、基本的には前者のように `useSelector` を使用しても問題ありません。ただし Redux 取得した値を JSX に引き渡す必要がある場合は後者の形で実装する必要があります。

React の再描画については以下の記事（英語）が参考になります。

- [When does React re-render components? - Felix Gerschau](https://felixgerschau.com/react-rerender-components/)


# タブ切り替え時の canvas 要素

タブ切り替えなど、画面内の canvas 要素を出したり消したりする必要がある場合、[Babylon.js コンポーネント実装の基本](#Babylon.js コンポーネント実装の基本) で示したナイーブな実装では問題が生じることがあります。canvas を消すタイミングで生成された `Engine` が放棄され、再表示の際に新たな `Engine` が生成されてしまうためです。

canvas が非表示にされても Babylon.js の状態を維持したい場合、以下の２つの選択肢が考えられます：

1. canvas の非表示処理を `display='none'` スタイルで実装する
2. Babylon.js のオブジェクトを管理するクラスをシングルトンで実装する

お手軽なのは 1. です。[react-tabs](https://github.com/reactjs/react-tabs) の `forceRenderTabPanel` のように、オプションが用意されている場合も多いので、使用するパッケージの詳細を調べてみるとよいでしょう。

シングルトンで解決する場合には canvas 要素の扱いに注意が必要です。そもそも Babylon.js の `Engine` は canvas 要素なしにはインスタンス生成ができません。

- [How to init a scene or engine without canvas? - BabylonJS Forum](https://forum.babylonjs.com/t/how-to-init-a-scene-or-engine-without-canvas/5022)

この制約がシングルトンの実装を微妙にややこしくします。悩むのは「シングルトンの `Engine` をいつ生成するか？」という点です。シングルトンのインスタンス取得時に `Engine` を生成する場合、あらかじめ canvas を引き渡さなければならないので、

```javascript
const game = GameFactory.getInstance(renderCanvas.current);
```

となり、`getInstance` のインターフェースが不便極まりないものになります。

対して `initialize` メソッドを追加し、`Engine` の生成を遅延すると、

```javascript
const game = GameFactory.getInstance();
game.initialize(renderCanvas.current);
```

となり、表面上の利便性は上がります。しかし、上記のコードをコンポーネントに記述した場合、canvas を再描画するたびに `initialize` がコールされるため、`initialize` の実装はステートフルなものにならざるを得ません。実装イメージは以下の通りです。

```javascript
let instance = null;

export const GameFactory = {
  getInstance: () => {
    if (instance === null) {
      instance = new Game();
    }
    return instance;
  }
};

class Game {
  initialize(canvas) {
    if (this.engine === null) {
      this.engine = new Engine(canvas);
      // ... 以下略 ...
    } else {
      // ２度目以降の処理をここに書く
    }
  }
}
```

どちらも避けたい方法ですね。

納得いかなかったので色々試行錯誤した結果、`document.createElement` で `Engine` 生成のための canvas を生成し、canvas は `Engine` の `registerView` を使って登録するようにすると、ある程度キレイに実装できるようです。以下にコードを示します。

```javascript
let instance = null;

export const GameFactory = {
  getInstance: () => {
    if (instance === null) {
      instance = new Game();
    }
    return instance;
  }
};

class Game {
  constructor() {
    // 架空の canvas を生成して Engine の生成に使用する
    const canvas = document.createElement('canvas');
    this.engine = new Engine(canvas);
    // ... 以下略 ...
  }

  registerView(canvas) {
    // canvas を登録する（ゴミが残らないように一度クリアしている）
    this.engine.views = [];
    this.engine.registerView(canvas);
  }
}
```

コンポーネント側のコードは以下のようになります。

```jsx
import { useEffect, useRef } from "react";
import { GameFactory } from './3d/game';

const GameCanvas = () => {
  const renderCanvas = useRef(null);
  const game = GameFactory.getInstance();

  useEffect(() => {
    game.registerView(renderCanvas.current);
  }, [renderCanvas, game]);

  return (
    <canvas ref={renderCanvas}></canvas>
  );
};

export default GameCanvas;
```