はじめに

こんにちは、普段はWebのフロントエンドをやっているエンジニアの一人です。
アップフロンティアのxRを専門にやるチームとは違い、私はそっち方面は全くの素人なのですが、一人一アバターが当たり前になるのではないかという昨今、自分もそっち方面にも触れていこうということで、 手始めに「three.js」と「three-vrm」を使ってWebブラウザ上に3Dモデル(VRM)を表示するということをやってみました。


VRMとは?

「VRM」はVRアプリケーション向けの人型3Dアバター(3Dモデル)データを扱うためのファイルフォーマットです。

https://vrm.dev/

これまでVR等でアバターとして3Dモデルを扱おうとした場合、

  • 作成者や使用したツールによって「作法」が違い、データの状況が異なる(座標系,スケール,ボーンの入れ方,etc. )
  • 3Dモデルデータを扱うフォーマットが複雑(OBJ,FXB,MMD,etc. )

といった状況から、アプリケーションごと・3Dモデルデータごとに調整を行わないといけないことが多々あったそうです。

この状況を改善しようと、

  • 細かいモデルデータの差違を吸収・統一
  • アプリケーション側の取り扱いを簡単にする

さらには、

  • 3Dモデルデータを「アバターとして使用する」上で必要な情報の整備

を目的として、「VRMコンソーシアム」という団体が提唱し策定・普及を進めている3Dアバターファイルフォーマットが「VRM」なんだとか。


使用するnpmパッケージ

今回使用する主なものは以下です。

  • typescript
  • three.js
  • @pixiv/three-vrm

three.js

three.jsはブラウザ上で手軽に3DCGを描画できるJavascriptライブラリです。
ブラウザ上で高度なグラフィックを描画するのに必要なJavaScript APIであるWebGLを扱いやすく抽象化してくれる、Javascriptで3DCGを扱うならまず名前が挙がる定番ライブラリです。

three-vrm

three-vrmはVRMファイルを読み込み、three.js上で扱えるようにしてくれるライブラリです。
VRMコンソーシアムにも参加しており、VRoidでお馴染みの「ピクシブ株式会社」がオープンソースとして公開しています。


実装

今回はコアとなるthree.js、three-vrm周りに絞って書いていきます。

three.jsの設定

three.jsを使ってcanvas上に3DCGを描画するための各設定(レンダラー、カメラ、シーン、ライト)を行います。
マウスでズームやパンといったカメラのコントロールを行えるようにするOrbitControls、グリッド線を表示するGridHelper、XYZ座標軸を表示するAxesHelperといった、3DCGのビューアにおいて定番的な機能もインポートして組み込むだけで簡単に実装する事ができます。

// レンダラーの設定
const renderer = new THREE.WebGLRenderer({
  antialias: true,
  alpha: true,
})
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(window.devicePixelRatio)
document.body.appendChild(renderer.domElement)

// カメラの設定
const camera = new THREE.PerspectiveCamera(
  35,
  window.innerWidth / window.innerHeight,
  0.1,
  1000,
)
camera.position.set(0, 1.1, 3)

// カメラコントーロールの設定
const controls = new OrbitControls(camera, renderer.domElement)
controls.target.set(0, 0.85, 0)
controls.screenSpacePanning = true
controls.update()

// シーンの設定
const scene = new THREE.Scene()

// ライトの設定
const light = new THREE.DirectionalLight(0xffffff)
light.position.set(1, 1, 1).normalize()
scene.add(light)

// グリッドを表示
const gridHelper = new THREE.GridHelper(10, 10)
scene.add(gridHelper)
gridHelper.visible = true

// 座標軸を表示
const axesHelper = new THREE.AxesHelper(0.5)
scene.add(axesHelper)

描画

この時点で一度レンダリングしてみます。
以下のように毎フレームレンダリングを実行する関数を定義し実行します。

// 初回実行
tick()

function tick() {
  requestAnimationFrame(tick)
  // レンダリング
  renderer.render(scene, camera)
}

まだモデルを読み込んでいないので背景しか表示されませんが、マウスでカメラをグリグリ操作できると思います。
マウスの操作によってカメラのアングルが変わり、毎フレームのレンダリングによってそれがキャンバス上に反映されているわけですね。

VRMの読み込み

下地が整ったところで、three-vrmを使ってVRMの3DCGモデルを読み込みます。
今回は、VRMコンソーシアムにも参加している株式会社ドワンゴが公開している「ニコニ立体ちゃん」こと、「アリシア・ソリッド」のVRMモデルを使用します。

// モデルをロード
const loader = new PromiseGLTFLoader()
const gltf = await loader.promiseLoad(
  './models/AliciaSolid.vrm',
  progress => {
    console.log(
      'Loading model...',
      100.0 * (progress.loaded / progress.total),
      '%',
    )
  },
)
// VRMインスタンス生成
const vrm = await VRM.from(gltf)
// シーンに追加
scene.add(vrm.scene)
vrm.scene.rotation.y = Math.PI

VRMはGLTFという3Dフォーマットをベースにしているため、three.jsで用意されているGLTFLoaderを使用してVRMモデルを読み込むことができます。
読み込んだ結果からVRMインスタンスを作成し、以降VRMモデルとして情報にアクセスできるようになります。

また、GLTFLoaderはPromiseに対応していないため、上の例では次のようにPromise化したものを用意して使っています。

class PromiseGLTFLoader extends GLTFLoader {
  promiseLoad(
    url: string,
    onProgress?: ((event: ProgressEvent<EventTarget>) => void) | undefined,
  ) {
    return new Promise<GLTF>((resolve, reject) => {
      super.load(url, resolve, onProgress, reject)
    })
  }
}

これでシーンにVRMモデルが追加されました。

表情の変更

VRMモデルはブレンドシェイプによって予め定義された表情をウェイトを指定して反映し、かつ複数の表情をブレンドすることができます。

vrm.blendShapeProxy.setValue(VRMSchema.BlendShapePresetName.Joy, 1.0)
vrm.blendShapeProxy.setValue(VRMSchema.BlendShapePresetName.A, 0.95)
vrm.blendShapeProxy.setValue(VRMSchema.BlendShapePresetName.I, 0.85)
vrm.blendShapeProxy.update()

それぞれの表情はこちらで定義されています。

ポーズの変更

読み込んだモデルはTスタンスという基本となるT字のポーズで表示されます。
ポーズを変えたい場合、ボーン一つ一つを調整していったりもできますが、VRMHumanoidクラスのsetPoseメソッドにVRMPoseオブジェクトを渡すと、一括でモデルのポーズを変更する事ができます。

VRMPoseは

{
  leftUpperLeg : {
    rotation: [  0.000,  0.000, -0.454,  0.891 ],
    position: [  0.000,  0.000,  0.000 ]
  },
  leftLowerLeg : {
    rotation: [ -0.454,  0.000,  0.000,  0.891 ]
  },
}

のようにkeyにボーン名、valueにrotationやpositionといったボーンの情報を持つオブジェクトになっています。
VRMモデルはhumanoidというルールに則ったボーン構造とボーン名を持っているので、異なるモデルに対しても同様にポーズを適用する事ができます。

アニメーション

アニメーションさせる場合、requestAnimationFrameに渡しているコールバックの中で都度ボーンを動かしたりといった処理を書いてもいいですが、複雑な動きをさせたりするのは無理があります。
そこで、three.jsを使ってキーフレームアニメーションによってアニメーションさせてみます。

// 対象とするボーンの配列
const bones = [VRMSchema.HumanoidBoneName.RightUpperArm].map(boneName => {
  return vrm.humanoid?.getBoneNode(boneName) as THREE.Bone
})
// AnimationClip作成
const clip = THREE.AnimationClip.parseAnimation(
  {
    // 対象とするボーンごとのタイムライン情報の配列
    hierarchy: [
      {
        // キーフレームの配列
        keys: [
          {
            rot: new THREE.Quaternion().setFromEuler(
              new THREE.Euler(0, 0, -Math.PI / 4),
            ),
            time: 0,
          },
          {
            rot: new THREE.Quaternion().setFromEuler(
              new THREE.Euler(0, 0, Math.PI / 4),
            ),
            time: 1,
          },
          {
            rot: new THREE.Quaternion().setFromEuler(
              new THREE.Euler(0, 0, -Math.PI / 4),
            ),
            time: 2,
          },
        ],
      },
    ],
  },
  bones,
)
clip.tracks.forEach(track => {
  track.name = track.name.replace(
    /^.bones[([^]]+)].(position|quaternion|scale)$/,
    '$1.$2',
  )
})
// AnimationMixer作成
const mixer = new THREE.AnimationMixer(vrm.scene)
// AnimationAction作成
const action = mixer.clipAction(clip)
// アニメーション再生
action.play()
...
const clock = new THREE.Clock()
function tick() {
  requestAnimationFrame(tick)
  mixer.update(clock.getDelta()) // 追加
  // レンダリング
  renderer.render(scene, camera)
}

ある時間でのボーンの状態(位置、大きさ等)の情報をキーフレームといい、キーフレーム間を補完してアニメーションさせることをキーフレームアニメーションといいます。
上のコードでは、AnimationClip.parseAnimationに渡しているオブジェクト中のkeysがキーフレームの配列に相当するもので、

// キーフレームの配列
keys: [
  {
    rot: new THREE.Quaternion().setFromEuler(
      new THREE.Euler(0, 0, -Math.PI / 4),
    ),
    time: 0,
  },
  {
    rot: new THREE.Quaternion().setFromEuler(
      new THREE.Euler(0, 0, Math.PI / 4),
    ),
    time: 1,
  },
  {
    rot: new THREE.Quaternion().setFromEuler(
      new THREE.Euler(0, 0, -Math.PI / 4),
    ),
    time: 2,
  },
]

この例では右上腕のボーンに対し、「0秒時点ではz軸に対して-45°の状態」、「1秒時点ではz軸に対して45°の状態」、「2秒時点ではz軸に対して-45°の状態」という情報をキーフレームとして与えています。
これをキーフレームアニメショーンとして再生することで、各キーフレーム間の情報が補完され、「右上腕を2秒かけてz軸に対して上下させる」といったスムーズなアニメーションになります。


おわりに

three.jsとthree-vrmを使い、ブラウザ上でVRMモデルの表示、表情・ポーズの変更、アニメーションをさせることができました。
3DCGは0からやるとなると用語等の必要な予備知識が多く、自分自身初めはカメラ?シーン?ライト?という感じでしたが、その辺りの仕組みが理解できれば最低限の実装自体はthree.jsのおかげで簡単に行うことができました。
正直最低限も最低限で、高度なことをやろうと思うとまだまだ知識が足りていない状態だと思いますが、これを足がかりに少しずつ知識を付けていけたらと思います。

最後に、今回記事中で紹介した内容をもう少し作り込んで、VRMモデルをプレビューするだけの簡単なWebページを作ってみました。
PWA化してあるのでスマホ等にダウンロードすればオフラインでも使用できます。
よかったら触ってみてください。
https://vrm-viewer-48655.web.app/