exokitxr/avatarsでWebVRなVtuberのシステムを動かしてみた

本記事は VTuber Tech #1 Advent Calendar 2019 の8日目の記事です。

はじめに

まず本題に入る前に、WebVRで開発するメリットについて考えてみましょう!

一番大きなメリットは成果物の配布が非常に楽な点です。完成したものを適当なサーバーにアップロードしてそのURLをツイートするか、Google検索に登録するなりすれば完了です!OculusQuestのような公式ストアでの配信に審査があるデバイスにも簡単にコンテンツを配信できます。

OculusGoではリリースされてるけどOculusQuest版がいつまで経ってもなかなか出てこないコンテンツやQuest向けに出すぞ!といっていつまで経ってもリーリスされていないコンテンツがあるのを見てると、コンテンツ配信の問題は、なかなか難しいんだろうなと感じるところがありますね。

一方でWebVRで開発を行う際に一番難しい部分はどういったところかというと、それは便利なライブラリがUnityに比べて少ないところです。UnityだとFinal IKやVRTK、MRTKみたいなデファクトスタンダードになりかけている便利な有名ライブラリがありますが、WebVRの場合あまり聞かないです。特にこれまで、WebVRにはUnityでいうFinal IKにあたるような良いVRのIKライブラリが存在しませんでした。

しかし突如としてこの「exokitxr/avatars 」というWebVR用のVRIK的なライブラリが誕生しました!

自分はこのツイートでこのライブラリの存在を初めて知りました

このツイートを見ていただくと解ると思いますが、OculusQuestのブラウザで頭と両腕の3点トラッキングを実現していることがわかります。足の動きもいい感じにしてくれています。

内部的にpixivのthree-vrmというライブラリを利用しているため、VRMファイル対応もされています!

ついにWebVRでも「exokitxr/avatars」があればVtuber的なことができるようになりました。本記事では「exokitxr/avatars」を実際につかって簡単なサンプル実装を行っていきます。

注意点

Exokit Avatarsは特殊なライセンス設定となっています(MITなどではありません)。

具体的には、ビルド済みの "https://avatars.exokit.org/avatars.js" をURL直接インポートして使用することは許可されていますが、リポジトリをフォークしたり、ソースコードを改変することは明示的に許可されていない上に、将来的にGithub Sponsors機能を使った有償ライセンスの付与を行うことについて作者が言及しています。(開発チームのDiscordのavatarsチャンネルを参照https://discordapp.com/invite/Apk6cZN)

ですので、基本的には恐らくソースコードの改変なしかつ、URLインポート縛りという制限下で利用する必要がありそうです。

また、企業等がプロダクション利用してい良いのかどうかもよくわからないのでプロダクション利用されたい場合は、作者に直接問い合わせてください。

サンプル実装

そもそも公式のリポジトリのhttps://github.com/exokitxr/avatars/blob/master/index.html がサンプル兼ドキュメントになっているのですが、勉強を兼ねて、このindex.htmlを参考に自分の得意なReact + TypeScript環境でフルスクラッチでサンプルを作ってみました。本記事ではReactやTypeScript固有の部分については触れずにExokit Avatarsの使い方をゆるく解説していきたいと思います。

本サンプルのリポジトリはここ https://github.com/shinyoshiaki/react-vr-avatar です

Exokit Avatars はThree.jsというWebブラウザで3Dを扱うフレームワーク向けのライブラリなので、利用する際にはThree.jsの環境構築が必要になります。今回は、Reactというフレームワーク上でサンプル実装をしていくので、React向けのThree.jsのラッパーである「react-three-fiber」というライブラリを使用しています。

まず、WebVRの設定を行います。ソースコード文中のWEBVRと言う文字列を見て「WebVRの設定をしているのね」と思っていただければ良いかと。

src/App.tsx

const App: FC = () => {
  return (
    <Canvas
      vr
      camera={{ position: [0, 0, 15] }}
      onCreated={({ gl }) => {
        const button = WEBVR.createButton(gl);
        document.body.appendChild(button);
      }}
    >
      <Main />
    </Canvas>
  );
};

つぎにSceneの設定をしていきます。spotLightやMirror(鏡のオブジェクト)やAvatarManagerなんかを置いています。AvatarManagerの中で、Exokit Avatarsを扱っています。

src/Main.ts

const Main: FC = () => {
  const { scene } = useThree();
  const [container] = useInstance(new Object3D());

  useStart(() => {
    scene.add(container);
    const gridHelper = new GridHelper(10, 10);
    container.add(gridHelper);
  });

  return (
    <ContainerContext.Provider value={container}>
      <ambientLight intensity={0.5} />
      <spotLight
        intensity={0.6}
        position={[30, 30, 50]}
        angle={0.2}
        penumbra={1}
        castShadow
      />
      <Mirror size={new Vector2(2, 2)} position={new Vector3(0, 0, -0.5)} />
      <AvatarManager />
    </ContainerContext.Provider>
  );
};

AvatarManagerの中でAvatarManagerClassのインスタンスを作ってuseStartで起動、useFrameで毎フレームの更新をやってます。ですので実際の処理はAvatarManagerClass内でやってます。

src/three/AvatarManager.tsx

const AvatarManager: FC = () => {
  const { camera, gl } = useThree();
  const container = useContext(ContainerContext);
  const [avatar] = useInstance(new AvatarManagerClass(container, camera, gl));

  useStart(() => {
    avatar.load();
  });

  useFrame(() => {
    avatar.frameUpdate();
  });

  return <Fragment />;
};

さて、ここからが本番です。

まずコードをざっと見ていきましょう

src/three/avatarManager.class.ts

import { Avatar, AvatarClass } from "../typings/avatar";
import { Camera, Object3D, Vector3, WebGLRenderer } from "three";
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { getLeftInput, getRightInput } from "./controller";

import AVATAR from "https://avatars.exokit.org/avatars.js";
import { Locomotion } from "./locomotion";

export default class AvatarManagerClass {
  avatar?: Avatar;
  locomotion: Locomotion;

  params = { heightFactor: 0 };

  constructor(
    private container: Object3D,
    private camera: Camera,
    private gl: WebGLRenderer
  ) {
    this.locomotion = new Locomotion(container);
  }

  load = async () => {
    const model = await loadModel("/model2.vrm").catch(() => {});
    if (!model) return;
    this.container.add(model.scene);

    const avatar = new (AVATAR as AvatarClass)(model, {
      fingers: true,
      hair: true,
      visemes: true
    });
    this.params.heightFactor = getHeightFactor(avatar.height);
    this.container.scale.set(1, 1, 1).divideScalar(this.params.heightFactor);
    this.avatar = avatar;
  };

  frameUpdate = () => {
    const { heightFactor } = this.params;
    const { avatar, camera, container, locomotion, gl } = this;
    if (!avatar) return;

    avatar.inputs.hmd.position
      .copy(camera.position)
      .sub(container.position)
      .multiplyScalar(heightFactor);
    avatar.inputs.hmd.quaternion.copy(camera.quaternion);

    // right hand
    const rightInput = getRightInput();
    if (rightInput) {
      const { pointer, grip, padX, padY, stick } = rightInput;
      const leftHand = gl.vr.getController(0);
      const avatarGamepad = avatar.inputs.leftGamepad;

      avatarGamepad.pointer = pointer;
      avatarGamepad.grip = grip;
      avatarGamepad.quaternion.copy(leftHand.quaternion);
      avatarGamepad.position.copy(
        new Vector3()
          .copy(leftHand.position)
          .sub(container.position)
          .multiplyScalar(heightFactor)
      );

      locomotion.update(
        padX,
        padY,
        stick,
        avatar.inputs.hmd.quaternion,
        avatar.height
      );
    }

    // left hand
    const leftInput = getLeftInput();
    if (leftInput) {
      const { pointer, grip, padX, padY } = leftInput;
      const leftHand = gl.vr.getController(1);
      const avatarGamepad = avatar.inputs.rightGamepad;

      avatarGamepad.pointer = pointer;
      avatarGamepad.grip = grip;
      avatarGamepad.quaternion.copy(leftHand.quaternion);
      avatarGamepad.position.copy(
        new Vector3()
          .copy(leftHand.position)
          .sub(container.position)
          .multiplyScalar(heightFactor)
      );

      locomotion.update(
        padX,
        padY,
        false,
        avatar.inputs.hmd.quaternion,
        avatar.height
      );
    }

    avatar.update();
  };
}

import AVATAR from "https://avatars.exokit.org/avatars.js";でExokitAvatarsをインポートしています。

↓のコードでVRMファイルを読み込んで、ExokitAvatarsのコンストラクタに渡しています。コンストラクタでは、指と髪と口の利用設定を有効にしています。

その後、アバターのスケールの設定なんかをしています。

  load = async () => {
    const model = await loadModel("/model2.vrm").catch(() => {});
    if (!model) return;
    this.container.add(model.scene);

    const avatar = new (AVATAR as AvatarClass)(model, {
      fingers: true,
      hair: true,
      visemes: true
    });
    this.params.heightFactor = getHeightFactor(avatar.height);
    this.container.scale.set(1, 1, 1).divideScalar(this.params.heightFactor);
    this.avatar = avatar;
  };

次に毎フレームの処理を見て行きます。毎フレームやる必要があるのは、ヘッドセットやハンドコントローラの位置と入力をアバターに反映する処理です。

↓のコードでは頭の位置をExokitAvatarsに入力しています。「camera」が頭にあたります。

    avatar.inputs.hmd.position
      .copy(camera.position)
      .sub(container.position)
      .multiplyScalar(heightFactor);
    avatar.inputs.hmd.quaternion.copy(camera.quaternion);

↓のコードでは手の位置と、コントローラのボタン入力を扱っています。アナログスティックの操作でアバターが移動できるようになります。コード中で右や左がゴッチャゴチャになっているのは仕様です(多分、主観客観のどちら側から見て右か左かの違い) 。 rightInput で右手のコントローラの入力を、const leftHand = gl.vr.getController(0); で右手のコントローラの位置を取ってきています。

    // right hand
    const rightInput = getRightInput();
    if (rightInput) {
      const { pointer, grip, padX, padY, stick } = rightInput;
      const leftHand = gl.vr.getController(0);
      const avatarGamepad = avatar.inputs.leftGamepad;

      avatarGamepad.pointer = pointer;
      avatarGamepad.grip = grip;
      avatarGamepad.quaternion.copy(leftHand.quaternion);
      avatarGamepad.position.copy(
        new Vector3()
          .copy(leftHand.position)
          .sub(container.position)
          .multiplyScalar(heightFactor)
      );

      locomotion.update(
        padX,
        padY,
        stick,
        avatar.inputs.hmd.quaternion,
        avatar.height
      );
    }

ExokitAvatarsを動かすのに必要なコードは大体こんなもんです。結構簡単に使えて素晴らしいです!

動作確認

OculusQuestなどから↓のURLを開けば実際に動作確認できます!(PCVRはFireFox推奨)

https://shinyoshiaki.github.io/react-vr-avatar/build/

録画 https://www.youtube.com/watch?v=COOMAYKIReA

まとめ

Exokit Avatarsがあれば、WebVRでもVtuberになれる!

あとがき

前から、WebVRにVRIK的なものがあればもっとWebVRを積極的に使うのになぁ〜と思っていたところで、ちょうどExokit AvatarsがVRIKを実現してくれました。

Exokit Avatarsがあればブラウザで完結するVRChat的なモノも割と簡単に作ることができそうです。URLでワールドをシェアできればかなり良さそうですね(TwitterやDiscordにワールドのURLを貼ってURLを見た人がそのままシームレスにVR内のワールドに入るイメージ)。実際Exokit Avatarsを開発しているExokitチームもそんな感じのものも開発中みたいです。

欲を言えば完全に権利関係がフリーで良い感じのVR IKシステムが出てきてほしいですね、WebVR、Unity問わず(Final IK、学生には財布にキツかった....)