🦑

GLTFやVrmをreact-three-fiberまたはThree.js+Reactで表示する

2022/09/25に公開

どうもこんにちは。最近高い米がマイブームのyosiです。

マジおいしいので、だまされたと思って1000円/kgくらいの米を買ってみてほしいです。米としては法外に高いけど、飲みに行ったりステーキ買うよりずっと安いから。

さて、個人開発をしていてVroid Studioで作成したVrmの3Dモデルを表示する処理を書いていて、最初はreact-three-fiberを用いて表示をしていたのですが、途中でreact-three-fiberを使わずにThree.js+Reactを用いるように訳あって書き直しました。

せっかくなので、react-three-fiberを使ったとき、使わなかった時でどういう処理になるのか紹介しようと思います。

Vrmのレンダリング結果

前提

PostPageコンポーネントの中に、Three.jsのレンダリングを行うGltfCanvasコンポーネントがあるものとします。

Vrmの読み込みを行っていますが、GLTFもしくはGLBのファイルもほぼ同様に読み込めます。

react-three-fiber を用いて表示する

Reactを用いているときにThree.jsでCGレンダリングを行う際、react-three-fiberを用いる方法が有名なようです。

https://github.com/pmndrs/react-three-fiber

記事を書いている時点で19.7kものスターがついていて、活発に開発されています。

さあ、このライブラリを使って書いてみましょう。

Canvasサイズを指定する

まずは、Canvasを設置するためのレイアウトを作ります。

frontend/pages/Post/PostPage.tsx
import { FC, useState, useEffect, useRef } from "react"
import Container from "@mui/material/Container"
import Grid from "@mui/material/Grid"
import Paper from "@mui/material/Paper"

import GltfCanvas from "components/GltfCanvas"

const PostPage: FC = () => {
  return (
    <Container maxWidth="lg">
      <Grid container alignItems="flex-start" spacing={1}>
        <Grid item xs={12} sm={7}>
          <Paper sx={{ p: 2, mt: 4, mb: 4 }}>
            <GltfCanvas />
          </Paper>
        </Grid>
      </Grid>
    </Container>
  )
}

export default PostPage

GridなどのMUIコンポーネントはあまり関係ないので無視してください。

Vrmを表示する

Vrmを表示するGltfCanvasコンポーネントの説明に移ります。

frontend/components/GltfCanvas/GltfCanvas.tsx
import { FC, useState, useEffect, useRef } from "react"
import { Canvas } from "@react-three/fiber"
import { Html, OrbitControls } from "@react-three/drei"
import { GLTFLoader, GLTF } from "three/examples/jsm/loaders/GLTFLoader"
import { VRMLoaderPlugin } from "@pixiv/three-vrm"

import useWindowSize from "hooks/useWindowSize"

const Model: FC = () => {
  const [gltf, setGltf] = useState<GLTF>()
  const [progress, setProgress] = useState<number>(0)

  useEffect(() => {
    if (!gltf) {
      const loader = new GLTFLoader()
      loader.register((parser) => {
        return new VRMLoaderPlugin(parser)
      })

      loader.load(
        "/test.vrm",
        (tmpGltf) => {
          setGltf(tmpGltf)
          console.log("loaded")
        },
        // called as loading progresses
        (xhr) => {
          setProgress((xhr.loaded / xhr.total) * 100)
          console.log((xhr.loaded / xhr.total) * 100 + "% loaded")
        },
        // called when loading has errors
        (error) => {
          console.log("An error happened")
          console.log(error)
        }
      )
    }
  }, [])

  return (
    <>
      {gltf ? (
        <primitive object={gltf.scene} />
      ) : (
        <Html center>{progress} % loaded</Html>
      )}
    </>
  )
}

const GltfCanvas: FC = () => {
  const gltfCanvasParentRef = useRef<HTMLDivElement>(null)
  const [canvasWidthAndHeight, setCanvasWidthAndHeight] = useState<number>(0)
  const windowSize = useWindowSize()

  useEffect(() => {
    if (gltfCanvasParentRef.current?.offsetWidth) {
      setCanvasWidthAndHeight(gltfCanvasParentRef.current.offsetWidth)
    }
  }, [windowSize])

  return (
    <div
      ref={gltfCanvasParentRef}
      style={{ height: `${canvasWidthAndHeight}px` }}
    >
      <Canvas
        frameloop="demand"
        camera={{ fov: 20, near: 0.1, far: 300, position: [0, 1, -10] }}
        flat
      >
        <directionalLight position={[1, 1, -1]} color={"0xFFFFFF"} />
        <Model />
        <color attach="background" args={["#f7f7f7"]} />
        <OrbitControls
          enableZoom={false}
          enablePan={false}
          enableDamping={false}
        />
        <gridHelper />
      </Canvas>
    </div>
  )
}

export default GltfCanvas

まず、CanvasのWidthは親要素内いっぱいに広がります。Heightは指定しないとデフォルトになるっぽいです。僕はWidth:Heightを1:1にしたいのでHeightを設定します。

重要なのはgltfCanvasParentRefが付いた要素以下です。

gltfCanvasParentRefのDiv要素はPaper内の要素いっぱいに広がります。つまり、paddingなどを考慮したPaper内部のWidthが500pxであればgltfCanvasParentRefのDiv要素幅は500pxになります。

useEffectはDomの更新が終わってから処理を行うhookです(更新後じゃないとDomが作られてなくてWidth幅が取得できなかったりする)。gltfCanvasParentRef.current.offsetWidthでWidth幅を取得し、canvasWidthAndHeightに入れておきます。

gltfCanvasParentRefのDiv要素のHeightにcanvasWidthAndHeightを入れることでWidth:Heightが1:1になるようにしています。

これでCanvasの大きさは指定できましたが、ブラウザのWindowサイズ自体が変更されたときにWidthが変わってもHeightが固定になってしまっています。

そこでブラウザのWindowサイズの変更を察知するuseWindowSizeというHookを作成して、変更されるたびにcanvasWidthAndHeightを更新しています。

frontend/hooks/useWindowSize.ts
import { useLayoutEffect, useState } from "react"

const useWindowSize = (): number[] => {
  const [size, setSize] = useState([0, 0])
  useLayoutEffect(() => {
    const updateSize = (): void => {
      setSize([window.innerWidth, window.innerHeight])
    }

    window.addEventListener("resize", updateSize)
    updateSize()

    return () => window.removeEventListener("resize", updateSize)
  }, [])
  return size
}

export default useWindowSize

次に、レンダリングに必要なSceneやLightをを作ったり設定を記述していきます(react-three-fiberを使う場合、明示的にSceneを作らなくても勝手に作られます)。

frameloopはレンダリングを行うタイミングの設定です。Canvasに動き続けるアニメーションを表示するのであれば画面を1秒間に数十回レンダリング結果を更新しなければなりません。しかし、逆に言えばアニメーションしないのであれば処理の無駄遣いです。

frameloop="demand"とすることで、ユーザー入力の直後のみレンダリング結果を更新するようになり、非常に効率的です。

トーンマッピングがデフォルトでオンになってるのでflatオプションをつけて無効にしています。

https://zenn.dev/yosipy/scraps/4ed7f82342e58f

directionalLightはReactコンポーネントのように追加できます。

OrbitControlsを追加することで、カメラ位置をマウスで操作できるようにしています。

次に、VrmやGLTFモデルの読み込み、Sceneへの追加を行うModelコンポーネントの説明をしていきます。

useGLTFを用いることで簡単に追加を行うことができますが、今回はリッチな表示をするために使うpixiv/three-vrmが正式版になったときに大きく仕様が変わってuseGLTFと連携が難しくなったのでGLTFLoaderを使用します。

three-vrmを用いて読み込みをするには公式のReadmeの通り、

      loader.register((parser) => {
        return new VRMLoaderPlugin(parser)
      })

とするだけです。

https://github.com/pixiv/three-vrm

Exsamplesが非常に参考になります。(デモ, ソースコード

読み込み中はsetProgress((xhr.loaded / xhr.total) * 100)でprogressを更新して、読み込みが完了したらsetGltf(tmpGltf)gltfを更新しています。

レンダリング部分は

      {gltf ? (
        <primitive object={gltf.scene} />
      ) : (
        <Html center>{progress} % loaded</Html>
      )}

となってますが、これは親の顔より見た三項演算子を使用していて、gltfがundefinedでなければ<primitive object={gltf.scene} />でモデルを表示して、gltfがundefinedのとき(読み込み中)は何パーセントまで読み込みを完了しているかを表示しています。

補足:three-vrmを用いずにuseGLTFを用いる

import { FC, Suspense } from "react"
import { Canvas } from "@react-three/fiber"
import { Html, OrbitControls, useProgress, useGLTF } from "@react-three/drei"
import { GLTF as StdlibGLTF } from "three-stdlib"

interface ModelProps {
  progress: number
}

const Model: FC<ModelProps> = (props) => {
  const gltf: StdlibGLTF = useGLTF(
    "/test.vrm",
    true
  )

  return <primitive object={gltf.scene} />
}

const GltfCanvas: FC = () => {
  const { progress } = useProgress()

  return (
    <Canvas
      frameloop="demand"
      camera={{ fov: 20, near: 0.1, far: 300, position: [0, 1, -10] }}
      flat
    >
      <directionalLight position={[1, 1, -1]} color={"0xFFFFFF"} />
      <Suspense fallback={<Html center>{progress} % loaded</Html>}>
        <Model progress={progress} />
      </Suspense>
      <color attach="background" args={["#f7f7f7"]} />
      <OrbitControls
        enableZoom={false}
        enablePan={false}
        enableDamping={false}
      />
      <gridHelper />
    </Canvas>
  )
}

export default GltfCanvas

react-three-fiber を使わずに Three.js + React で表示する

react-three-fiberを使って書いてみて、少ない記述でReactとThree.jsを連携させることができた一方、以下のようなことを問題に思いました。

  • Sceneなどが隠蔽されていて細かい操作を行うのが難しい
  • ReactRouterなどをCanvasの上位コンポーネントとして使ったときに、CanvasがUnmountされたタイミングでContextLostとなってしまい、色味がおかしくなってしまった。そしてその解決方法がパッと思いつかなかった。
  • 意図しない挙動が起きたときに、それがThree.js側の問題かreact-three-fiber側の問題かわからず、切り分けのためにreact-three-fiberを使わないコードを何度も書いていた

さて、やっと本題です。react-three-fiber使わずに書いてみます。

毎秒60回レンダリングするような連打利運ぐループは行わず、上記react-three-fiberを使ったコードと同じようにユーザー入力直後の実レンダリングするような処理を書いていきます。

frontend/components/GltfCanvas/GltfCanvas.tsx
import { FC, useState, useEffect, useRef } from "react"
import type { Dispatch } from "react"
import * as THREE from "three"
import { GLTFLoader, GLTF } from "three/examples/jsm/loaders/GLTFLoader"
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"
import { VRMLoaderPlugin } from "@pixiv/three-vrm"

import useWindowSize from "hooks/useWindowSize"

interface ModelProps {
  scene: THREE.Scene
  gltf: GLTF | undefined
  setGltf: Dispatch<React.SetStateAction<GLTF | undefined>>
  setProgress: Dispatch<React.SetStateAction<number>>
}

const Model: FC<ModelProps> = (props) => {
  useEffect(() => {
    if (props.scene) {
      const loader = new GLTFLoader()

      loader.register((parser) => {
        return new VRMLoaderPlugin(parser)
      })

      loader.load(
        "/test.vrm",
        (tmpGltf) => {
          props.setGltf(tmpGltf)
        },
        // called as loading progresses
        (xhr) => {
          console.log((xhr.loaded / xhr.total) * 100 + "% loaded")
          props.setProgress((xhr.loaded / xhr.total) * 100)
        },
        // called when loading has errors
        (error) => {
          console.log("An error happened")
        }
      )
    }
  }, [props.scene])

  useEffect(() => {
    if (props.gltf) {
      const vrm = props.gltf.userData.vrm
      props.scene.add(vrm.scene)
    }
  }, [props.gltf])

  return <></>
}

const GltfCanvas: FC = () => {
  const [width, height] = useWindowSize()

  const canvasParentRef = useRef<HTMLDivElement>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)

  const [scene, setScene] = useState<THREE.Scene>()
  const [camera, setCamera] = useState<THREE.PerspectiveCamera>()
  const [renderer, setRenderer] = useState<THREE.WebGLRenderer>()

  const [gltf, setGltf] = useState<GLTF>()
  const [progress, setProgress] = useState<number>(0)

  useEffect(() => {
    if (canvasParentRef.current && canvasRef.current) {
      const canvas_width_and_height = canvasParentRef.current.offsetWidth

      const tmpScene = new THREE.Scene()
      setScene(tmpScene)
      tmpScene.background = new THREE.Color("#f7f7f7")
      const tmpCamera = new THREE.PerspectiveCamera(
        20,
        canvas_width_and_height / canvas_width_and_height,
        0.1,
        300
      )
      setCamera(tmpCamera)

      const tmpRenderer = new THREE.WebGLRenderer({ canvas: canvasRef.current })
      setRenderer(tmpRenderer)

      tmpRenderer.setPixelRatio(window.devicePixelRatio)
      tmpRenderer.setSize(canvas_width_and_height, canvas_width_and_height)
      tmpRenderer.outputEncoding = THREE.sRGBEncoding

      const directionalLight = new THREE.DirectionalLight(0xffffff)
      directionalLight.position.set(1, 1, -11).normalize()
      tmpScene.add(directionalLight)

      const size = 10
      const divisions = 10

      const gridHelper = new THREE.GridHelper(size, divisions)
      tmpScene.add(gridHelper)

      tmpRenderer.render(tmpScene, tmpCamera)

      const controls = new OrbitControls(tmpCamera, canvasParentRef.current)
      controls.target.set(0, 0, 0)
      controls.enableZoom = false
      controls.enablePan = false
      controls.enableDamping = false
      controls.addEventListener("change", () =>
        tmpRenderer.render(tmpScene, tmpCamera)
      )

      tmpCamera?.position.set(0, 1, -10)
      controls.update()
    }
  }, [])

  useEffect(() => {
    if (scene && camera && renderer && gltf) {
      renderer.render(scene, camera)
    }
  }, [scene, camera, renderer, gltf])

  useEffect(() => {
    if (canvasParentRef.current) {
      const canvas_width_and_height = canvasParentRef.current.offsetWidth

      renderer?.setSize(canvas_width_and_height, canvas_width_and_height)
    }
  }, [width])

  return (
    <>
      <div ref={canvasParentRef}>
        <canvas ref={canvasRef} />
        {scene && (
          <Model
            scene={scene}
            gltf={gltf}
            setGltf={setGltf}
            setProgress={setProgress}
          />
        )}
      </div>
      {!gltf && (
        <div>
          <span>{progress}</span>
        </div>
      )}
    </>
  )
}

export default GltfCanvas

GltfCanvasの最初のuseEffectでレンダリングの設定やLightの追加などを行っています。

以下の処理でユーザー操作があったときにレンダリング結果を更新するようにしています。

      controls.addEventListener("change", () =>
        tmpRenderer.render(tmpScene, tmpCamera)
      )

Three.js単体でレンダリングするときはcanvasサイズを親要素から自動で設定してくれたりはしないので、renderer?.setSize(canvas_width_and_height, canvas_width_and_height)で設定しています。

progressは適当に表示するにとどめてますので、レイアウトはいい感じにしてください。

Modelではreact-three-fiberの時と同様に、Vrmの読み込みを行ってます。

useEffectの第2引数に気を付けて、設定やモデルの読み込みが意図せず複数回行われないように注意しましょう。

まだ、作成中なので雑な感じですが、ひとまずこんな感じで同じようなレンダリングができました。

まとめ

使わないコードも書いてみて、少ないコードで記述できる react-three-fiber は非常に完成度の高いライブラリだと実感しました。

Sceneなどが隠蔽されていて細かい操作を行うのが難しいというのは僕がまだreact-three-fiberにそこまで詳しくないというのもあります。僕はReactRouterでページ移動時にレンダリングに使ったデータがキャッシュされてるっぽくて困った(ページアクセス時に毎回レンダリングフローを最初からやり直したかった)ので苦戦していましたが、いい方法があるかもしれません。どのみち、多くの用途では十分な柔軟性はありそうです。react-three-fiber固有の知識が必要というのはどのみちデメリットですが。

意図しない挙動が起きたときに、それがThree.js側の問題かreact-three-fiber側の問題かわからず、切り分けのためにreact-three-fiberを使わないコードを何度も書いていたというのは、開発していて何度も感じましたが、実際にreact-three-fiberに依存した問題が起きたことはほぼありませんでした。

総評としては、たいていは react-three-fiber を使うとよいと思います。

react-three-fiber を使うことでコードは非常にシンプルで状態の管理なども非常に楽でした。

また、JSX記法で書けるので、オブジェクトに対する変更がとても簡潔に書けます。

その反面、細かいキャッシュや細かい処理が難しい(もしくは固有の知識が求められる)ので、ケースによっては使わない選択肢も問題ないと思います。

使わないことで、Three.jsの情報資産をそのまま使えることもメリットとして感じますし、問題の切り分けも楽です。

いつも記事を書くときかなり時間をかけてしまうので、今日は30分で書くぞと意気込んで始めたのですが、思ったより壮大な記事になって結局2時間以上かかってます。。。

そろそろ僕はイカになって色を塗るお仕事があるので、さようならまた会いましょうよい一日を。

追記 2022/10/03

やはりreact-three-fiberを使用するか悩んでます。

実際に使わずにやってみて、stateの管理がかなり面倒でした(どちらにせよThree.jsには破壊的変更を行うメソッドが多いので、setStateを使わずに値が書き換えられてしまうのはいいのだろうか)

ドキュメントを読み直すと https://docs.pmnd.rs/react-three-fiber/API/hooks で詳細にいじれそうでした。

何も関係ない余談ですが、モデルの色が白っぽくなってしまったときはambientLightを消すとよかったです(Three-vrmのシェーダーと相性悪そう)。

Discussion