~ この記事は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のエフェクトを動かすモックを作ってみました。ソースはこちらからご覧いただけます。
このあとにソースなどを載せていきますが、一つのスクリプトに様々な処理が混在していたりします。あくまでもモックなので、スケールしやすさは考慮せずにプロトタイプの完成の早さを優先させていただいてます!
方向性
スマホブラウザで操作(今回は指がドラッグされる操作)をすると 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つの機能の違いについては、今回のアドベントカレンダーでのびーさんが書かれていたこちらの記事がとても詳しく説明してくださっています。
Firebaseをブラウザのjsで扱う際はWebアプリを作る過程で発行されるスニペット、もしくは管理画面から発行されるスニペットを貼るだけとなっています。
タッチの動作を連携させたいので、実直に座標を格納していく構造にしようと思います。後述するのですが、ブラウザをタッチした座標をUnity側で直接取り扱うのは取り廻しが悪いので、0-1に正規化した値を格納していきます。
ブラウザから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 に送ります。見た目的にはこのようなものをつくります。
ソースを一部抜粋します。
// 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の方を使っていきます。
まず 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 を使って認証をする必要があります。
左上の歯車マーク → プロジェクトの設定 → サービス アカウント を開いて以下を行います。
- 秘密鍵をダウンロード & 任意の名前にリネーム
- Node.js用のスニペットをコピペし、秘密鍵のパスを1.で変更した名前に合わせて修正
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をお手軽に扱うことができます。
まず Visual Studio で sln ファイルを開きビルドします。Debugビルド・Any-CPU の構成にしました。Example群は必要ないので VisualStudio 上で削除しました。
ビルドされたdllファイルを Assets/Plugins
以下にインストールします。Pluginsフォルダがない場合は作成しておきます。
インストールは、他のアセットと同じようにドラッグドロップで問題ないです。
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; } }
これを動かしたいオブジェクトにアタッチします。
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
すると。。。
このように連動させることができます!!
6. VFXを使ってみる
VFXと連動させるために、スクリプトから位置を操作できるようにします。
スクリプトから操作させるためには、BlackBoardのプロパティを生やす必要があります。今回は Initialize
ブロックの Position(Sphere)
の center
を操作することで実現しました。
あとは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グラフをアタッチしたオブジェクトをアタッチします。
最後に、ポストプロセスなど、自分好みの見た目にシーンを調整していきましょう。
完成!!
動いた!!
まとめ
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