WebブラウザとUnityをfirebaseで連携させたインタラクティブコンテンツ

~ この記事はKAYAC Advent Calendar 2019の20日目の記事です。~

こんにちは。CL技術部の深澤です。新卒で入社して5年目になります。( 深澤 匠 | 面白法人カヤック

3年ほどWebフロントエンドに従事して主にWebGL案件に携わったのち、最近はUnityやUnrealEngineなどを使ってARコンテンツやイベント・展示案件などに関わらせていただいています。シェーダーが好きです。

新卒入社からアドベントカレンダーは毎年書いており、せっかくなので過去のバックナンバーも載せておきます。

2015 ... WebGLも怖くない!canvasライブラリを効率良く学ぶオススメの順番 - KAYAC engineers' blog

2016 ... 【脱・gulp】npm-scriptsでシンプルなフロントエンド開発環境を作る - KAYAC engineers' blog

2017 ... 【WebGL】シェーダーを使って3D空間でスプライトアニメーションさせる - KAYAC engineers' blog

2018 ... WebGLコンテンツの開発フローと抑えどころ - KAYAC engineers' blog


完成物

このように、スマホブラウザをコントローラーとして、タッチのドラッグ操作に応じてUnityのエフェクトを動かすモックを作ってみました。ソースはこちらからご覧いただけます。

f:id:takumifukasawa:20191219143345g:plain

github.com

このあとにソースなどを載せていきますが、一つのスクリプトに様々な処理が混在していたりします。あくまでもモックなので、スケールしやすさは考慮せずにプロトタイプの完成の早さを優先させていただいてます!

方向性

スマホブラウザで操作(今回は指がドラッグされる操作)をすると Unity のアプリと連動して何かが起きる、というのを実現するために、以下のような構成をつくります。 は通信の流れです。

Web → Firebase → WebSocket(Node.js ローカルサーバー) → Unity

双方向通信を実現する WebSocket を介すことで、Firebase と Unity の双方向通信を実現することが可能です。 今回は一方通行の構成ですが、双方向通信もできます。

環境

  • PC側

    • Windows10
    • Unity 2019.2.15f HDRP
    • Node.js: v12.13.1
      • WebSocketをローカルサーバーで立てるため
  • ブラウザ側

    • iOS13.2 safari
  • フォルダ構成

    • Unity側
    • WebBrowser側
    • Node.js サーバー側

UnityはHDRPでVFXグラフを使ってみます。WebSocket連携はグラフィック側には依存していない処理なので、built-in pipeline でも、SRP環境でも問題なく動作するはずです。手元ではビルトインパイプラインでも問題なく連携しました。 PCはMac環境は試していないですが、同じ構成をつくることができれば動作するはずです。

リポジトリ内のフォルダ構成はこのようになっています。

- BrowserControlsUnityExample/
  - UnityFirebaseWebSocketHDRP/ # Unityプロジェクト
    ...
  - WebController/
    - index.html # 操作用Webブラウザ
    ...
  - WebSocketServer/
    - index.js/ # WebSocketを立ち上げるNode.jsローカルサーバーのスクリプト
    ...

1. Firebase準備

Firebase のデーターベース機能は Firebase Realtime Database と Firestore の2つがあるのですが、今回は Firebase Realtime Database を使います。

ちなみに2つの機能の違いについては、今回のアドベントカレンダーでのびーさんが書かれていたこちらの記事がとても詳しく説明してくださっています。

techblog.kayac.com

Firebaseをブラウザのjsで扱う際はWebアプリを作る過程で発行されるスニペット、もしくは管理画面から発行されるスニペットを貼るだけとなっています。

f:id:takumifukasawa:20191219151558p:plain

タッチの動作を連携させたいので、実直に座標を格納していく構造にしようと思います。後述するのですが、ブラウザをタッチした座標をUnity側で直接取り扱うのは取り廻しが悪いので、0-1に正規化した値を格納していきます。

f:id:takumifukasawa:20191219151755p:plain

ブラウザからFirebaseで上のような構造のデータをセットするためには、以下のようなコードを書いていくことで実現できます。これだけでFirebaseと通信できるなんてすごいですね。

注意点として、今回はdatabase機能を使うので firebase-database.js を読み込むのを忘れないことです。firebaseでは使う機能に応じて必要なスクリプトを別途読み込んでいくスタイルになっています。

  <!-- The core Firebase JS SDK is always required and must be listed first -->
  <script src="https://www.gstatic.com/firebasejs/7.6.0/firebase-app.js"></script>
  <script src="https://www.gstatic.com/firebasejs/7.6.0/firebase-database.js"></script>
  <script>
    // Your web app's Firebase configuration
    const firebaseConfig = {
      apiKey: "xxxxx",
      authDomain: "xxxxx",
      databaseURL: "xxxxx",
      projectId: "xxxxx",
      storageBucket: "xxxxx",
      messagingSenderId: "xxxxx",
      appId: "xxxxx"
    };
    // Initialize Firebase
    firebase.initializeApp(firebaseConfig);

    const ref = firebase.database().ref("coords");

    ref.set({ x: 0.5, y: 0.5 });
  </script>

2. Web → Firebase

以降は通信の順に説明していきたいと思います。まずは Web と Firebase をつなぐ部分です。

前述のFirebaseのコードを元に、タッチ操作に応じて座標を Firebase に送ります。見た目的にはこのようなものをつくります。

f:id:takumifukasawa:20191219145434g:plain

ソースを一部抜粋します。

// WebController/index.html

...

  <div id="wrapper">
    <canvas id="canvas"></canvas>
  </div>

  <!-- The core Firebase JS SDK is always required and must be listed first -->
  <script src="https://www.gstatic.com/firebasejs/7.6.0/firebase-app.js"></script>
  <script src="https://www.gstatic.com/firebasejs/7.6.0/firebase-database.js"></script>

  <script>
    // Your web app's Firebase configuration
    const firebaseConfig = {
      apiKey: "xxxxx",
      authDomain: "xxxxx",
      databaseURL: "xxxxx",
      projectId: "xxxxx",
      storageBucket: "xxxxx",
      messagingSenderId: "xxxxx",
      appId: "xxxxx"
    };
    // Initialize Firebase
    firebase.initializeApp(firebaseConfig);

    const ref = firebase.database().ref("coords");

    const wrapper = document.getElementById("wrapper");
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    let wrapperWidth;
    let wrapperHeight;

    // タッチ座標のキュー
    const coords = [];

    function onWindowResize() {
      wrapperWidth = wrapper.offsetWidth;
      wrapperHeight = wrapper.offsetHeight;
      aspect = wrapperWidth / wrapperHeight;
      canvas.width = wrapperWidth;
      canvas.height = wrapperHeight;
    }

    function tick() {
      ctx.fillStyle = "rgba(0, 0, 0, 0.15)";
      ctx.fillRect(0, 0, wrapperWidth, wrapperHeight);
      let i = coords.length;
      // touchmoveでキューに積んでおいた座標の数だけ色を塗る
      while(i--) {
        const coord = coords[coords.length - (i + 1)];
        ctx.fillStyle = "rgb(255, 255, 255)";
        ctx.beginPath();
        ctx.arc(coord.x, coord.y, 40, 0, Math.PI * 2, true);
        ctx.fill();
        ctx.closePath();
        coords.splice(i, 1);
      }
      requestAnimationFrame(tick);
    }

    canvas.addEventListener("touchmove", (e) => {
      const touch = e.touches[0];
      const { clientX, clientY } = touch;
      // アニメーションループ内で色を塗るためにキューに積んでおく
      coords.push({
        x: clientX,
        y: clientY
      });
      // 座標を0-1に正規化
      // 実際使うときは画面比(canvasの縦横比率)を考慮してあげる
      const normalizedCoord = {
        x: (clientX / wrapperWidth) * 2 - 1,
        y: (clientY / wrapperHeight) * 2 - 1,
      };
      // 正規化した座標を送信
      ref.set(normalizedCoord);
    });

    onWindowResize();
    window.addEventListener('resize', onWindowResize);
    // 一番最初は黒く塗る
    ctx.fillStyle = "rgba(0, 0, 0, 1)";
    ctx.fillRect(0, 0, wrapperWidth, wrapperHeight);
    requestAnimationFrame(tick);
  </script>

jsやcssは全部html内にまとめました。ios13のsafariはES6記法が使えるのでそのままES6を使いました。

canvas周りのコードがいろいろ入っていますが、firebaseの処理を行っているのはtouchmove内だけですね。

コード内のコメントにも書いたことに関連して、今回はアスペクト比(canvasの縦横比率)は考慮していないので、斜めにドラッグしたときは比率に応じて角度が、ブラウザとUnity側で異なってしまいます。実際に使うときはアスペクト比を考慮してあげるのがよさそうです。

3. Firebase -> WebSocket

ここではNode.jsサーバーでのWebSocketとFirebaseの連携について書いていきます。

3.1 Node.js で WebSocket を立ち上げる

Node.jsでWebSocketを扱う際は ws モジュールを使っていきます。 WebSocket向けのモジュールとして socket.io も有名ですね。ちゃんと調べてはいないですが、socket.ioの中身ではwsが使われているらしくwsよりも高機能になっているとのことですが、今回はモックなので薄い実装になっているwsの方を使っていきます。

www.npmjs.com

まず ws をインストールします。

> npm init // package name などは任意で
> npm i ws

続いてindex.jsという命名でWebSocketを使ったnodeサーバーを作っていきます。

// WebSocketServer/index.js

const WebSocket = require('ws');
const WebSocketServer = WebSocket.Server;
const wss = new WebSocketServer({
  port: 8080
});

// WebSocketServer接続のイベントを貼る
wss.on('connection', (ws) => {
  // メッセージを受信したときのイベント
  ws.on('message', (message) => {
    // 接続しているクライアント全てに "Hello World." 文字列を送信
    wss.clients.forEach((client) => {
      if(client.readyState === WebSocket.OPEN) {
        client.send("Hello World.");
      }
    });
  });
});

3.2 WebSocket と Firebase を連携させる

あとはFirebaseからデータを受け取り、WebSocketでUnityに通知を送れば Node.jsの役割は完了です。 Node.jsでFirebaseを扱うためにはadmin用のモジュールが必要になるのでインストールします。

> npm i firebase-admin

先ほどのプログラムを修正します。

Node.jsでFirebaseを使うためには Firebase Admin SDK を使って認証をする必要があります。

左上の歯車マーク → プロジェクトの設定 → サービス アカウント を開いて以下を行います。

  1. 秘密鍵をダウンロード & 任意の名前にリネーム
  2. Node.js用のスニペットをコピペし、秘密鍵のパスを1.で変更した名前に合わせて修正

f:id:takumifukasawa:20191219141252p:plain

f:id:takumifukasawa:20191219141500p:plain

wsで座標系をUnityに送る部分を作っていきます。

シンプルにxとyをつなげた文字列を送ることとします。

ブラウザ側から渡ってきたデータが { x : 0.5, y: 0.3 } とすると 0.5,0.3 の文字列がUnityに渡るイメージです。

実際に何かのコンテンツを作る際は渡したいデータ構造も複雑になると思うので、JSON形式で渡してあげるのが取り廻しはよさそうです。

// WebSocketServer/index.js

const WebSocket = require('ws');
const WebSocketServer = WebSocket.Server;
const wss = new WebSocketServer({
  port: 8080
});

const admin = require("firebase-admin");

const serviceAccount = require("./serviceAccountKey.json"); // 認証用のjsonファイルをダウンロードして読み込み

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: "xxx" // ここはFirebaseの管理画面で確認できる
});

const db = admin.database();
const ref = db.ref("coords");

ref.on('value', (snapshot) => {
  const val = snapshot.val();
  const sendValue = `${val.x},${val.y}`; 
  console.log("==================");
  console.log("value changed.");
  console.log(val);
  console.log(sendValue);
  wss.clients.forEach((client) => {
    if(client.readyState === WebSocket.OPEN) {
      client.send(sendValue);
    }
  });
}, (errorObject) => {
  console.log(`failed: ${errorObject.code}`);
});

4. WebSocket -> Unity

4.1 websocket-sharp

こちらのモジュールを導入すると、UnityでWebSocketをお手軽に扱うことができます。

github.com

まず Visual Studio で sln ファイルを開きビルドします。Debugビルド・Any-CPU の構成にしました。Example群は必要ないので VisualStudio 上で削除しました。

f:id:takumifukasawa:20191219155942p:plain

ビルドされたdllファイルを Assets/Plugins 以下にインストールします。Pluginsフォルダがない場合は作成しておきます。 インストールは、他のアセットと同じようにドラッグドロップで問題ないです。

f:id:takumifukasawa:20191219152646p:plain

f:id:takumifukasawa:20191219155920p:plain

f:id:takumifukasawa:20191219171602p:plain

4.2 Unity

それでは、UnityとWebSocketをつなげていきます。スクリプトの説明はソース内に書いておきました。

WebSocketで接続するコードとtransformを操作するコードを同居させましたが、実際はWebSocketを通じて多種多様な処理を行うことになると思うので、 UniRX などを使うと処理が分離できて見通しがよくなるかもしれません。

// ClientSample.cs

using UnityEngine;
using WebSocketSharp;

public class ClientSample : MonoBehaviour
{
    // 可動範囲をinspector側で指定できるように
    [SerializeField]
    private Vector2 movableRange = new Vector2(5, 5);
    
    private WebSocket ws;
    private Vector3 position;
    
    // Start is called before the first frame update
    void Start()
    {
        // 初期位置を保存しておく
        position = transform.position;
        //  WebSocketのインスタンスを生成。ポートはNode.js側のWebSocketで開いたポートに合わせる
        ws = new WebSocket("ws://localhost:8080");
        // 接続が開いたときのイベント
        ws.OnOpen += (sender, e) =>
        {
            Debug.Log("open");
        };
        // 通知があったときのイベント。e.Dataで送られてきたデータを取得できる。今回の場合は座標を繋げた文字列 `0.5,0.5`
        ws.OnMessage += (sender, e) =>
        {
            var data = e.Data;
            // [x座標, y座標]の配列になる
            string[] coords = data.Split(',');
            // Unity向けに座標を変換。今回はx軸とy軸を動かす
            // y軸で-1をかけているのは、UnityだとY-UPなのに対し、Webから送られてくる座標は原点が左上にあり、下にいくほど値が大きくなるため
            position = new Vector3(
                float.Parse(coords[0]),
                float.Parse(coords[1]) * -1,
                0
            );
            position.x *= movableRange.x;
            position.y *= movableRange.y;
        };
        // エラー時のイベント
        ws.OnError += (sender, e) =>
        {
            Debug.Log("error: " + e.Message);
        };
        // 接続が閉じたときのイベント
        ws.OnClose += (sender, e) =>
        {
            Debug.Log("close");
        };
        // 接続開始
        ws.Connect();
    }

    // Update is called once per frame
    void Update()
    {
        // update内で座標更新
        transform.position = position;
    }

    void OnDestroy()
    {
        ws.Close();
        ws = null;
    }
}

これを動かしたいオブジェクトにアタッチします。

f:id:takumifukasawa:20191219163332p:plain

5. 動かす

まず、Node.jsのローカルサーバーを立ち上げます。リポジトリ内でいうと WebSocketServer/index.js を立ち上げます。

> cd WebSocketServer
> node index.js

つぎに、Unityを実行します。これはPlayボタンを押すだけですね。

最後に、Webブラウザーを立ち上げます。リポジトリ内でいうと WebController/index.html がブラウザで見られるようになっていれば大丈夫です。ローカル内の他のリソースは読み込んでいないので、PCで開く分にはブラウザにindex.htmlをドラッグすれば問題ないです。スマホで見る場合は、browser-sync を立ち上げるなどして、同じネットワークで接続するか、どこか適当なサーバーにアップします。WebController 内に browser-sync が入った package.json を用意してあるので、以下のコマンドで立ちあげてもらっても大丈夫です。

> cd WebController
> npm i
> npm start

or 

> npm i -g browser-sync
> cd WebController/
> browser-sync start --server

すると。。。

f:id:takumifukasawa:20191219163008g:plain

このように連動させることができます!!

6. VFXを使ってみる

VFXと連動させるために、スクリプトから位置を操作できるようにします。

スクリプトから操作させるためには、BlackBoardのプロパティを生やす必要があります。今回は Initialize ブロックの Position(Sphere)center を操作することで実現しました。

f:id:takumifukasawa:20191219144609p:plain

あとはvfxのコンポーネントを取得しプロパティに値をセットすれば、スクリプト側からVFXの値を変更することができます。コードを一部抜粋します。リポジトリにも上げてますので、そちらでもご確認いただけます。

// ClientSampleVFX.cs

    [SerializeField]
    private GameObject vfxObject;

    private VisualEffect vfx;

...

    void Start()
    {
        vfx = vfxObject.GetComponent<VisualEffect>();
    }

...

    void Update()
    {
        vfx.SetVector3("CenterPosition", position);
    }

このスクリプトを、適当なオブジェクトにアタッチし、inspectorでvfxObjectに、VFXグラフをアタッチしたオブジェクトをアタッチします。

最後に、ポストプロセスなど、自分好みの見た目にシーンを調整していきましょう。

完成!!

動いた!!

f:id:takumifukasawa:20191219135325p:plain f:id:takumifukasawa:20191219143345g:plain

まとめ

FirebaseとWebSocketを連携させると遅延が大きく気になる可能性あるなと思っていたのですが、体感としては全く気にならなかったです。スマホの4G回線で繋いでも気になりませんでした。

ただ、Firebaseという外部サービスを利用している以上ネットワークの速度に大きく依存するので、実際に扱うときはNode.jsを立てるPCが速いネットを使うことができるかどうか、外部環境はどうなっているかなどをよく確認しておく必要があります。4G環境で電波が1本になるような閉鎖された空間などでは使い物にならないかもしれません。複数人の通信も試してはいないので、人数が多い場合にどうなるかなど、様々な組み合わせでの実験・調査は必要です。

それでも、実現できそうな環境であれば、イベントとして使った場合に来場者のスマホをコントローラーとして大画面を操作するインタラクティブコンテンツとして可能性は広がっていると思います。操作側はWebページを読み込むだけですし、個人のスマホをコントローラーとして使うことができるので、体験した映像や画像をSNSにシェアするなどの連携も相性がよさそうです。

最後に

ご一読ありがとうございました!!

参考リンク

Firebase Realtime DatabaseをNode.jsから動かしてみよう

「Socket.IO は必要か?」または「WebSocket は通るのか?」問題について 2016 年版 | blog.jxck.io

【Unity】Node.jsのサーバにWebSocketを使ってデータを送信 | Unlit Sphere

[Unity]Unity で WebSocket ライブラリを用いてリアルタイム通信する | プライムストラクチャーのTECHLOG