Help us understand the problem. What is going on with this article?

VRMのモデルをthree.jsで動かしてみた

More than 1 year has passed since last update.

概要

Webブラウザ(+α)でVRM形式のキャラクターを動かしてみた!

成果物

こんなものを作ってみました。

three.js + Cannon.js でめり込ませない!


preview: https://takenokotech.github.io/ts-vrm
code: https://github.com/TakenokoTech/ts-vrm

three.js + Leap Motion で動かす!

そもそもVRMとは?

VRアプリケーション向けの人型3Dアバター(3Dモデル)データを扱うためのファイルフォーマット
https://dwango.github.io/vrm/

ファイル自体は、JSONで書かれた3Dモデルやシーンを表現するフォーマット「glTF2.0」のデータにVRMの拡張情報を追加したデータです。

three.jsでVRMを表示

three.jsにはGLTFLoaderというgltfファイルを読み込めるローダーがあるので何も考えずに表示してみます。
モデルにはルービンちゃんをお借りしてます。

sample.ts
import * as THREE from "three";
import GLTFLoader from "three-gltf-loader";
import OrbitControls from "three-orbitcontrols";

// 幅、高さ取得
const width = window.innerWidth;
const height = window.innerHeight;

// レンダラの作成、DOMに追加
const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height);
renderer.setClearColor(0xf6f6f6, 1.0);
document.body.appendChild(renderer.domElement);

// シーンの作成、ライトの作成と追加
const scene = new THREE.Scene();
scene.add(new THREE.AmbientLight(0xffffff, 1));

// カメラの作成と追加、OrbitControlsの追加
const camera = new THREE.PerspectiveCamera(45, width / height, 1, 100);
camera.position.set(0, 1, -3);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1, 0);
controls.update();

// VRMのファイルをロード
new GLTFLoader().load("vrm/Victoria_Rubin.vrm", (data) => {
    scene.add(data.scene);
});

// レンダリング
const render = () => {
    renderer.render(scene, camera);
    requestAnimationFrame(render);
};
render();

残念ながら、VRM拡張のマテリアルが当たらなくて髪が真っ黒になってしまいました:expressionless:

VRMが表示できるローダーを作ってみる

https://github.com/Keshigom/WebVRM/blob/master/docs/src/WebVRM.js
こちらを参考にさせていただきました。

VRMLoader.ts
import * as THREE from "three";

export default class VrmLoader {
    load(url: string, callback: (vrm: THREE.GLTF) => void) {
        new GLTFLoader(THREE.DefaultLoadingManager).load(url, (vrm: THREE.GLTF) => {
            vrm.scene.name = "VRM";
            vrm.scene.castShadow = true;
            vrm.scene.traverse(this.attachMaterial);
            callback(vrm);
        });
    }

    private attachMaterial(object3D: THREE.Object3D) {
        const createMaterial = (material: any): THREE.MeshLambertMaterial => {
            let newMaterial: any = new THREE.MeshLambertMaterial();
            newMaterial.name = material.name;
            newMaterial.color.copy(material.color);
            newMaterial.map = material.map;
            newMaterial.alphaTest = material.alphaTest;
            newMaterial.morphTargets = material.morphTargets;
            newMaterial.morphNormals = material.morphNormals;
            newMaterial.skinning = material.skinning;
            newMaterial.transparent = material.transparent;
            // newMaterial.wireframe = true;
            return newMaterial;
        };

        let mesh = object3D as THREE.Mesh;
        if (!mesh || !mesh.material) return;
        mesh.castShadow = true;

        if (Array.isArray(mesh.material)) {
            const list: THREE.Material[] = mesh.material;
            list.forEach((m: THREE.Material, index: number) => (list[index] = createMaterial(m)));
        } else {
            mesh.material = createMaterial(mesh.material);
        }
    }
}
sample.ts
- new GLTFLoader().load("vrm/Victoria_Rubin.vrm", (data) => {
+ new VrmLoader().load("vrm/Victoria_Rubin.vrm", (data) => {

VRM拡張: /extensions/VRM/materialProperties あたりのUnity依存のマテリアル情報を無視してthree.jsのマテリアルを当てなおしました。
あと、テカりの出ないMeshBasicMaterialを使っていたりするので、MeshLambertMaterialで作り直しています。

イイ感じ:ok_hand:

three.jsで表示したVRMモデルを動かす

今回はUnityでVRMモデルの動きをアニメーションデータとして出力したものをthree.jsで読み込んで動かしてみます。
VRMでは「VRM拡張: モデルのボーンマッピング(json.extensions.VRM.humanoid)」という項目でnodeとHumanoidが紐づいたボーン構造が取得できます。

schema/humanoid.jsonHumanBodyBonesをjsonファイルに変換したものです。
Appendixにファイルを置いておきますね。

sample.ts
import humanoidBone from "schema/humanoid.json"

// VRMのファイルをロード
new VrmLoader().load("vrm/Victoria_Rubin.vrm", (data) => {
    scene.add(data.scene);
+   attachHumanoidBone(data)
});

//=== 追加 ===
function attachHumanoidBone(vrm: Vrm): { [n: number]: THREE.Bone } {
    const humanoidMap: { [n: number]: THREE.Bone } = {};
    for (const [i, humanBone] of vrm.userData.gltfExtensions.VRM.humanoid.humanBones.entries()) {
        for (const [j, node] of vrm.parser.json.nodes.entries()) {
            if (humanBone.node == j) {
                vrm.scene.traverse((object: any) => {
                    if (object.name == node.name) humanoidMap[humanoidBone[humanBone.bone]] = object;
                });
            }
        }
    }
    return humanoidMap;
}

これで、UnityのHumanoidと同じボーン構造として扱えるようになります。

three.js Unity

あとは、マッスルを当てるだけ!

Unity側でアニメーションデータを作る

それでは、Unityでthree.jsで扱えるアニメーションデータを作ってみましょう。
通信にはwebsocket-sharpを使ってみます。

送信元

WebSocketSender.cs
using System;
using System.Collections.Generic;
using UnityEngine;
using WebSocketSharp;
using static VrmAnimationJson;

public class WebSocketSender : MonoBehaviour {

    [SerializeField] private string characterName;
    [SerializeField] private Animator animator;
    [SerializeField] private Transform rootBone;

    private WebSocket webSocket;
    private HumanPose humanPose = new HumanPose ();
    private readonly VrmAnimationJson anime = new VrmAnimationJson ();

    void Start () {
        this.LoadBone ();
        this.StartWebsocket ();
    }

    void Update () {
        if (!webSocket.IsAlive) {
            webSocket.Connect ();
            return;
        }
        this.UpdateBone ();
        this.UpdateWebsocket ();
    }

    private void OnDestroy () {
        if (webSocket != null) webSocket.Close ();
    }

    private void StartWebsocket () {
        Debug.Log ("WebsocketAccessor Start");
        webSocket = new WebSocket ("ws://localhost:5001/");
        webSocket.OnOpen += (sender, e) => { Debug.Log ("Opended"); };
        webSocket.OnMessage += (sender, e) => { };
        webSocket.Connect ();
    }

    private void UpdateWebsocket () {
        Debug.Log ("WebsocketAccessor Update");
        try {
            webSocket.Send (JsonUtility.ToJson (this.anime));
        } catch (Exception e) {
            Debug.Log (e);
        }
    }

    private void LoadBone () {
        for (int i = 0; i <= 54; i++) {
            Transform bone = GetComponent<Animator> ().GetBoneTransform ((HumanBodyBones) i);
            this.anime.vrmAnimation.Add (new VrmAnimation ());
            this.anime.vrmAnimation[i].keys.Add (new Key ());
        }
    }

    private void UpdateBone () {
        Animator animator = GetComponent<Animator> ();
        HumanPoseHandler humanPoseHandler = new HumanPoseHandler (animator.avatar, this.rootBone);
        humanPoseHandler.GetHumanPose (ref humanPose);

        for (int i = 0; i <= 54; i++) {
            Transform bone = GetComponent<Animator> ().GetBoneTransform ((HumanBodyBones) i);
            if (bone == null) continue;
            float[] pos = new float[3] { bone.localPosition.x, bone.localPosition.y, bone.localPosition.z };
            float[] rot = new float[4] { bone.localRotation.x, bone.localRotation.y, bone.localRotation.z, bone.localRotation.w };
            float[] scl = new float[3] { bone.localScale.x, bone.localScale.y, bone.localScale.z };
            this.anime.vrmAnimation[i].name = "" + i;
            this.anime.vrmAnimation[i].bone = bone.name;
            this.anime.vrmAnimation[i].keys[0] = new Key (pos, rot, scl, 0);
        }
    }
}
  • StartでWebsocketサーバーの接続とVrmAnimatorからボーン構造の取得
  • Updateでボーン状態の取得とWebsocketサーバーへの送信

送信するアニメーションのモデル

VrmAnimationJson.cs
[Serializable]
public class VrmAnimationJson {

    public List<VrmAnimation> vrmAnimation = new List<VrmAnimation> ();

    [Serializable]
    public class VrmAnimation {
        public string name = "";
        public string bone = "";
        public List<Key> keys = new List<Key> ();
    }

    [Serializable]
    public class Key {
        public float[] pos;
        public float[] rot;
        public float[] scl;
        public long time;

        public Key (float[] pos, float[] rot, float[] scl, long time) {
            this.pos = pos;
            this.rot = rot;
            this.scl = scl;
            this.time = time;
        }

        public Key () { }
    }
}

作った Web Socket Sender を適当なオブジェクトに当てて、AnimatorとTransformにVRMを割り当てます。
ボーン構造がちがうVRMでも動きが確認できるようにアリシアちゃんをお借りしてます。

WebSocket Server

unityからwebにアニメーションを送るため、nodeでWebSocketのサーバーを立てます。

server.ts
import { Server } from "ws";
const server = new Server({ port: 5001 });

server.on("connection", websocket => {
    let time = new Date();

    websocket.on("message", message => {
        console.log("Received: " + (new Date().getMilliseconds() - time.getMilliseconds()) + "ms" /* + message*/);
        time = new Date();
        server.clients.forEach(client => {
            client.send(message);
        });
    });

    websocket.on("close", () => {
        console.log("I lost a client");
    });
});
console.log("start websocket server. port=5001");

three.jsのボーンにあてる

WebSocket から受け取ったアニメーションデータをthree.jsで扱えるように変換してボーンを動かします。

sample.ts
//=== 追加 ===
let boneMap: { [n: number]: THREE.Bone }; // attachHumanoidBoneで取得したボーン状態を入れておく
let messageData: any;

try {
    const socket = new WebSocket("ws://127.0.0.1:5001");
    socket.onopen = (event: Event) => {
        console.log("websocket open");
        socket.onmessage = (message: any) => {
            messageData = message.data;
        };
        socket.onclose = () => {
            console.log("websocket close");
        };
    };
} catch (e) {
    console.warn(e);
}

// renderで各フレームごとに呼び出す
function updateFrame() {
    if (!messageData) return;
    const animation: VrmAnimation[] = JSON.parse(messageData).vrmAnimation;
    for (let ani of animation) {
        const name = -(-ani.name);
        const key = ani.keys[ani.keys.length - 1];
        if (!boneMap || !boneMap[name] || key.rot.length != 4) continue;
        boneMap[name].quaternion.set(-key.rot[0], -key.rot[1], key.rot[2], key.rot[3]);
    }
}

export interface VrmAnimation {
    name: string;
    bone: string;
    keys: Key[];
}

export interface Key {
    pos: number[];
    rot: number[];
    scl: number[];
    time: number;
}

unityとWebsocketサーバーを起動してみたら無事にアニメーションが当たりました:open_hands:

まとめ

今回はWebSocketで通信してモーションデータを送りました。

ちなみにVrmAnimation.Keytimeを使ってフレームごとに記録するようにすると、
静的なjsonファイルを読み込むだけでキャラクターを動かすことができます。

AnimationClipを使う必要がないので楽チンですね!

Appendix.

TakenokoTech
たけのこです! 都内でエンジニアやってます。 最近はVRに嵌っています。
https://takenoko.tech
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした