Next.js(TypeScript) + react-three-fiberでVRMを表示する

こんにちは、フロントエンドエンジニアの堀江(@nandemo_3_)です。

前回、Babylon.jsを使って簡単に、Web上に3Dモデルを描画しました。 synamon.hatenablog.com

元々は、Web上でVRMを描画したかったので、今回はそちらにチャレンジしていきたいと思います。

VroidHubのアバタープレビュー画面ですね。

すでに、いろんな方がやられているので、そこまで真新しさはありませんが、 Next.jsでトライしてみましたので、参考になれば幸いです。

はじめに

当初は、Babylon.jsをベースに、Web上にVRMを表示しようと考えていました。

babylon-vrm-loaderというライブラリを用いれば、できるということがわかっていました。

github.com

しかしながら、babylon-vrm-loaderはBabylo.js 最新バージョンの6に未対応で、

さらに、VRMをモーションデータ(VMD)で動かしたいというのが、後々やりたいことでもあったので、

babylon-vrm-loaderでは難しそうということがわかり、今回はThree.jsの方で、VRMを表示してみました。

バージョン情報

  • node.js: v16.14.2
  • next: 13.4.4
  • react: 18.2.0
  • typescript: 5.0.4
  • three: ^0.153.0
  • @pixiv/three-vrm: ^1.0.10
  • @react-three/drei: ^9.70.1
  • @react-three/fiber: ^8.13.0

リポジトリ

ソース全文はこちらです。

github.com

使用したライブラリやVRMデータ

今回使うライブラリの紹介です。

Next.jsでThree.jsを使用するために、react-three-fiberを利用します。

Babylon.jsでいうreact-babylonjsになります。

github.com

また、Three.jsでVRMを描画するためのライブラリに、pixivが開発しているthree-vrmを利用します。

github.com

肝心なVRMデータはニコニ立体よりアリシアちゃんをお借りしました。

そのほかのVRMデータもニコニ立体のVRMモデルを配布している方からお借りしています。

3d.nicovideo.jp

とりあえず、VRMを表示する

それでは、プロジェクト作成とライブラリをインストールするために、以下のコマンドを実行します。

$ npx create-next-app --ts
$ cd <project-directory>
$ yarn add three @types/three @react-three/fiber @pixiv/three-vrm @react-three/drei

参考サイトをもとに、画面いっぱいにシーンおよびVRMを表示してみました。

シーンの表示エリアを指定してあげないと(divタグのstyle={{ height: 1000 }})、何も表示されていないように見えるので注意。

※VRMファイルは、publicにmodelsを作成し、配置してください。

コードはこちら

/// page.tsx
"use client"

import React, { useRef } from 'react'
import { Canvas } from '@react-three/fiber'

import { OrbitControls } from "@react-three/drei"
import Model from './Model'

export default function Home() {
  const gltfCanvasParentRef = useRef<HTMLDivElement>(null)

  return (
    <main>
      <div
        ref={gltfCanvasParentRef}
        style={{ height: 1000 }}
      >
        <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>
    </main>
  )
}
// Model.tsx
import { FC, useState, useEffect } from "react"
import { Html} from "@react-three/drei"
import { GLTFLoader, GLTF } from "three/examples/jsm/loaders/GLTFLoader"
import { VRMLoaderPlugin } from "@pixiv/three-vrm"


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(
        "/models/AliciaSolid.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)
        }
      )
    }
  }, [gltf])

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

export default Model

UI実装

これだけだと尺が足りないので、VroidHubのように一覧ページを簡単に作りたいと思います。

コンテンツをクリックしたら、ダイアログでVRMのビューが見れるようにします。

まずは、UIライブラリのMUIのインストールをします。

$ yarn add @mui/material @emotion/react @emotion/styled

続いて、UI実装ですが、

MUIのGrid、Card、Dialogコンポーネントを使って、UIをそれなりに実装しました。

Modelコンポーネントは、VRMのファイルパスがベタ書きだったので、Props化しました。

最も引っかかったポイントは、カメラの焦点位置でした。

上記のコードでは画像のように、焦点位置がアバターの足元になっていました。

Babylon.jsはカメラの設定で、微調整できましたが、react-three-fiberは設定の仕方が違うようです。

今回は対象コードをmeshでラップし、positionを調整することで、

アバターの中心部を焦点とすることができました。

ソースはこちら

// page.tsx
"use client"

import React, { useRef } from 'react'
import { Canvas } from '@react-three/fiber'
import Image from 'next/image'
import { Card, CardMedia, CardContent, Dialog, Grid, Typography } from '@mui/material';

import { OrbitControls } from "@react-three/drei"
import Model from './Model'

const models = [
  {model: "/models/AliciaSolid.vrm", thumbnail: "/images/9a12f79ea966172fb14b2700e7ae139c_thumb.png", title: "ニコニ立体ちゃん (VRM)", desctiption: "映像作品や自作ゲーム、技術デモなど様々なシチュエーションにおいて 無料で使える3Dモデル「ニコニ立体ちゃん"},
]

interface ClickableImage {
  src: string;
  alt: string;
  onClick: () => void
}

const ClickableImage = ({ src, alt, onClick }: ClickableImage) => {
  return (
    <Image src={src} alt={alt} onClick={onClick} width={100} height={200} style={{ cursor: 'pointer' }} />
  );
};

export default function Home() {
  const gltfCanvasParentRef = useRef<HTMLDivElement>(null)
  const [open, setOpen] = React.useState(false);
  const [selectedUrl, setSelectedUrl] = React.useState("");

  const handleOpen = (url: string) => {
    setOpen(true);
    setSelectedUrl(url);
  };

  const handleClose = () => {
    setOpen(false);
    setSelectedUrl("");
  };

  return (
    <main>
      <Grid container spacing={2}>
        {models.map((model, index) => {
          return (
            <Grid item key={index}>
              <Card 
                sx={{ width: 345, minHeight: 300 }}
                onClick={() => handleOpen(model.model)}
              >
              <CardMedia
                sx={{ height: 150 }}
                image={model.thumbnail}
              />
              <CardContent>
                <Typography gutterBottom variant="h6" component="div">
                  {model.title}
                </Typography>
                <Typography variant="body2" color="text.secondary">
                  {model.desctiption}                
                </Typography>
              </CardContent>
            </Card>
          </Grid>
          )
        })}
      </Grid>

      <Dialog onClose={handleClose} open={open}>
        <div
          ref={gltfCanvasParentRef}
          style={{ height: 700, width: 600 }}
        >
          <Canvas
            frameloop="demand"
            camera={{ fov: 20, near: 0.1, far: 300, position: [0, 1, -7] }}
            flat
          >
            <mesh position={[0, -1, 0]}>
              <directionalLight position={[1, 1, -1]} color={"0xFFFFFF"} />
              <Model url={selectedUrl}/>
              <color attach="background" args={["#f7f7f7"]} />
              <OrbitControls
                enableZoom={true}
                enablePan={false}
                enableDamping={false}
              />
              <gridHelper />
            </mesh>
          </Canvas>
        </div>
      </Dialog>
    </main>
  )
}
// Model.tsx
import { FC, useState, useEffect } from "react"
import { Html} from "@react-three/drei"
import { GLTFLoader, GLTF } from "three/examples/jsm/loaders/GLTFLoader"
import { VRMLoaderPlugin } from "@pixiv/three-vrm"

interface ModelProps {
  url: string;
}

const Model: FC<ModelProps> = ({url}: ModelProps) => {
  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(
        url,
        (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)
        }
      )
    }
  }, [gltf, url])

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

export default Model

完成した一覧ページがこちらです。

VRMのビューはこんな感じでダイアログで確認することができます。

ドラッグやスクロールで角度や距離を変えられます。2Dのアバターサムネイルだけでなく、実際のモデルをさまざまな角度から見ることができます。

SSG化

最後に、Next.jsなのでSSG化して、ローカルで動作するか検証します。

ローカルにサーバを立てるので、serveをインストールします。

$ yarn add -D serve

そして、package.jsonのscriptsに以下のコードを追加します。

// package.json
{
    //...
    "scripts": {
        //...
        "export": "next build && next export",
        "serve": "yarn export && serve ./out"
    },
    //...
}

では、実行。

$ yarn serve
- error "next export" does not work with App Router. Please use "output: export" in next.config.js https://nextjs.org/docs/advanced-features/static-html-export
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

初歩的なミスで、エラーが起きました。 エラーメッセージにも書いてある通り、nextConfigにoutput: 'export'を追加したら解消しました。

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export', // 追加
}

module.exports = nextConfig

http://localhost:3000/にアクセスすると、問題なくVRMが表示されました。

課題

VRMをWeb上で表示する課題として、VRMデータの傍受があります。

Webで今回のような要件を実現するには、どうしてもHTTP GETメソッドでVRMデータを取得する必要があり、Chromeの開発者ツールで容易にVRMデータをダウンロードすることができてしまいます。

デモで使用しているVRMデータは、著作権フリーのため問題ありませんが、有料で販売しているVRMデータの場合、データの傍受対策が必要です。

例えば、

  • S3のバケットポリシーで特定のIPアドレスのみアクセスを許容する
  • VRMファイルを暗号化(モザイク化?)し、ブラウザ側で復号する(VRMファイルをダウンロードされても、そのままのデータでは利用できないようにする)

後者の場合は、Javascriptで復号処理を実装するため、それも開発者ツールで見れてしまうので、実現方法を検討する必要があります。

VroidHubをざっと見た感じ後者を採用しているようですが、どのように対策されているのか気になるところです。

ここら辺の知識がないので、勉強します。

まとめ

今回は、Next.jsとreact-three-fiberを用いて、VRMを表示しました。

カメラの焦点位置で少し手こずりましたが、VRM描画自体はかなり簡単に実装することができました。

今後は、冒頭でも述べましたが、VRMをモーションデータ(VMD)で動かすのにチャレンジしてみたいと思います。

最後まで見てくださりありがとうございました。

参考サイト

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

VRoid Hub