見出し画像

three.js で VRM を表示する (6) - BVHファイルのアニメーション再生

three.js」を使って「VRM」を表示し、「BVHファイル」のアニメーションを再生する方法をまとめました。

・three.js@0.133.1
・@pixiv/three-vrm 0.6.7
・Node.js v16.13.0

前回

1. 開発環境の準備

開発環境の準備手順は、次のとおり。

(1) Node.jsのインストール。

(2) プロジェクトの作成。

$ mkdir helloworld
$ cd helloworld
$ npm init -y

(3) 「webpack」と「live-server」と「three.js」と「@pixib」のインストール。

$ npm i -D webpack webpack-cli
$ npm i -g live-server
$ npm i -S three @pixiv/three-vrm

(4) プロジェクトフォルダ直下の「package.json」の「scripts」を以下のように編集。

・pakage.json

    :
  "scripts": {
    "start": "live-server dist",
    "build": "webpack",
    "watch": "webpack -w"
  },
    :

(5) プロジェクトフォルダ直下に「webpack.config.js」を作成し、以下のように編集。

・webpack.config.js

module.exports = {
  mode: "development",

  entry: "./src/index.js",
  output: {
    path: `${__dirname}/dist`,
    filename: "main.js"
  },
  resolve: {
    extensions: [".js"]
  }
};

2. VRMの表示

VRMを表示するだけのコードを書きます。

(1) プロジェクトフォルダ直下に 「src」フォルダを生成し、「src」フォルダ直下に「index.js」を作成し、以下のように編集。

・src/index.js

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 camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

// レンダラーの準備
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x7fbfff, 1.0);
document.body.appendChild(renderer.domElement);

// ライトの準備
const light = new THREE.DirectionalLight(0xffffff);
light.position.set(-1, 1, -1).normalize();
scene.add(light);

// VRMの読み込み
const loader = new GLTFLoader();
loader.load("./alicia.vrm", (gltf) => {
  VRM.from(gltf).then((vrm) => {
    // 姿勢の指定
    vrm.scene.position.y = -1;
    vrm.scene.position.z = -3;
    vrm.scene.rotation.y = Math.PI;

    // シーンへの追加
    scene.add(vrm.scene);
  });
});

// アニメーションループの開始
function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

(2) プロジェクトフォルダ直下に 「dist」フォルダを生成し、「dist」フォルダ直下に「index.html」を作成し、以下のように編集。

・dist/index.html

<html>
  <head>
    <meta charset="utf-8">
    <style>
      body { margin: 0; }
    </style>
  </head>
  <body>
    <script type="text/javascript" src="main.js"></script>
  </body>
</html>

(3) 「ニコニ立体ちゃん (VRM)」からダウンロードして、「dist」フォルダ直下に「alicia.vrm」という名前で配置。

(4) ビルドと実行。

$ npm run build
$ npm run start

ブラウザが起動し、VRMが表示されます。

3. BVHファイルの準備

BVHファイル」(Biovision Hierarchy)は、ボーンの階層構造を含むモーション定義ファイルです。モーションキャプチャデータの保存などに使われます。

(1) 以下のサイトからバク転するアニメーション「85_02.bvh」を取得し、distフォルダ直下に配置。
他のアニメーションでも、コードのファイル名を変えれば動きます。

4. BVHファイルの読み込み

BVHファイルの読み込み手順は、次のとおりです。

(1) index.jsを以下のように編集。

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

// シーンの準備
const scene = new THREE.Scene();

// カメラの準備
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

// レンダラーの準備
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x7fbfff, 1.0);
document.body.appendChild(renderer.domElement);

// ライトの準備
const light = new THREE.DirectionalLight(0xffffff);
light.position.set(-1, 1, -1).normalize();
scene.add(light);

// アニメーションの準備
let mixer = null;

// VRMの読み込み
const loader = new GLTFLoader();
loader.load("./alicia.vrm", (gltf) => {
  VRM.from(gltf).then((vrm) => {
    // 姿勢の指定
    vrm.scene.position.y = -1;
    vrm.scene.position.z = -3;
    vrm.scene.rotation.y = Math.PI;

    // シーンへの追加
    scene.add(vrm.scene);

    // BVHの読み込み
    const loader = new BVHLoader();
    //01_01, 85_02
    loader.load("85_02.bvh", function (bvh) {
      // AnimationClipの生成
      const clip = createClip(vrm, bvh);

      // AnimationMixerの生成
      mixer = new THREE.AnimationMixer(vrm.scene);
      mixer.clipAction(clip).setEffectiveWeight(1.0).play();
    });
  });
});

// アニメーションループの開始
let lastTime = new Date().getTime();
function animate() {
  requestAnimationFrame(animate);

  // AnimationMixerの更新
  let time = new Date().getTime();
  if (mixer) mixer.update(time - lastTime);
  lastTime = time;

  renderer.render(scene, camera);
}
animate();

// トラックの取得
function findTrack(name, tracks) {
  for (let i = 0; i < tracks.length; i++) {
    if (tracks[i].name == name) return tracks[i];
  }
  return null;
}

// 配列をQuaternionに変換
function values2quaternion(values, i) {
  return new THREE.Quaternion(
    values[i * 4],
    values[i * 4 + 1],
    values[i * 4 + 2],
    values[i * 4 + 3]
  );
}

// キーリストの生成
function createKeys(id, tracks) {
  const posTrack = findTrack(".bones[" + id + "].position", tracks);
  const rotTrack = findTrack(".bones[" + id + "].quaternion", tracks);

  const keys = [];
  const rate = 0.008; // サイズの調整
  for (let i = 0; i < posTrack.times.length; i++) {
    const key = {};

    // 時間
    key["time"] = parseInt(posTrack.times[i] * 1000);

    // 回転
    if (id == "rButtock" || id == "lButtock") {
      const id2 = id == "rButtock" ? "rThigh" : "lThigh";
      let q1 = values2quaternion(rotTrack.values, i);
      const rotTrack2 = findTrack(".bones[" + id2 + "].quaternion", tracks);
      q1.multiply(values2quaternion(rotTrack2.values, i));
      key["rot"] = [-q1.x, q1.y, -q1.z, q1.w];
    } else {
      key["rot"] = [
        -rotTrack.values[i * 4],
        rotTrack.values[i * 4 + 1],
        -rotTrack.values[i * 4 + 2],
        rotTrack.values[i * 4 + 3],
      ];
    }

    // 位置
    if (id == "hip") {
      key["pos"] = [
        -posTrack.values[i * 3] * rate,
        posTrack.values[i * 3 + 1] * rate,
        -posTrack.values[i * 3 + 2] * rate,
      ];
    }
    keys.push(key);
  }
  if (keys.length == 0) return null;
  return keys;
}

// クリップの生成
function createClip(vrm, bvh) {
  // ボーンリストの生成
  const nameList = [
    VRMSchema.HumanoidBoneName.Head,
    VRMSchema.HumanoidBoneName.Neck,
    VRMSchema.HumanoidBoneName.Chest,
    VRMSchema.HumanoidBoneName.Spine,
    VRMSchema.HumanoidBoneName.Hips,
    VRMSchema.HumanoidBoneName.RightShoulder,
    VRMSchema.HumanoidBoneName.RightUpperArm,
    VRMSchema.HumanoidBoneName.RightLowerArm,
    VRMSchema.HumanoidBoneName.RightHand,
    VRMSchema.HumanoidBoneName.LeftShoulder,
    VRMSchema.HumanoidBoneName.LeftUpperArm,
    VRMSchema.HumanoidBoneName.LeftLowerArm,
    VRMSchema.HumanoidBoneName.LeftHand,
    VRMSchema.HumanoidBoneName.RightUpperLeg,
    VRMSchema.HumanoidBoneName.RightLowerLeg,
    VRMSchema.HumanoidBoneName.RightFoot,
    VRMSchema.HumanoidBoneName.LeftUpperLeg,
    VRMSchema.HumanoidBoneName.LeftLowerLeg,
    VRMSchema.HumanoidBoneName.LeftFoot,
  ];
  const idList = [
    "head",
    "neck",
    "chest",
    "abdomen",
    "hip",
    "rCollar",
    "rShldr",
    "rForeArm",
    "rHand",
    "lCollar",
    "lShldr",
    "lForeArm",
    "lHand",
    "rButtock",
    "rShin",
    "rFoot",
    "lButtock",
    "lShin",
    "lFoot",
  ];
  const bones = nameList.map((boneName) => {
    return vrm.humanoid.getBoneNode(boneName);
  });

  // AnimationClipの生成
  const hierarchy = [];
  for (let i = 0; i < idList.length; i++) {
    const keys = createKeys(idList[i], bvh.clip.tracks);
    if (keys != null) {
      hierarchy.push({ keys: keys });
    }
  }
  const clip = THREE.AnimationClip.parseAnimation(
    { hierarchy: hierarchy },
    bones
  );

  // トラック名の変更
  clip.tracks.some((track) => {
    track.name = track.name.replace(
      /^\.bones\[([^\]]+)\].(position|quaternion|scale)$/,
      "$1.$2"
    );
  });
  return clip;
}

BVHLoaderでBVHを読み込んだあと、VRM用のAnimationClipに変換してます。BVHとVRMは仕様の違いがあるので、調整処理を入れています。

・BVHに1=1mのような決められたサイズ仕様はないので、パラメータで調整。

const rate = 0.008; // サイズの調整

・BVHとVRMのモデルの体型が違うので、位置を使うのは「hip」のみ。
・X軸とZ軸の向きが逆。(BVHに軸の仕様はある?)

    // 位置
    if (id == "hip") {
      key["pos"] = [
        -posTrack.values[i * 3] * rate,
        posTrack.values[i * 3 + 1] * rate,
        -posTrack.values[i * 3 + 2] * rate,
      ];
    }

・BVHにはUpplerLegにあたる関節が2つ(ButtockとThigh)あるので乗算値を利用。

    // 回転
    if (id == "rButtock" || id == "lButtock") {
        // BVHにはUpperLegに2つ関節あるので掛け合わせる
      const id2 = id == "rButtock" ? "rThigh" : "lThigh";
      let q1 = values2quaternion(rotTrack.values, i);
      const rotTrack2 = findTrack(".bones[" + id2 + "].quaternion", tracks);
      q1.multiply(values2quaternion(rotTrack2.values, i));
      key["rot"] = [-q1.x, q1.y, -q1.z, q1.w];
    } else {

(2) ビルドと実行。

$ npm run build
$ npm run start

ニコニコ立体ちゃんがバク転してくれます。




この記事が気に入ったらサポートをしてみませんか?