Skip to content

Instantly share code, notes, and snippets.

@ahuglajbclajep
Last active January 31, 2024 06:58
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ahuglajbclajep/6ea07f6feb250aa776afa141a35e725b to your computer and use it in GitHub Desktop.
Save ahuglajbclajep/6ea07f6feb250aa776afa141a35e725b to your computer and use it in GitHub Desktop.

VRM と three.js と react-three-fiber に関する技術メモ

https://github.com/ahuglajbclajep/three-vrm-react-example

参考になりそうな URL

VRM とは

three.js 入門

three-vrm とは

three-vrm 使ってみた

VRM とは

JSON として記述された標準的な 3D モデルなどを表現する規格である glTF2.0 をベースに、特に人型のキャラクターについて 座標系, スケール, 初期姿勢, 表情の表現方法, ボーンの入れ方, 一人称視点での視点の位置 などのモデルデータの差異を吸収し統一する目的で作られたファイルフォーマット。 プラットフォーム非依存な形式であり、通常の *.gltf フォーマットをベースに 一人称視点の再現のための情報, 物理エンジンに依存しない揺れる物の設定, アバターを表示するのに適した専用のマテリアルの設定, ライセンス情報などのメタデータ などが利用できるようになっている。 「一般社団法人VRMコンソーシアム」という VRM の発起人であるドワンゴなどを中心としたグループが仕様などを策定している。

仕様を読むとわかるが、glTF のバイナリ形式である *.glb に準拠しており、 *.glb としても読むことが可能。 元々の *.glb で保存できる値に関して統一性を持たせるためさらに強い制約を設け、VRM 用の専用のデータ領域を追加したという感じ。

three.js 入門

とりあえず何かを表示するほぼ最小の例

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight
);
camera.position.z = 5;

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

renderer.render(scene, camera);

基本的に必要なのは scene, camera, renderer で、最終的には renderer.render(scene, camera); のようにして描画する。

camera は例えば new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight); とかで作れる。 第 1 引数は FOV で第 2 引数はアスペクト比。 camera.position.z = 5; でカメラを少し手前に持ってきている。

renderernew THREE.WebGLRenderer() で作り、実体は <canvas> で例えば document.body.appendChild(renderer.domElement); のように反映する。 renderer.setSize() で画面のサイズや内部解像度の設定ができる。

ここに箱を描画する場合は、まずメッシュを作りこれを scene.add(mesh); のように scene に追加する。 メッシュは new THREE.BoxGeometry() でボーンのような物を作り、 new THREE.MeshBasicMaterial() で表面の感じを指定し、new THREE.Mesh(geometry, material); のようにして作る。

箱を回す

描画するだけなら renderer.render(scene, camera); で終わりだが、動かすには requestAnimationFrame() を使いメインループを構築する。 以下のようなコードで通常は 60fps で描画される。 requestAnimationFrame() を使っているため、ユーザーが別のタブを見ている間は勝手に一時停止してくれ充電に優しい。

function animate() {
  window.requestAnimationFrame(animate);

  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;
  renderer.render(scene, camera);
}
animate();

glTF のモデルを読み込む

基本的には glTF(*.gltf, *.glb)が推奨されるが、FBX, OBJ, COLLADA といった形式のものも扱えるらしい。 だいたい以下のような感じで、gltf => { ... } がモデルのダウンロードが終了すると呼ばれ、ここでメッシュを scene.add() するのが基本。

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";

const loader = new THREE.GLTFLoader();
loader.load(
  "path/to/model.glb",
  gltf => {
    scene.add(gltf.scene);
  },
  xhr => {
    console.log(`${(xhr.loaded / xhr.total) * 100}% loaded`);
  },
  error => {
    console.error(error);
  }
);

three-vrm とは

これはざっと中心部のコードを読んでドキュメントも眺めてみた結果からだが、これはおそらく「モデルデータ *.vrm を解析し、表情や視線などに関するパーツを探し出し、これを操作する API を構築するライブラリ」だと思われる。

基本的な使い方は以下のような感じ。 vrmVRM.from()VRM と同じ VRM クラスであり、要するに VRM.from() がこのクラスの static なイニシャライザになっている。

import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { VRM } from "@pixiv/three-vrm";

const scene = new THREE.Scene();

const loader = new GLTFLoader();
loader.load(
  "/models/three-vrm-girl.vrm",
  gltf => {
    VRM.from(gltf).then(vrm => {
      scene.add(vrm.scene);
    });
  },
  progress =>
    console.log(
      `Loading model... ${(progress.loaded / progress.total) * 100}%`
    ),
  error => console.error(error)
);

ちなみに vrm.scenegltf.scene はこれだけでは特になにもしていないことになる。 three-vrm の本領はここからで、読み込んだモデルを簡単な API で操作できるというところにある。 例えば vrm.humanoid!.getBoneNode(VRMSchema.HumanoidBoneName.LeftUpperArm).rotation.x = のようにすれば左腕が動いたり、 vrm.lookAt.target = のようにすれば視線の方向を変更できたり、vrm.lendShapeProxy.setValue(VRMSchema.BlendShapePresetName.Fun, 0.7) のようにすれば表情を変えたりといったことが可能になる。

three-vrm 使ってみた

three-vrm-sample のようなものを作ってみる。

React + three.js の基本

$ git clone git@github.com:ahuglajbclajep/my-react-template.git
$ cd my-react-template
$ yarn install
$ yarn add three react-three-fiber

とりあえず以下で three.js の最初の緑の四角が描画できる。 メッシュの構築やシーンへ追加する部分が宣言的に書けているという感じ。 背景は黒にしておいたほうがモデルがきれいに見える。

import React from "react";
import { Canvas } from "react-three-fiber";

const App: React.FC = () => {
  return (
    <Canvas>
      <mesh>
        <boxBufferGeometry attach="geometry" args={[1, 1, 1]} />
        <meshBasicMaterial attach="material" color={0x00ff00} />
      </mesh>
    </Canvas>
  );
};

export default App;
body {
  color: white;
  background-color: black;
}

#root {
  width: 100vw;
  height: 100vh;
}

回す場合は以下のように useFrameuseRef を使う。 useFrame を使う場合は、直接 <Canvas><mesh>...</mesh></Canvas> としないこと。 useFrame を使うコンポートの JSX のトップレベルは <mesh> じゃないとダメっぽい。 これは <Canvas> が Context API の Provider にもなっており、useFrame の実装で useContext() されているために起きる。 つまり useFrame()<Canvas> の子コンポーネントでしか動作しないということ。

const App: React.FC = () => {
  const cube = useRef<import("three").Mesh>(null);
  useFrame(() => {
    if (cube.current) {
      cube.current.rotation.x += 0.01;
      cube.current.rotation.y += 0.01;
    }
  });
  return (
    <mesh ref={cube}>
      <boxBufferGeometry attach="geometry" args={[1, 1, 1]} />
      <meshBasicMaterial attach="material" color={0x00ff00} />
    </mesh>
  );
};

// see https://github.com/react-spring/react-three-fiber/issues/253
const Wrapper: React.FC = () => {
  return (
    <Canvas>
      <App />
    </Canvas>
  );
};

three-vrm を使ってみる

yarn add @pixiv/three-vrm する。

モデルを切り替えられるようにしたいので、まずはモデルを適宜ロードする関数と実際にモデルを操作できる vrm をエクスポートするような hooks を作る。 呼ばれる度に loader を作らなくていいようにするため、loaderuseRef() で持っておく。 あとは初期状態を後からロードするいつのもパターンで書いている。

import { VRM } from "@pixiv/three-vrm";
import { useRef, useState } from "react";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";

const useVRM = (): [VRM | null, (_: string) => void] => {
  const { current: loader } = useRef(new GLTFLoader());
  const [vrm, setVRM] = useState<VRM | null>(null);

  const loadVRM = (url: string): void => {
    loader.load(url, gltf => {
      VRM.from(gltf).then(vrm => setVRM(vrm));
    });
  };

  return [vrm, loadVRM];
};

export { useVRM };

本体はこんな感じで、ファイルを渡すと loadVRM でデータが読まれ {vrm && <primitive object={vrm.scene} />} で反映される。 <primitive> は既存のメッシュをシーンに追加するためのコンポーネント。 このままではシーンが真っ暗なため、<directionalLight /><spotLight /> などの適当な光源を配置する。

const App: React.FC = () => {
  const [vrm, loadVRM] = useVRM();

  const handleFileChange = (
    event: React.ChangeEvent<HTMLInputElement>
  ): void => {
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    const url = URL.createObjectURL(event.target.files![0]);
    loadVRM(url);
  };

  return (
    <>
      <input type="file" accept=".vrm" onChange={handleFileChange} />
      <Canvas>
        <directionalLight />
        {vrm && <primitive object={vrm.scene} />}
      </Canvas>
    </>
  );
};

モデルを正面に向けカメラの位置を調節する

初期状態ではモデルは後ろを向いているので、vrm.scene.rotation.y = Math.PI; で前を向かせる。 これは useVRM()loadVRM() 内でやったりすればいい。 カメラは自分で作ることもできるが、デフォルトのものが用意されているのでこれの向きだけ変更する。 基本的にカメラには <Canvas camera={{ position: [0, 1, 2] }}> のようにアクセスする。 このデフォルトのカメラは、他のコンポーネントからなどでも useThree() 経由で取得でき、例えば以下のようなコードを useVRM() に追記するとカメラのほうを向いてくれるようになるはず。

import { useThree } from "react-three-fiber";

const { camera } = useThree();

useEffect(() => {
  if (vrm && vrm.lookAt) vrm.lookAt.target = camera;
});

自分でカメラを作って設定する場合は以下のようになる。 new PerspectiveCamera() のところとかは three.js の最初の例と同じ。 //@ts-ignore がないと型エラーになるので、この方法はあまりよくないのかも。

import { useThree } from "react-three-fiber";
import { PerspectiveCamera } from "three";

const { aspect } = useThree(); // アスペクト比を取得
const { current: camera } = useRef(new PerspectiveCamera(75, aspect);

// camera が一度作られて終わりなので基本的には一度しか呼ばれない
useEffect(() => {
  camera.position.set(0, 0.6, 4);
}, [camera]);

return (
  <>
    <input type="file" accept=".vrm" onChange={handleFileChange} />
    <Canvas
      //@ts-ignore
      camera={camera}>
      ...
    </Canvas>
  </>
);

モデルに物理演算を適用し回転させる

モデルに物理演算を適用するには vrm.update(delta); のようにする。 当然 useFrame() 内でこれを呼ぶので、とりあえずは VRM.tsx のようなコンポーネントを作る。 物理演算が有効になっているか確認しやすくするため、vrm.scene.rotation.y で適当に回転させる。 clockdeltaconst { clock } = useThree(); からでもとれるが、特に delta については UseFrame() の引数でとれるものを使わないと、挙動が不安定になる。

import React from "react";
import { useFrame } from "react-three-fiber";

type Props = {
  vrm: import("@pixiv/three-vrm").VRM | null;
};

const VRM: React.FC<Props> = ({ vrm }) => {
  useFrame(({ clock }, delta) => {
    if (vrm) {
      vrm.scene.rotation.y = Math.PI * Math.sin(clock.getElapsedTime());
      vrm.update(delta);
    }
  });

  return vrm && <primitive object={vrm.scene} />;
};

export default VRM;

ちなみに以下のようにすると目だけマウスに合わせて動かしたりもできる。

import { Vector3 } from "three";

if (vrm.lookAt) vrm.lookAt.lookAt(new Vector3(...mouse.toArray(), 0));

以下のように three.js の Object3D.lookAt() を活用すると頭を動かしたりもできるが、座標系の関係か常に後ろを向いてしまうし、人間の関節の可動範囲を超えた動きをしてしまったりするので微妙。

import { VRMSchema } from "@pixiv/three-vrm";

if (vrm && vrm.humanoid) {
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  const head = vrm.humanoid.getBoneNode(VRMSchema.HumanoidBoneName.Head)!;
  head.lookAt(mouse.x, mouse.y, 2);
}

サイズに関して

three.js は全体で 600KB ほどあり、モジュール毎にインポートはできるものの Tree Shaking には非対応らしい。 というわけでデフォルトでは react-three-fiber を使うとこれが全部ついてくる。 この挙動については three.js をカスタムビルドする場合と同様に、必要なモジュールだけをインポートしたオレオレミニ three.js を用意すればある程度は解決できる。 頑張れば大体 400KB くらいにはできるらしい。 three-minifier-webpack というプラグインを使うと特に何もしなくてもいい感じに必要なファイルだけを読んでバンドルしてくれるそうだが、react-three-fiber を使っていると効果がない模様。

カメラを動かしたりズームしたりできるようにする

マウス操作などをハンドルしてモデルを動かすのには、three.js の OrbitControls というカメラのコントローラーが使える。 react-three-fiber で OrbitControls を使う例は react-three-fiber 自体の examples にいくつかあり、大体以下のようなコードを書くことになる。 extends() は適当なオブジェクトを JSX として書けるようにするヘルパー関数で、OrbitControls を渡すと <orbitControls /> と書けるようになる。 元のオブジェクトの頭文字が小文字になったコンポーネントが作られ、args で配列としてコンストラクタの引数が渡せたり、宣言的にプロパティを指定したりできる。 <OrbitControls /> で必須なのは refargs で、enableDamping で動作を滑らかにし target でカメラの方向を調整してる。

extend({ OrbitControls });

const Controls: React.FC = () => {
  const controls = useRef<OrbitControls>(null);
  const { camera, gl } = useThree();

  useFrame(() => controls.current?.update());

  return (
    // @ts-ignore
    <orbitControls
      ref={controls}
      args={[camera, gl.domElement]}
      enableDamping
      target={new Vector3(0, 1, -2)}
    />
  );
};

ただし extends() で作ったコンポーネントの型定義は存在しないため、 // @ts-ignore で無視するか以下のようなコードを書く必要がある。 実は react-three-fiber で定義済みのコンポーネントも同様に宣言されている。 typeof OrbitControls のようにしないと OrbitControls["new"] ができなので *.d.ts には書けない。

import { ReactThreeFiber } from "react-three-fiber";

declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace JSX {
    interface IntrinsicElements {
      orbitControls: ReactThreeFiber.Object3DNode<
        OrbitControls,
        // `OrbitControls["new"]` does not work without `typeof`.
        typeof OrbitControls
      >;
    }
  }
}

ちなみに extends() を使わず下記のように自分でコンポーネントを作る方法もあるが、controls.enableDamping = true; などが機能しない。

import React, { useEffect, useRef } from "react";
import { useThree } from "react-three-fiber";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

const Controls: React.FC = () => {
  const { camera, gl } = useThree();
  const { current: controls } = useRef(
    new OrbitControls(camera, gl.domElement)
  );

  useEffect(() => () => controls.current.dispose(), []);

  useEffect(() => {
    controls.enableDamping = true;
    return () => controls.dispose();
  }, [controls]);

  useFrame(() => controls.update());

  return null;
};

余談だが <gridHelper /><axesHelper /> を使うと、デバッグに便利な座標系が出せる。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment