2024-03-17 / @syui

vrm , threejs

three-vrmでvrmaを読み込む

three-vrmでvrmaを読み込むことができるようになりました。そこで今回は色々なtipsを紹介します。

three-vrmは、私がよく3d-modelの読み込みに使っているthree.js.vrmに対応させたものです。three.jsは.gltf(v2.0)を読み込めますので、その拡張である.vrm.gltf.glbに変換して読み込めばいいのですが、色々と問題があります。そのためthree-vrmを使ったほうが見栄えが良くなります。

three-vrm -> vrma

使用するのは、npm, webpack, tsあたりです。

nodeはv18.14.1です。場合によってはnvmを使用してください。

.
├── dist
│   ├── index.html
│   ├── vrm/ai.vrm
│   └── vrma/VRMA_01.vrma
├── package.json
├── src
│   └── index.ts
├── tsconfig.json
└── webpack.config.js

./dist/vrm/, ./dist/vrma/にファイルを置いてください。

後述しますが、src/index.tsの以下の部分で読み込みます。

  load("/vrm/ai.vrm");
  load("/vrma/VRMA_01.vrma");
{
	"name": "model",
	"version": "1.0.0",
	"private": true,
	"scripts": {
		"build": "webpack",
		"dev": "webpack-dev-server --open"
	},
	"devDependencies": {
		"ts-loader": "^9.5.1",
		"typescript": "^5.4.2",
		"webpack": "^5.90.3",
		"webpack-cli": "^5.1.4",
		"webpack-dev-server": "^5.0.3"
	},
	"dependencies": {
		"@pixiv/three-vrm": "^2.1.1",
		"@pixiv/three-vrm-animation": "^2.1.1",
		"three": "^0.162.0"
	}
}
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "skipLibCheck": true
  }
}
const path = require('path');

module.exports = {
    mode: 'development',
	entry: './src/index.ts',
    module: {
        rules: [
            {
                test: /\.ts$/,
                loader: 'ts-loader'
            }
        ]
    },
    resolve: { extensions: ['.ts', '.js'] },
	output: {
	        filename: 'main.js',
	        path: path.join(__dirname, "dist")
	},
	devServer: {
	    static: {
	      directory: path.join(__dirname, "dist"),
	    }
	}
}
$ npm i
import * as THREE from "three"
import { Vector3 } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader"
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { VRMLoaderPlugin } from "@pixiv/three-vrm";
import { createVRMAnimationClip, VRMAnimationLoaderPlugin } from "@pixiv/three-vrm-animation";

window.addEventListener("DOMContentLoaded", () => {
  const canvas = document.getElementById("canvas");
  if (canvas == null) return;

  const scene = new THREE.Scene();

  const camera = new THREE.PerspectiveCamera(
    30, canvas.clientWidth/canvas.clientHeight, 0.1, 20);
  camera.position.set(0.0, 0, -4.0)
  camera.rotation.set(0.0, Math.PI, 0.0)

		camera.lookAt(new THREE.Vector3(0, 0, 0));
		const renderer = new THREE.WebGLRenderer();
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(canvas.clientWidth, canvas.clientHeight);
  renderer.setClearColor(0x7fbfff, 1.0);
  canvas.appendChild(renderer.domElement);


  const light = new THREE.DirectionalLight(0xffffff, Math.PI);
  light.position.set(1.0, 1.0, 1.0);
  scene.add(light);

  let currentVrm: any = undefined;
  let currentVrmAnimation: any = undefined;
  let currentMixer:any = undefined;

  function load(url: string) {
    loader.load(
        url,
        (gltf) => {
            tryInitVRM(gltf);
            tryInitVRMA(gltf);
        },
        (progress) => console.log( 
          "Loading model...", 
          100.0 * (progress.loaded / progress.total), "%" 
        ),
        (error) => console.error(error)
    );
  }

  function tryInitVRM(gltf: any) {
    const vrm = gltf.userData.vrm;
    if ( vrm == null ) {
        return;
    }
    currentVrm = vrm;
    scene.add(vrm.scene);
    initAnimationClip();
  }

  function tryInitVRMA(gltf: any) {
    const vrmAnimations = gltf.userData.vrmAnimations;
    if (vrmAnimations == null) {
        return;
    }
    currentVrmAnimation = vrmAnimations[0] ?? null;
    initAnimationClip();
  }

  function initAnimationClip() {
    if (currentVrm && currentVrmAnimation) {
        currentMixer = new THREE.AnimationMixer(currentVrm.scene);
        const clip = createVRMAnimationClip(currentVrmAnimation, currentVrm);
        currentMixer.clipAction(clip).play();
    }
  }
  
  const loader = new GLTFLoader();
  loader.register((parser) => {
    return new VRMLoaderPlugin(parser);
  });
  loader.register((parser) => {
    return new VRMAnimationLoaderPlugin(parser);
  });

  // ここで読み込む
  load("/vrm/ai.vrm");
  load("/vrma/VRMA_01.vrma");

  const clock = new THREE.Clock();
  clock.start();

  scene.background = new THREE.Color( 0x404040 );
  const directionalLight = new THREE.DirectionalLight(0xffffff);
  directionalLight.position.set(1, 1, 1);
  scene.add(directionalLight);

  const ambientLight = new THREE.AmbientLight(0x333333);
  scene.add(ambientLight);

  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = 0.2;
  controls.enableRotate = true;
  controls.target.set( 0.0, 1.0, 0.0 );

  const update = () => {
      controls.update();
      requestAnimationFrame(update);
      const deltaTime = clock.getDelta();
      if (currentMixer) {
          currentMixer.update(deltaTime);
      }
      if (currentVrm) {
          currentVrm.update(deltaTime);
      }

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

})
<html>
	<head>
		<script src="main.js"></script>
	</head>
	<body>
		<div id="canvas" style="width:100%;height:640px;"></div>
	</body>
</html>
$ npm run build
$ npm run dev

random vrma

例えば、random(ランダム)で.vrmaを切り替えてみましょう。

//VRMA_01 全身を見せる
//VRMA_02 挨拶
//VRMA_03 Vサイン
//VRMA_04 撃つ
//VRMA_05 回る
//VRMA_06 モデルポーズ
//VRMA_07 屈伸運動

setInterval(() => {
        load("./vrma/VRMA_0" + Math.floor(Math.random() * 7 +  1) + ".vrma");	
}, 10000);

自然に見せるにはidle状態の.vrmaを用意してsetIntervalをすれば良さそうですね。

make vrma

bvhを作成してそれをvrmaに変換することができます。基本的に.vrma.gltfでいくつかの宣言を行うことで有効になります。それをglbに変換してvrmaにリネームします。

しかし、めんどくさすぎてそんなことはやってられませんので、UniVRMを使用すると良いでしょう。

例えば、最新版のUniVRMをinstallして、AnimationClipToVrmaSample/Assetsをunity(project)にコピーすればSampleMotion/Wave.anim.vrmaでexportできます。

基本的な手順としては、まずue5.bvhから.fbxを用意し、それをunityで読み込みます。

読み込むとAnimation Clipができます。これはunity独自のmodel motionのようなものです。まずはfbxのAnimation TypeHumanoidにします。

ue5からfbxをexportする際は、animationですべてのチェックを付けましょう。精度が高まります。あと、コリジョンは外しました。

    1. Animation Type : Humanoid
    1. Animation Clip -> .vrma

ref : https://note.com/hibit_at/n/nf05ac76ecc3c

sRGBEncoding

見た目を変えます。

// https://threejs.org/docs/#api/en/constants/Renderer
const renderer = new THREE.WebGLRenderer({antialias: true, alpha: true});
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
//renderer.outputEncoding = THREE.sRGBEncoding;
renderer.outputColorSpace = THREE.SRGBColorSpace;

ref : https://koro-koro.com/threejs-no4/

fvp -> glb

.fvpというのは3d-printの拡張子です。

これはポーズを決めて出力できますが、それをglbに変換することでポーズ付きのglbができます。

ポーズもfvpの出力もvroid studioで行います。