2023-10-24 / @syui

vrm , 3d

three-vrm v2.0 update

three-vrm関連のブログを調べていると、ほとんどのコードが動かないのでthree-vrm v2.0の対応をまとめます。

唯一、動くコードを発行していたのがpixivのexampleでした。参考にしてみてください。

$ git clone https://github.com/pixiv/three-vrm/
$ cd three-vrm
$ git checkout gh-pages
$ cd ./packages/three-vrm/examples/

$ vim lookat-advanced.html 

example

以下は私が作ったexampleです。基本的には.jsなので好きなframeworkで動かしてみてください。

"dependencies": {"@pixiv/three-vrm": "^2.0.6", "three": "^0.157.0"}
import * as THREE from 'three';
import { GridHelper, Mesh, MeshLambertMaterial, PlaneGeometry, Vector3, Color, DirectionalLight, Fog, HemisphereLight, AnimationAction, AnimationClip, AnimationMixer, MathUtils, Matrix4, Quaternion } from 'three';
import { VRMLoaderPlugin, VRMUtils, VRMLookAt, VRMSchema } from '@pixiv/three-vrm';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';

// model
const defaultModelUrl = 'https://pixiv.github.io/three-vrm/packages/three-vrm/examples/models/VRM1_Constraint_Twist_Sample.vrm';

// lookat
const _v3A = new THREE.Vector3();
class VRMSmoothLookAt extends VRMLookAt {
	constructor(humanoid, applier) {
		super(humanoid, applier);
		this.smoothFactor = 10.0;
		this.yawLimit = 45.0;
		this.pitchLimit = 45.0;
		this._yawDamped = 0.0;
		this._pitchDamped = 0.0;
	}
	update(delta) {
		if ( this.target && this.autoUpdate ) {
			this.lookAt( this.target.getWorldPosition( _v3A ) );
			if (
				this.yawLimit < Math.abs( this._yaw ) ||
					this.pitchLimit < Math.abs( this._pitch )
			) {
				this._yaw = 0.0;
				this._pitch = 0.0;
			}
			const k = 1.0 - Math.exp( - this.smoothFactor * delta );
			this._yawDamped += ( this._yaw - this._yawDamped ) * k;
			this._pitchDamped += ( this._pitch - this._pitchDamped ) * k;
			this.applier.applyYawPitch( this._yawDamped, this._pitchDamped );
			this._needsUpdate = false;
		}
		if ( this._needsUpdate ) {
			this._needsUpdate = false;
			this.applier.applyYawPitch( this._yaw, this._pitch );
		}
	}
}

// renderer
const renderer = new THREE.WebGLRenderer({alpha: true, antialias: true});
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.enabled = true;
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setPixelRatio( window.devicePixelRatio );
document.body.appendChild( renderer.domElement );

// camera
const camera = new THREE.PerspectiveCamera( 30.0, window.innerWidth / window.innerHeight, 0.1, 20.0 );
camera.position.set( 0.0, 1.0, 5.0 );

// camera controls
const controls = new OrbitControls( camera, renderer.domElement );
controls.screenSpacePanning = false;
controls.target.set( 0.0, 1.0, 0.0 );
controls.update();

// scene
const scene = new THREE.Scene();

// add color
const bgColor = new Color(0xffffff);
scene.background = new Color(bgColor);
scene.fog = new Fog(bgColor, 3, 10);
const ambiantLight = new HemisphereLight(0xffffff, 0x444444);
ambiantLight.position.set(0, 20, 0);
scene.add(ambiantLight);

// add mesh
const floor = new Mesh(
  new PlaneGeometry(100, 100),
  new MeshLambertMaterial({
    color: 0xffffff,
    depthWrite: true,
  })
);
floor.position.y = -0.5;
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
const grid = new GridHelper(50, 100, 0xffffff, 0xffffff);
scene.add(grid);

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

// gltf and vrm
let currentVrm = undefined;
let currentAnimationUrl = undefined;
let currentMixer = undefined;
const helperRoot = new THREE.Group();
helperRoot.renderOrder = 10000;
scene.add( helperRoot );

function loadVRM( modelUrl ) {
	const loader = new GLTFLoader();
	loader.crossOrigin = 'anonymous';
	helperRoot.clear();
	loader.register((parser) => {
		return new VRMLoaderPlugin(parser, { autoUpdateHumanBones: true } );
	});
	loader.load(
		modelUrl,
		(gltf) => {
			const vrm = gltf.userData.vrm;
			VRMUtils.removeUnnecessaryVertices(gltf.scene);
			VRMUtils.removeUnnecessaryJoints(gltf.scene);
			//VRMUtils.rotateVRM0(vrm);
			
			vrm.scene.traverse((obj) => {
				obj.frustumCulled = false;
			});

			// replace the lookAt to our extended one
			const smoothLookAt = new VRMSmoothLookAt(vrm.humanoid, vrm.lookAt.applier);
			smoothLookAt.copy(vrm.lookAt);
			vrm.lookAt = smoothLookAt;
			scene.add(vrm.scene);
			currentVrm = vrm;
			vrm.lookAt.target = camera;
			currentVrm.humanoid.getNormalizedBoneNode('leftUpperArm').rotation.z = 1.3;
			currentVrm.humanoid.getNormalizedBoneNode('rightUpperArm').rotation.z = -1.3;
		},
	)
}

loadVRM( defaultModelUrl );

function blink(){
	var rand = Math.random()
	if (rand > .9) {
		currentVrm.expressionManager.setValue('blink', 1);
	} else {
		currentVrm.expressionManager.setValue('blink', 0);
	}
}

// animate
const clock = new THREE.Clock();
function animate() {
	requestAnimationFrame(animate);
	const delta = clock.getDelta();
	if (currentMixer) {
		currentMixer.update(delta);
	}
	if (currentVrm) {
		const s = 0.01 * Math.PI * Math.sin(Math.PI * clock.elapsedTime);
		blink();
		currentVrm.humanoid.getNormalizedBoneNode('neck').rotation.y = s;
		//currentVrm.humanoid.getNormalizedBoneNode('leftUpperArm').rotation.z = s;
		//currentVrm.humanoid.getNormalizedBoneNode('rightUpperArm').rotation.x = s;
		currentVrm.update(delta);
	}
	renderer.render(scene, camera);
}
animate();

scene rotation

modelを回転させる

vrm.scene.rotation.y = 3;
vrm.scene.rotation.y = Math.PI * Math.sin(clock.getElapsedTime());

まばたきを制御します。

vrm.expressionManager.setValue('blink', 1);

pose

左手を動かします。

vrm.humanoid.getNormalizedBoneNode('leftUpperArm').rotation.z = 1;

lookat

視線をカメラに合わせます。

// lookat
const _v3A = new THREE.Vector3();
class VRMSmoothLookAt extends VRMLookAt {
	constructor(humanoid, applier) {
		super(humanoid, applier);
		this.smoothFactor = 10.0;
		this.yawLimit = 45.0;
		this.pitchLimit = 45.0;
		this._yawDamped = 0.0;
		this._pitchDamped = 0.0;
	}
	update(delta) {
		if ( this.target && this.autoUpdate ) {
			this.lookAt( this.target.getWorldPosition( _v3A ) );
			if (
				this.yawLimit < Math.abs( this._yaw ) ||
					this.pitchLimit < Math.abs( this._pitch )
			) {
				this._yaw = 0.0;
				this._pitch = 0.0;
			}
			const k = 1.0 - Math.exp( - this.smoothFactor * delta );
			this._yawDamped += ( this._yaw - this._yawDamped ) * k;
			this._pitchDamped += ( this._pitch - this._pitchDamped ) * k;
			this.applier.applyYawPitch( this._yawDamped, this._pitchDamped );
			this._needsUpdate = false;
		}
		if ( this._needsUpdate ) {
			this._needsUpdate = false;
			this.applier.applyYawPitch( this._yaw, this._pitch );
		}
	}
}

loader.load(
        modelUrl,
        (gltf) => {
            const vrm = gltf.userData.vrm;

            // replace the lookAt to our extended one
            const smoothLookAt = new VRMSmoothLookAt(vrm.humanoid, vrm.lookAt.applier);
            smoothLookAt.copy(vrm.lookAt);
            vrm.lookAt = smoothLookAt;
            scene.add(vrm.scene);
            currentVrm = vrm;
            vrm.lookAt.target = camera;
        },
)

animation

アニメーションは基本的に以下の構文になります。

vrmではなくcurrentVrmを使用します。

// animate
const clock = new THREE.Clock();
function animate() {
	requestAnimationFrame(animate);
	const delta = clock.getDelta();
	if (currentMixer) {
		currentMixer.update(delta);
	}
	if (currentVrm) {
        // ここに追加
        currentVrm.humanoid.getNormalizedBoneNode('leftUpperArm').rotation.z = 1;
        currentVrm.update(delta);
	}
	renderer.render(scene, camera);
}
animate();

bone name

BoneNodeでしているするときは小文字から始めます。

currentVrm.humanoid.getNormalizedBoneNode('head').rotation.z = 1;
currentVrm.humanoid.getNormalizedBoneNode('leftHand').rotation.z = 1;
const enum VMDBoneNames {
  Root = '全ての親',
  Center = 'センター',
  Hips = '下半身',
  Spine = '上半身',
  Chest = '上半身2',
  Neck = '首',
  Head = '頭',
  LeftEye = '左目',
  LeftShoulder = '左肩',
  LeftUpperArm = '左腕',
  LeftLowerArm = '左ひじ',
  LeftHand = '左手首',
  LeftThumbProximal = '左親指0',
  LeftThumbIntermediate = '左親指1',
  LeftThumbDistal = '左親指2',
  LeftIndexProximal = '左人指1',
  LeftIndexIntermediate = '左人指2',
  LeftIndexDistal = '左人指3',
  LeftMiddleProximal = '左中指1',
  LeftMiddleIntermediate = '左中指2',
  LeftMiddleDistal = '左中指3',
  LeftRingProximal = '左薬指1',
  LeftRingIntermediate = '左薬指2',
  LeftRingDistal = '左薬指3',
  LeftLittleProximal = '左小指1',
  LeftLittleIntermediate = '左小指2',
  LeftLittleDistal = '左小指3',
  LeftUpperLeg = '左足',
  LeftLowerLeg = '左ひざ',
  LeftFoot = '左足首',
  LeftFootIK = '左足IK',
  LeftToes = '左つま先',
  LeftToeIK = '左つま先IK',
  RightEye = '右目',
  RightShoulder = '右肩',
  RightUpperArm = '右腕',
  RightLowerArm = '右ひじ',
  RightHand = '右手首',
  RightThumbProximal = '右親指0',
  RightThumbIntermediate = '右親指1',
  RightThumbDistal = '右親指2',
  RightIndexProximal = '右人指1',
  RightIndexIntermediate = '右人指2',
  RightIndexDistal = '右人指3',
  RightMiddleProximal = '右中指1',
  RightMiddleIntermediate = '右中指2',
  RightMiddleDistal = '右中指3',
  RightRingProximal = '右薬指1',
  RightRingIntermediate = '右薬指2',
  RightRingDistal = '右薬指3',
  RightLittleProximal = '右小指1',
  RightLittleIntermediate = '右小指2',
  RightLittleDistal = '右小指3',
  RightUpperLeg = '右足',
  RightLowerLeg = '右ひざ',
  RightFoot = '右足首',
  RightFootIK = '右足IK',
  RightToes = '右つま先',
  RightToeIK = '右つま先IK',
}

const enum VMDMorphNames {
  Blink = 'まばたき',
  BlinkR = 'ウィンク',
  BlinkL = 'ウィンク右',
  A = 'あ',
  I = 'い',
  U = 'う',
  E = 'え',
  O = 'お',
}

カメラ移動

animationで使うといいです。

camera.translateZ(0.01);
camera.translateY(0.01);
camera.translateX(0.01);

// カメラ目線で移動
camera.lookAt(new THREE.Vector3(0, 0, 0));

ref

https://pixiv.github.io/three-vrm/packages/three-vrm/examples/

https://pixiv.github.io/three-vrm/packages/three-vrm-materials-mtoon/examples/

https://gist.github.com/ahuglajbclajep/6ea07f6feb250aa776afa141a35e725b