16
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

3D グラフィックス初心者が Web で VRoid をアニメーションさせてみた

はじめに

対象読者は、以下のような方を想定しています。
というか、半年後の自分のための備忘録です。

  • なんとなく Web(JavaScript)のことはわかる
  • 3D グラフィックスよくわからん、難しそう

記事タイトルにもありますが、3D グラフィックスのことなんてよくわからんのでマサカリ大歓迎です。
また誰の役に立つかは不明ですが、理解の整理がてらメモした用語集も記事末尾に掲載しています。

やりたかったこと

  • VRoid Studio で作成した 3D キャラクター を
  • Web(Three.js)上で
  • アニメーションさせる

できたもの

See the Pen @pixiv/three-vrm by blachocolat (@blachocolat) on CodePen.

CodePen 内の 3D キャラクターは、@pixiv/three-vrm Examples の女の子を拝借させていただきました😃

3D キャラクターの作成

VRoid Studio


[出典:VRoid Studio 公式サイト]

VRoid Studio」は、オリジナルの 3D キャラクターを簡単に作れる pixiv 社製のデスクトップアプリです。
MMO RPG のキャラメイクのようにテンプレートから選択したり、身長などのパラメータを調整するだけでなく、顔や服装のテクスチャを編集(差し替え)することができるので、自由度もかなり高い印象です。

対応する出力形式は、VRM(*.vrm)と呼ばれる 人型 3D キャラクターを対象にしたファイルフォーマットで、3D モデル全般を扱う GLTF(*.gltf)ファイルをベースにしているそうです。
詳しい仕様は、VRM 公式サイトにまとめられています。

Web 上での表示

@pixiv/three-vrm

@pixiv/three-vrm は、VRM ファイルを Web(Three.js)上で取り扱うためのライブラリで、VRoid Studio と同じ開発元である pixiv 社が提供しています。
詳しくは、公式ドキュメントExamples が充実しているので、そちらをご覧いただくのがオススメです。

Three.js

Three.js」は、Web で 3D グラフィックスをやるなら必須と言っても過言ではない超有名&便利な WebGL ライブラリです。
公式サイトのトップページには、導入事例の数々がこれでもかと並べられています。

似たようなライブラリに Microsoft 社製の「Babylon.js」があり、記事執筆時点での GitHub Star は、それぞれ 56.5k / 10.4k となっています。

VRM の描画

基本的には Examples のソースコード通りに書けば、問題ないかと思います。
OrbitControls は、3D キャラクターをグリグリ動かしたり、ズームイン/アウトする機能を提供するものなので、ユーザーインタラクションが不要であれば削除しましょう。

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

class VRMPlayer {
  constructor (src) {
    // renderer
    this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
    this.renderer.setSize(window.innerWidth, window.innerHeight)
    this.renderer.setPixelRatio(window.devicePixelRatio)
    document.body.appendChild(this.renderer.domElement)

    // camera
    this.camera = new THREE.PerspectiveCamera(15.0, window.innerWidth / window.innerHeight, 0.1, 100.0)
    this.camera.position.set(0.0, 1.25, 2.5)

    // camera controls
    const controls = new THREE.OrbitControls(this.camera, this.renderer.domElement)
    controls.screenSpacePanning = true
    controls.target.set(0.0, 1.25, 0.0)
    controls.update()

    // scene
    this.scene = new THREE.Scene()

    // light
    const light = new THREE.DirectionalLight(0xffffff)
    light.position.set(1.0, 1.0, 1.0).normalize()
    this.scene.add(light)

    // loader
    const loader = new THREE.GLTFLoader()
    loader.crossOrigin = 'anonymous'
    loader.load(src, async (gltf) => {
      this.model = await THREE.VRM.from()
      this.scene.add(this.model.scene)
    })
  }
}

const vrmPlayer = new VRMPlayer('path-to.vrm')

Three.js は、「右手系・Y 軸の正方向が上側(Z 軸の正方向が手前側)」の座標系を持ちます。
表示された 3D キャラクターがちょっと近いな、と感じたら、camera.position.set(x, y, z) の第 3 引数をより大きくし(3D キャラクターをカメラから遠ざけ)ましょう。

アニメーションの適用

アニメーションは、毎フレーム(例えば、1/30 秒)ごとに 3D キャラクターの表情(Blend Shape)やポーズ(各 Bone の位置・向き)を次々と変化させることで実現できます。

Blend Shape アニメーション

手始めに、3D キャラクターのまばたきを実装していきましょう。

class VRMPlayer {
  constructor () {
    this.animate = this.onAnimate.bind(this)
    ...
    loader.load(src, async (gltf) => {
      ...
      this.animate()
    })
  }
  ...
  get blinkValue () {
    return (Math.sin(this.clock.elapsedTime * 1/3) ** 1024) +
      (Math.sin(this.clock.elapsedTime * 4/7) ** 1024)
  }
  ...
  onAnimate () {
    const delta = this.clock.getDelta()

    if (this.model) {
      // blend shape animations
      this.model.blendShapeProxy.setValue(
        THREE.VRMSchema.BlendShapePresetName.Blink,
        this.blinkValue
      )
      ...

      this.model.update(delta)
    }

    this.renderer.render(this.scene, this.camera)
    window.requestAnimationFrame(this.animate)
  }
}

各フレームにおける表情は、onAnimate() 内で設定(model.blendShapeProxy.setValue())され、この onAnimate()window.requestAnimationFrame() により再帰的に呼び出すことでアニメーションを実現しています。
ここで blinkValue は、経過時間(clock.elapsedTime)にもとづいて変化する適当な周期関数で、具体的には以下のグラフのように緩やかなインパルスを出力します。

Screen Shot 2019-11-20 at 16.10.25.png

まばたきの Blend Shape(VRMSchema.BlendShapePresetName.Blink)は、

  • 0 のとき、目を(完全に)開く
  • 1 のとき、目を(完全に)閉じる

表情に対応しており、このインパルス部分がまばたきのときどき目をつむる動作を実現しています。

Blend Shape はその名の通り、複数の表情(Shape)を混合(Blend)することもできます。
記事冒頭にある 3D キャラクターの口パクは、異なる周期を持つ「母音 A の口」と「母音 O の口」を組み合わせることで表現しています。

  ...
  get speakAValue () {
    return (Math.sin(this.clock.elapsedTime * 11) ** 4) * 0.7
  }
  get speakOValue () {
    return (Math.sin(this.clock.elapsedTime * 7) ** 4) * 0.5
  }
  ...
  onAnimate () {
    ...
    if (this.model) {
      // blend shape animations
      this.model.blendShapeProxy.setValue(
        THREE.VRMSchema.BlendShapePresetName.A,
        this.speakAValue
      )
      this.model.blendShapeProxy.setValue(
        THREE.VRMSchema.BlendShapePresetName.U,
        this.speakOValue
      )
    }
    ...
  }

公式ドキュメントによると、Blend Shape では、

  • まばたき
  • 各母音の口
  • 喜怒哀楽
  • 視線方向

を制御できるようです。

Bone アニメーション

3D キャラクターのポーズをアニメーションさせる方法も、Blend Shape を使った場合とあまり変わりません。
ここでは、model.humanoid.getBoneNode() で取得した Bone の向き(Rotation)を変化させることで、待機モーション(静止時の微妙な上下な揺れ)を表現してみます。

  ...
  get waitingValue () {
    return (1 - (Math.sin(this.clock.elapsedTime * 4/5) ** 4)) * Math.PI / 32
  }
  ...
  onAnimate () {
    ...
    if (this.model) {
      // bone animations
      this.model.humanoid.getBoneNode(
        THREE.VRMSchema.HumanoidBoneName.LeftUpperLeg
      ).rotation.x = this.waitingValue
    }
    ...
  }

でも、本当にこれでいいの?

待機モーション程度であれば、適当な周期関数を作って値を設定してやることで、それっぽいアニメーションを実現できました。
しかしながら、キックバク宙などのような複雑なモーションになると、動きを関数化するなんてほぼ不可能です。

Keyframe アニメーション

毎フレームごとではなく、一部のフレーム(Keyframe)の Blend Shape / Bone を与えることでアニメーションを実現する便利な仕組みがあります。
Web の CSS の世界にも、@keyframe と呼ばれる同様の機能がありますね。
Three.js における Keyframe アニメーションについては、面白法人カヤックさんの記事が詳しいです。

  ...
  constructor () {
    ...
    loader.load(src, async (gltf) => {
      ...
      // keyframe animations
      const bones = [
        THREE.VRMSchema.HumanoidBoneName.Neck
      ].map((boneName) => {
        return this.model.humanoid.getBoneNode(boneName)
      })
      const clip = THREE.AnimationClip.parseAnimation({
        hierarchy: [{
          keys: [{
            rot: new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0)).toArray(),
            time: 0.0,
          }, ..., {
            rot: new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0)).toArray(),
            time: 4.6,
          }, {
            rot: new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 16, 0, 0)).toArray(),
            time: 4.8,
          }, {
            rot: new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0)).toArray(),
            time: 5.0,
          }]
        }]
      }, bones)
      clip.tracks.some((track) => {
        track.name = track.name.replace(/^\.bones\[([^\]]+)\].(position|quaternion|scale)$/, '$1.$2')
      })
      this.mixer = new THREE.AnimationMixer(this.model.scene)
      this.mixer.clipAction(clip).play()
    })
    ...
  }
  ...
  onAnimate () {
    ...
    if (this.mixer) {
      this.mixer.update(delta)
    }
  }
}

ソースコードの中ほど、AnimationClip.parseAnimation() で Keyframe を設定しています。
rot は Rotation の略ですが、実際に指定すべき値は Quaternion なので注意が必要です。
rot の他には、pos(Position)や scl(Scale)を指定することができます。

外部アニメーションの適用

ソースコード上で Keyframe をポチポチと指定することも、実作業を考えると現実的ではありません。
世の中には Blender や Unity といった 3D グラフィックスのための便利なアプリケーションがあるので、これらで作成したアニメーションを適用することとしましょう。

ただし、VRM ファイルにアニメーションを含めることはできないため、Unity で開いた VRM ファイルにアニメーションを適用して GLTF ファイルとしてエクスポートする、といった作業を行う必要があります(要検証)
そうなると、もはや @pixiv/three-vrm は必要ありませんね。。。

    ...
    constructor () {
      ...
      const loader = new THREE.GLTFLoader()
-     loader.load('path-to.vrm', async (gltf) => {
-       this.model = await THREE.VRM.from(gltf)
-       this.scene.add(this.model.scene)
+     loader.load('path-to.gltf', (gltf) => {
+       this.scene.add(gltf)
        ...
-       this.mixer = new THREE.AnimationMixer(this.model.scene)
+       this.mixer = new THREE.AnimationMixer(gltf)
+       gltf.animations.some((clip) => {
+         this.mixer.clipAction(clip).play()
+       })
      })
    }
  }
  ...

おわりに

VRoid Studio で作成した 3D キャラクター を Web(Three.js)上でアニメーションさせる方法を試してきました。
VRM は、いわゆる VTuber や VRChat などの文化とともに生まれた仕様だからなのか、

「VRM に動きを与える=(モーションキャプチャーやコントローラーで)毎フレームごとに取得した Bone を適用する」

ことを想定していると感じました。
記事では疎かになってしまった、Keyframe アニメーションももっと追いかけたほうがよさそうです。。。

ちょっと寄り道になってしまった部分もありますが、勉強する以前はやけに高く見えた(Web における)3D グラフィックスの敷居が下がって見えるようになったのは大きな収穫でした。
何か面白いものがいろいろ作れそうです。知らんけど。

【おまけ】3D グラフィックス関連 オレオレ用語集

Skeleton

Bone の親子関係(ツリー構造)で表現された 3D モデルの骨組み。

Bone

位置(Position)と向き(Rotation または Quaternion)を持つ 3D オブジェクト上の点。

Position

親 Bone からの相対位置を表した3次元ベクトル(Vector3)で定義される。

Rotation

XYZ 各軸に対する回転角と、回転を適用する順序('XYZ' など)を並べた Euler で定義される。
単純な回転操作(「3D モデルの向きを180°回転させたい」など)に向いている。

Quaternion

回転軸を表す3次元ベクトルと、その軸に対する回転角を並べた Quaternion で定義される。
複雑な回転操作(「3D モデルの腕や脚を自在に曲げたい」など)に向いている。

子 Bone の位置は、親 Bone に設定された Rotation / Quaternion の回転の影響を受けるため、以下の childA および childB は同一に見える。

const parentA = new THREE.Bone()
parentA.rotation.set(0, 0, 0, 'XYZ')

const childA = new THREE.Bone()
childA.position.set(1, 1, 0)
childA.rotation.set(0, 0, 0, 'XYZ')
const parentB = new THREE.Bone()
parentB.rotation.set(0, 0, -Math.PI / 4, 'XYZ')

const child = new THREE.Bone()
childB.position.set(0, 1.41421356, 0)
childB.rotation.set(0, 0, Math.PI / 4, 'XYZ')

Mesh

Skeleton に合わせて動く 3D モデルの肉付け。

Material

Mesh に貼りつける 3D モデルの肌。
ベースとなる Texture(画像)と、素材感(光の反射度合いなど)を表現する Shader で構成される。

Blend Shape

パラメータで制御する 3D モデルの表情(「喜怒哀楽」「発話時の口の形」など)。
「喜び顔 70% 」かつ「悲しみ顔 30%」、「母音 A の口 50%」かつ「母音 O の口 20%」のように、あらかじめ定義された形(Shape)を適当な度合いで混合(Blend)した表情を出力する。

Animation

毎フレームごとの Bone(位置・向き)や Blend Shape(表情)を変化させることで表現する 3D モデルの動き。
一部のフレーム(Keyframe)の Bone / Blend Shape のみを定義し、Keyframe 間のフレームについては適当なアルゴリズムで補間する Keyframe アニメーション もよく用いられる。

FBX (*.fbx)

従来から広く使用されている 3D モデル用のファイルフォーマット。

GLTF (*.gltf)

3D モデルの標準(2D 画像における JPEG の位置付け)を目指す、比較的新しいファイルフォーマット。

VRM (*.vrm)

GLTF を VRoid 用に拡張したファイルフォーマット。
GLTF とは異なり、Animation を含めることはできない

参考記事

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
16
Help us understand the problem. What are the problem?