こんにちは、フロントエンドエンジニアの堀江(@nandemo_3_)です。
前回、Babylon.jsを使って簡単に、Web上に3Dモデルを描画しました。 synamon.hatenablog.com
元々は、Web上でVRMを描画したかったので、今回はそちらにチャレンジしていきたいと思います。
VroidHubのアバタープレビュー画面ですね。
すでに、いろんな方がやられているので、そこまで真新しさはありませんが、 Next.jsでトライしてみましたので、参考になれば幸いです。
はじめに
当初は、Babylon.jsをベースに、Web上にVRMを表示しようと考えていました。
babylon-vrm-loaderというライブラリを用いれば、できるということがわかっていました。
しかしながら、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
リポジトリ
ソース全文はこちらです。
使用したライブラリやVRMデータ
今回使うライブラリの紹介です。
Next.jsでThree.jsを使用するために、react-three-fiberを利用します。
Babylon.jsでいうreact-babylonjsになります。
また、Three.jsでVRMを描画するためのライブラリに、pixivが開発しているthree-vrmを利用します。
肝心なVRMデータはニコニ立体よりアリシアちゃんをお借りしました。
そのほかのVRMデータもニコニ立体のVRMモデルを配布している方からお借りしています。
とりあえず、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)で動かすのにチャレンジしてみたいと思います。
最後まで見てくださりありがとうございました。