🕶️

ReactでThree.jsを扱うときのTips(というかTip)

2022/12/08に公開

木瓜丸です。
突然ですが、僕は個人での開発ではThree.jsを使ってWebGLによるGPGPUをすることが多いです。具体的にはWebGLを使った音声合成を行っています。

Three.jsとReactを組み合わせて開発する上で、どのように連携させるのがベストなのか悩んでいる方も多いかと思います。
この記事では次のようなサンプルを作ってみることを通して、設計から実装までの流れに沿って自分なりにこの方法が最適なのではないかと思っているReactとThree.jsの連携方法を紹介します。(動いている物を後程追記します)

  • Playボタンを押すと、Three.jsでの4秒感覚でループするアニメーションが流れる
  • Stopボタンを押すと、アニメーションが止まる
  • アニメーション中の現在の時間が表示される

Three.jsの描画ロジックは完全に分離したい

連携というと当然かもしれませんが、Three.jsで表示する時に用いるロジックは完全にReactコンポーネント内から分離してしまいたいです。
ここで言う分離とは、Reactコンポーネント内でThree.jsでの描画に関わる処理を意識する必要がないということです。Reactコンポーネント内で実行したいことをReact側の任意のタイミングで呼び出したら、あとは分離されたThree.js側が全部やってくれる状態を目指します。

したがって、その境界を必ず明確にした上で、React側から呼び出したいインターフェースを定義しましょう。

この記事のサンプルでは、React側から取得したいプロパティと実行したいアクションは下記のようになります。

取得したいプロパティ

  • アニメーション中の現在の時間(currentTime: Number)
  • アニメーションが再生中かどうか(isPlaying: Boolean)

実行したいアクション

  • アニメーションの再生を開始する(play(void): void)
  • アニメーションを停止する(stop(void): void)

Hooksの実装

インターフェースの定義ができたら、Hooksの実装に入ります。THREE.WebGLRendererはuseEffectで初期化し、それをずっと使ってあげましょう。

const useRendering = () => {
	const renderer = useRef<THREE.WebGLRenderer>()
	const sceneRef = useRef(new THREE.Scene())
	const cameraRef = useRef(new THREE.OrthographicCamera())
	
	const animationFrameRef = useRef<number>(-1)
	const currentTimeRef = useRef(0)
	const offsetRef = useRef(0)
	const whenRef = useRef(0)
	
	const [currentTime, setCurrentTime] = useState(0)
	const [isPlaying, setIsPlaying] = useState(false)
	const play = () => {...}
	const stop = () => {...}
	const animate = (currentTime: number) => {...}
	useEffect(() => {
	    if(!rendererRef.current){
		    rendererRef.current = new THREE.WebGLRenderer();
	    }
	    animationFrameRef.current = requestAnimationFrame(animate)
	    return () => {
		    cancelAnimationFrame(animationFrameRef.current)
	    }
	}, [])
	return {
		currentTime,
		isPlaying,
		play,
		stop,
	}
}

THREE.SceneやTHREE.CameraはWebGLの初期化処理には関係ないので、useEffectで画面の初期化を待つ必要はありません。
今回はアニメーションの再生時間を管理するため、currentTimeRef, offsetRef, whenRefというRefObject変数を用意しました。それぞれの役割は以下の通りです。

  • currentTimeRef: 画面初期化からの総経過時間(requestAnimationFrameで渡される秒数)
  • offsetRef: 4秒間のアニメーションのうち、何秒目から再生開始したか
  • whenRef: アニメーションの再生開始時刻(再生開始時のcurrentTimeRefを使用)

時間をRefとState両方で持ちたい

時間をReactで表示する場合、Stateとして時間を持ち、再描画させる必要があります。しかし、StateはrequestAnimationFrameによって再起的に呼び出される関数内では正確に取得することはできません。
ものすごくざっくり言うと、本来Reactで再描画が発生する場合には、同時にコンポーネント内で定義されたイベントハンドラ用の関数も同時に再定義されます。しかし、requestAnimationFrameによって画面初期化時から再帰的に呼び出されている関数は参照が変わらないものであるため、関数外のスコープで再定義された状態変数を参照できず、古い値が参照されてしまいます。
そこで、基本的にはRefで持ち、Refの更新と同時にStateを更新してあげることにします。

今回は使いませんが、シンプルに時間だけ管理したい場合には、次のようなhookとか作ったら便利なんじゃないでしょうか。

const useRefState = <T>(value: T) => {
	const [currentValue, setCurrentValue] = useState<T>(value)
	const currentValueRef = useRef<T>(value)
	return {
		current: currentValueRef.current,
		value: currentValue,
		set: (value: T) => {
			currentValue.current = value
			setCurrentValue(value)
		},
	}
}

まとめ

結局のところ、ケースバイケースですよという話だったかもしれませんが、Three.jsでやりたいことをインターフェースとして定義して、それ以外をコンポーネントから追い出すと幸せになれるよということでした。
悩んでいた時に記事を読み漁ってみたのですが、あまり記事になっていなかったので書いてみました。誰かのお役に立てれば幸いです。

Discussion