お仕事で React と Babylon.js を組で使用することになり、色々調べて得られた知見をまとめます。
サンプルコードを GitHub に置いたので、必要に応じてこちらも参照してみてください。
なお、この記事は随時更新予定です。
Babylon.js コンポーネント実装の基本
React では useRef
と useEffect
を使って Babylon.js のためのコンポーネントを実装するのが王道のようです。
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 要素の無駄な再描画を回避するために、どのように状態を取得するべきかという点です。つまり、コンポーネントの中で、
const something = useSelector(state => state.something);
game.setSomething(something);
のように useSelector
で値を取得しても問題ないのか、あるいは、
const store = useStore();
const state = store.getState();
store.subscribe(() => {
game.setSomething(state.something);
});
のように Store に直接コールバックを設定する必要があるのか、です。
再描画の計算コストはそれなりに大きそうなので、できるだけ再描画されない方法で管理したいですよね。
結論ですが、React では DOM に差分がある時だけ再描画を行うので、Store から取得した値をコンポーネントから返却する JSX の中で使用しない限り、再描画は発生しません。なので、基本的には前者のように useSelector
を使用しても問題ありません。ただし Redux 取得した値を JSX に引き渡す必要がある場合は後者の形で実装する必要があります。
React の再描画については以下の記事(英語)が参考になります。
タブ切り替え時の canvas 要素
タブ切り替えなど、画面内の canvas 要素を出したり消したりする必要がある場合、[Babylon.js コンポーネント実装の基本](#Babylon.js コンポーネント実装の基本) で示したナイーブな実装では問題が生じることがあります。canvas を消すタイミングで生成された Engine
が放棄され、再表示の際に新たな Engine
が生成されてしまうためです。
canvas が非表示にされても Babylon.js の状態を維持したい場合、以下の2つの選択肢が考えられます:
- canvas の非表示処理を
display='none'
スタイルで実装する - Babylon.js のオブジェクトを管理するクラスをシングルトンで実装する
お手軽なのは 1. です。react-tabs の forceRenderTabPanel
のように、オプションが用意されている場合も多いので、使用するパッケージの詳細を調べてみるとよいでしょう。
シングルトンで解決する場合には canvas 要素の扱いに注意が必要です。そもそも Babylon.js の Engine
は canvas 要素なしにはインスタンス生成ができません。
この制約がシングルトンの実装を微妙にややこしくします。悩むのは「シングルトンの Engine
をいつ生成するか?」という点です。シングルトンのインスタンス取得時に Engine
を生成する場合、あらかじめ canvas を引き渡さなければならないので、
const game = GameFactory.getInstance(renderCanvas.current);
となり、getInstance
のインターフェースが不便極まりないものになります。
対して initialize
メソッドを追加し、Engine
の生成を遅延すると、
const game = GameFactory.getInstance();
game.initialize(renderCanvas.current);
となり、表面上の利便性は上がります。しかし、上記のコードをコンポーネントに記述した場合、canvas を再描画するたびに initialize
がコールされるため、initialize
の実装はステートフルなものにならざるを得ません。実装イメージは以下の通りです。
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 {
// 2度目以降の処理をここに書く
}
}
}
どちらも避けたい方法ですね。
納得いかなかったので色々試行錯誤した結果、document.createElement
で Engine
生成のための canvas を生成し、canvas は Engine
の registerView
を使って登録するようにすると、ある程度キレイに実装できるようです。以下にコードを示します。
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);
}
}
コンポーネント側のコードは以下のようになります。
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;