Unity WebGLとThree.jsに連動して踊っていただく

Unity WebGLとThree.jsに連動して踊っていただく

ごぶさたしてます。@fnobiです。こちらは面白法人グループ Advent Calendar 2022の20日目の記事になります。

確認してみたら前に記事を書いたのは2019年のようですねー。3年の間にディレクター転向したり子会社に転籍したりなんかいろんなことが起きてました。げんきです。現在はカヤックアキバスタジオにてファンコミュニティ事業というチームを率いて頑張っています。

akiba.kayac.studio

さてそんなわけで、今回はせっかくなので、今年のプロジェクトでよく使ったUnity WebGL書き出しの話題です。

まずはこちらのサンプルをご覧ください。

youtu.be

レンガ的ななにかが、右と左でおんなじ動きをしてますね。つまんねえなと思ったあなたもうちょっと待ってね。こちら実は、どちらもWebGLをつかった3Dグラフィックで、 左はUnity WebGL・右はThree.js を使って動作しています。

なかなか同時にお目見えすることは少ない技術ですが、今年制作した某案件において、

  • Web用のARエンジンである8thwallを使って空間認識
  • その認識した空間を引き継ぎながら、Unity WebGLのゲームを動かす

というなかなかトリッキーな要件がありまして、これはその検証用につくったモック(を手直ししたもの)になっています。

WebGLを扱う人なら知らない人はいない定番のライブラリThree.js・近頃かなり進化してモバイル等でもある程度安心して動かせるようになったUnity WebGL、どちらもWebGLコンテンツを実現する上でかなりメジャーな選択肢ですが、裏側にある考え方はかなり異なっています。

今回はこのサンプルを紹介しつつ、Unity・Three.jsそれぞれのWebGLの考え方を掘り下げていきます。どうか最後までお付き合いをば!

とりあえずUnity WebGLを動かそう

まずUnity版の中身をつくっていきましょう。

Unityでつくったつまらない空間

空間はこんな感じですね。ご察しの通り筆者はUnityは専門外なので大したものつくれませんが、まあ3Dっぽい何かがあればよしとしましょう。 これをさっそくWebGL用に書き出してみます。

Unityから書き出したつまらない空間

はい、何も嬉しくない空間ができました。こちらがThree.jsと連動する、というのが目標になってきます。

javascriptからUnityビルドを動かす

なお、このビルドを実行すると、以下のようなファイル群が出てきます。

sample2021
├── Build
│   ├── sample2021.data
│   ├── sample2021.framework.js
│   ├── sample2021.loader.js
│   └── sample2021.wasm
└── index.html

このindex.htmlをブラウザで動作させれば、Unityで作成したプロジェクトが動作するという形ですが、このindex.htmlは自分のプロジェクトでは だいたい捨てています

Web書き出しを使う場合、他のWebシステム等々とも連携して動作させたいケースが大半なので、html部分をUnity側で吐き出されてしまうと、サーバーサイド実装から値を渡す際などなにかと面倒です。一応、Unity内で WebGLTemplates を用意することでこのhtmlをカスタマイズすることもできますが、動的な値が入れられないのは変わらないので、とりあえずの動作確認をするときだけ使う、みたいな割り切り方でよいでしょう。

というわけで、このBuild以下のファイルたちのみをjsから読み込んで動かすことを考えていきます。

上記4つのファイルの中で、ゲームの本体とでも言うべきファイルは sample2021.data sample2021.framework.js sample2021.wasm の3つで、 sample2021.loader.js はゲームを呼び出す際の前提となるライブラリ・関数が含まれたスクリプトになっています。こちらを読み込むことで、下記の関数が使えるようになります。

declare function createUnityInstance(
  canvas: HTMLCanvasElement,
  options: {
    dataUrl: string;
    frameworkUrl: string;
    codeUrl: string;
    streamingAssetsUrl: string;
    companyName: string;
    productName: string;
    productVersion: string;
    matchWebGLToCanvasSize?: boolean;
    devicePixelRatio?: number;
  },
  onProgress?: (progress: number) => void,
  onSuccess?: (unityInstance: UnityInstance) => void,
  onError?: (message: string) => void
): Promise<UnityInstance>;

こちらを実行することで、渡した任意のcanvas上でゲームが動くようになります。先程のゲーム本体の3つのファイルのURLもこのタイミングで指定する形なので、今回のケースで言うと例えばこんな呼び出し方になります。

const canvas = document.getElementById('canvas-for-unity');

const DIR_FOR_UNITY_BUILD = './dir/for/unity/sample2021/Build';

const unityInstance = await createUnityInstance(
  canvas,
  {
    companyName: 'KAYAC AKIBA STUDIO',
    productName: 'unity-webgl-sample',
    productVersion: '0.0.1',
    dataUrl: `${DIR_FOR_UNITY_BUILD}/sample2021.data`,
    frameworkUrl: `${DIR_FOR_UNITY_BUILD}/sample2021.framework.js`,
    codeUrl: `${DIR_FOR_UNITY_BUILD}/sample2021.wasm`,
    streamingAssetsUrl: "StreamingAssets",
    matchWebGLToCanvasSize: true
  }
);

また、この全体のディレクトリ名およびBuild以下のファイル名に含まれる sample2021 という名称は、Unityの書き出し先を選択する際につけた名前に対応しており、あとからリネームしない限りは同じものが入ります。ので、こんな感じで関数化すると幾分シンプルになるかもですね。

function unityHelper(
  canvas: HTMLCanvasElement,
  unityBuildRoot: string,
  buildName: string
) {
  return createUnityInstance(canvas, {
    companyName: "KAYAC AKIBA STUDIO",
    productName: "unity-webgl-sample",
    productVersion: "0.0.1",
    dataUrl: `${unityBuildRoot}/${buildName}.data`,
    frameworkUrl: `${unityBuildRoot}/${buildName}.framework.js`,
    codeUrl: `${unityBuildRoot}/${buildName}.wasm`,
    streamingAssetsUrl: "StreamingAssets",
    matchWebGLToCanvasSize: true
  });
}

// ----------------------

const canvas = document.getElementById("canvas-for-unity");
const unityInstance = await unityHelper(
  canvas,
  `./dir/for/unity/sample2021/Build`,
  "sample2021"
);

だいぶ汎用的に使えそうな感じになりましたね。

その他、細かいケースを拾ったり、Reactで扱いやすくするためcustom hooksになおしたりしたサンプルが最初のレポジトリに入れてあるので、よかったら見てみてください。

Three.jsを動かす

さて続いては、Three.jsで似たような空間をつくっていきましょう。こちらは歴史も古いライブラリですし、世の中にサンプルもたくさんあるのでさくっと参りましょう。

import {
  BoxGeometry,
  Mesh,
  MeshBasicMaterial,
  PerspectiveCamera,
  Scene,
  WebGLRenderer
} from "three";

// NOTE: 以下は今回コードで同期していないので、Unity側と予め設定をそろえておく必要がある
const CAMERA_FOV = 60;
const CAMERA_CLIPPING_NEAR = 0.3;
const CAMERA_CLIPPING_FAR = 1000;

const BOX_SIZE_X = 2;
const BOX_SIZE_Y = 1;
const BOX_SIZE_Z = 3;

function initThree(canvas: HTMLCanvasElement) {
  // Three.js 三種の神器
  const renderer = new WebGLRenderer({
    canvas
  });
  const scene = new Scene();
  const camera = new PerspectiveCamera(
    CAMERA_FOV,
    1,
    CAMERA_CLIPPING_NEAR,
    CAMERA_CLIPPING_FAR
  );

  // Unity側と同じ形状の立体を置く
  const geometry = new BoxGeometry(BOX_SIZE_X, BOX_SIZE_Y, BOX_SIZE_Z);
  const material = new MeshBasicMaterial({ color: 0x00ff00 });
  const cube = new Mesh(geometry, material);
  scene.add(cube);

  // render (実際はこちらを毎フレーム実行)
  renderer.render(scene, camera);
}
  • Three.jsから必要なクラスをimport
  • 描画面を司るrenderer・空間上のオブジェクトを司るscene・どんな画角からのグラフィックを扱うか決めるcameraの3つを作成
  • 主役となる3Dオブジェクト(Mesh)を作成してsceneに追加
  • ぜんぶの準備ができたらrender

というのがだいたいの流れですね。これがさらで書けたらThree.jsチョットデキルといった感じでしょうか。

今回特に大事なのは、cameraとgeometryを作成するときのパラメーターで、このあたりをUnity側と揃えておかないと、このあと動きが連動するように実装したとしても、最終的な見た目が変わってしまいます。Three.jsとUnityでカメラの仕組みは全く同じというわけではないですが、上記のパラメーターと表示サイズをきちんと揃えてあげれば、ちゃんと全く同じような画角が描画できますのでご安心を。

Unity内のCubeの設定
Unity内のCameraの設定

実際はこちらが毎フレーム描画されるようにしたり、こちらもReactから呼び出しやすいようにcustom hooksにまとめたり、Web UIのスライダーをぐりぐりしたらcubeとcameraが動くようにしたり…といろいろやってますが、そのあたりはコードも見てみてください。

SendMessageを用いた連携実装

さていよいよ、この2つの実装が連動するように実装していきます。今回、処理的な流れとしては、

  1. スライダーを動かす(cameraやcubeの位置や回転のパラメーターを操作)
  2. パラメーターがThree.jsの空間に反映される
  3. Three.jsの空間の状態をUnityの空間に反映

という感じででやっていくので、Web→Unityにどうにかしてパラメーターを渡すような実装が必要になっていきます。

Unity側でこうしたケースのために用意されているのが、外部からUnity Scene内の任意のオブジェクト・任意の関数を呼び出すことができる SendMessage という仕組みです。

declare class UnityInstance {
  public SendMessage(
    gameObject: string,
    methodName: string,
    value: unknown
  ): void;
}

先程登場した createUnityInstance の戻り値から取得できるインスタンスに対して、Scene内のオブジェクト名・そのオブジェクトが持つメソッド名・引数として渡したい値を伴って呼び出す形で、WebからUnityの世界に干渉することができます。案外ゆるめにいろいろできそうなインタフェースでびっくりですね。

今回は、Unity内のcubeとcameraにこちらを使って呼び出すためのメソッドを生やし、Webから好き勝手に動かせるオブジェクトにしてしまいましょう。こんな具合でC#を書きました。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class CubeController : MonoBehaviour
{
    private float quaternionX = 0;
    private float quaternionY = 0;
    private float quaternionZ = 0;
    private float quaternionW = 0;

    void Start()
    {
    }

    void Update()
    {
    }

    public void SetPositionX(float x)
    {
        transform.position = new Vector3(
            x,
            transform.position.y,
            transform.position.z
        );
        Debug.Log("SetPositionX:" + x);
    }

    public void SetPositionY(float y)
    {
        transform.position = new Vector3(
            transform.position.x,
            y,
            transform.position.z
        );
        Debug.Log("SetPositionY:" + y);
    }

    public void SetPositionZ(float z)
    {
        transform.position = new Vector3(
            transform.position.x,
            transform.position.y,
            z
        );
        Debug.Log("SetPositionZ:" + z);
    }

    public void SetQuaternionX(float x)
    {
        this.quaternionX = x;
        Debug.Log("SetQuaternionX:" + x);
    }
    public void SetQuaternionY(float y)
    {
        this.quaternionY = y;
        Debug.Log("SetQuaternionY:" + y);
    }
    public void SetQuaternionZ(float z)
    {
        this.quaternionZ = z;
        Debug.Log("SetQuaternionZ:" + z);
    }
    public void SetQuaternionW(float w)
    {
        this.quaternionW = w;
        Debug.Log("SetQuaternionW:" + w);
    }
    public void ApplyQuaternion()
    {
        Quaternion q = new Quaternion(quaternionX, quaternionY, quaternionZ, quaternionW);
        transform.rotation = q;
        Debug.Log("ApplyQuaternion:" + transform.rotation);
    }
}

なお、非常に面倒なことに、SendMessageで扱える引数は基本的に プリミティブな値ひとつだけ です。このため3次元ベクトル(位置座標)やクォータニオン(回転)をそのまんま渡すことは諦め、パラメーターを一個ずつ渡すハメになり、メソッドの数も多くなってしまっています。このあたりはもうちょっとうまい方法もあるのかもですが…とりあえずたくさん呼び出してもさほど処理遅延等もないのでよしとしましょう。

こちらのスクリプトをcubeとcameraにComponentとして追加してあげれば、準備は完了です。

Three.js→Unityの座標変換

あとはこの関数をSendMessageで呼び出すだけなのですがその前に。先程だいたい同じ見た目に準備したはずのUnity空間とThree.js空間なのですが、実は大きな違いがあります。それが 座標体系 です。

UnityとThree.jsの座標ガイド

左がUnityのエディタ画面で表示されるガイド、右がThree.jsのエディタ(three.js editor)で表示されるガイドです。見ての通り、 X軸とZ軸が入れ替わっていますね 。「なんかカメラの向きが違うだけなのでは?」と思われるかもしれませんがこちら、各座標の+-も含めて考えるとどう回転させても全く同じには重なりません。

つまり UnityとThree.jsでは、物体の位置の表現方法が根本から違う わけです。なんてこったー。今回はこのあたり踏まえて座標変換をかけた上で、Unityに座標を渡す必要があります。

/* ===================================================================== *
 * Three.jsオブジェクト(srcObject)の位置・回転を、Unity内のオブジェクト(name)に反映する
 * ===================================================================== */
const syncObject = (srcObject: Object3D, name: string) => {
  const q = new Quaternion();
  q.setFromEuler(srcObject.rotation);
  const rotater = new Quaternion();
  rotater.setFromAxisAngle(new Vector3(0, 1, 0).normalize(), Math.PI);
  q.multiply(rotater);
  unityInstance.SendMessage(name, "SetQuaternionX", q.x);
  unityInstance.SendMessage(name, "SetQuaternionY", -q.y);
  unityInstance.SendMessage(name, "SetQuaternionZ", -q.z);
  unityInstance.SendMessage(name, "SetQuaternionW", q.w);
  unityInstance.SendMessage(name, "ApplyQuaternion");
  unityInstance.SendMessage(name, "SetPositionX", -srcObject.position.x);
  unityInstance.SendMessage(name, "SetPositionY", srcObject.position.y);
  unityInstance.SendMessage(name, "SetPositionZ", srcObject.position.z);
};

結論、こんな感じになりました。反転やら180度回転やらいろいろやった末に、先程のSendMessageで各座標の同期を書いています。特にクォータニオンの同期がなかなか厄介ですがご安心ください、 筆者もあんまり分かってません 。動かしながら考えうるパターンを潰していけばなんとかなります。

まとめ

いかがでしたでしょうか?Three.js→Unityの空間同期をテーマに、いろいろなトピックに触れてみました。こうしたトリッキーな要件があると、慣れているライブラリでも普段は触らないようなプリミティブな部分の一端に触れることができて非常に勉強になりますね。

この記事を読んで学びになった!という方がいれば、この もう一度あるかどうかわからない要件 にまつわる検証も報われるというものです。ぜひご感想など聞かせてください。

だいぶクリスマスも近づいてきましたね!引き続きテックブログをお楽しみください!