LoginSignup
6
3

More than 1 year has passed since last update.

UnityのWebGLビルドでメタバースの基本機能を作ってみた

Last updated at Posted at 2022-12-20

はじめに

本記事はDeNA23卒内定者 Advent Calender 2022の21日の記事です。

20日の記事は @FarStep131 さんの『GitHub Apps のトークンを使ってプライベートリポジトリにアクセスする』という記事でした。見ていない方はぜひご覧ください!
DeNA 公式 Twitter アカウント @DeNAxTech では Blog 記事だけでなくいろいろな勉強会での登壇資料も発信してますので,ぜひフォローして下さい!

こんにちはラビ(@rabbitbooster) です。
本日はUnityのWebGLビルドを利用して、3D空間上でコミュニケーションをとれるメタバースっぽいデモを作りました。
ここで、今回の内容をプレイできます。
(後述するAPIの制限に引っかかっているとだめかもしれません...)

デモ内容

sample.gif

同じルーム内のアバターがアニメーションで動いて、マイク権限があれば会話をすることができます。

Unityを使う理由

ブラウザ上にはThree.js、Babylon.jsなど3D表示ライブラリがありますが、それらと比較して
Unityの利点として

  • URPを使える → 高機能のグラフィックス、最適化が利用可能
  • Unity上で構築したアセットの再利用ができる
  • 他のプラットフォームへの移植が容易

などの利点があります。

実行環境

Chorome 108.0
Unity 2021.3.15f1
Sora JavaScript SDK 2022.1.0

必要な機能

メタバースに必要な機能には

  • アバター同士の音声会話 or テキスト会話
  • アバターの位置やアニメーションの同期

は最低限必要だと思います。
もちろんこれ以外にも必要な機能はあるかと思いますが、今回はこれらの構築を行いました。

リアルタイム通信

アバター同士の会話や位置同期を行うには、リアルタイム通信を行う必要があります。
ブラウザ上でリアルタイム通信を行う手段としてWebSocket APIがありますが、
今回は音声、データ転送を同時に行うためにWebRTCを使います。

WebRTCとは?

ブラウザ同士でリアルタイムコミュニケーションを実現するための仕組み

です。1
基本的にはブラウザ同士で音声やビデオ通信を行うことが想定されていますが、Unityが公式ビルドを出していたり、今回使うSoraのUnitySDKなどが用意されているため、幅広くリアルタイム通信を行うことが可能です。

WebRTCは基本的にP2P通信であるため、複数人で通信を行うためにはSFU(Selective Forwarding Unit)サーバーを使う必要があります。
そのため、配信サーバはSFUサーバーサービスであるWebRTC SFU Soraの検証用に無償で利用可能なSora Laboを使用しました。また、クライアント側にはSora Javascript SDKを使用しました。

Unity スクリプトから JavaScript を呼び出す

これらのJavascript SDKを利用するには、UnityのC#側からJavascriptを呼び出す必要があります。
Unity公式ドキュメントを読むと、Unity のC#側から JavaScript 関数を呼び出す方法として

.jslib 拡張子を使用して、Assets フォルダー内の “Plugins” サブフォルダーに
JavaScript コードのあるファイルを配置します。

とあり、JavascriptをPluginとしてC#側から呼び出すことが可能になります。

アバター同士の音声会話

アバター同士の音声会話はUnity側からjslib側に指示を出す形で実装を行います。

jslib側
var LibrarySoraWebRTC = {
    isHost: Boolean,
    EnterRoom: function (channelId_UTF) {
        const channelId = UTF8ToString(channelId_UTF);
        const debug = true;
        const sora = Sora.connection("シグナリングサーバURL", debug);
        const metadata = {
            access_token: "Sora Laboで発行したaccess_token"
        };
        const options = {
            multistream: true,
            dataChannelSignaling: true,
            dataChannels: [
                {
                    label: "#telepathy",
                    direction: "sendrecv",
                },
            ],
        };
        //Audioタグ用の設定
        const parent = document.createElement('div');
        parent.id = "VoiceParent";
        navigator.mediaDevices
            .getUserMedia({ audio: true })
            .then((stream) => {
                //マイクを取得した場合
                window.soraConnection = sora.sendrecv(channelId, metadata, options);
                //他人のマイク音声をaudioタグへ
                soraConnection.on("track", (event) => {
                    if (event.track.kind == 'audio') {
                        const audio = document.createElement('audio');
                        audio.id = "audio-" + event.track.id;
                        audio.srcObject = event.streams[0];
                        audio.controls = true;
                        audio.autoplay = true;
                        audio.parent = parent;
                    }
                });
                soraConnection.on("removetrack", (event) => {
                    parent.removeChild(document.getElementById("audio-" + event.track.id));
                });
                window.soraConnection.connect(stream);
            })
            .catch((e) => {
                //マイクが取得できない場合
                window.soraConnection = sora.recvonly(channelId, metadata, options);
                //他人のマイク音声をaudioタグへ
                soraConnection.on("track", (event) => {
                    if (event.track.kind == 'audio') {
                        const audio = document.createElement('audio');
                        audio.id = "audio-" + event.track.id;
                        audio.srcObject = event.streams[0];
                        audio.controls = true;
                        audio.autoplay = true;
                        audio.parent = parent;
                    }
                });
                soraConnection.on("removetrack", (event) => {
                    parent.removeChild(document.getElementById("audio-" + event.track.id));
                });
                window.soraConnection.connect();
            });
    },
    ExitRoom: function () {
        window.soraConnection.disconnect();
        window.soraConnection = null;
    }
};
mergeInto(LibraryManager.library, LibrarySoraWebRTC);
Unity側
    [DllImport("__Internal")]
    public static extern void EnterRoom(string roomID);

アバターの位置やアニメーションの同期

アバターの位置同期やアニメーションの同期をそのまま実装を行うと大変なため、UnityのオープンソースネットワークライブラリであるMirrorを利用します。
このライブラリではオブジェクトの移動やアニメーションなどの同期を行うプロトコルは上位レイヤーとして規定されています。
そのため新たな通信プロトコルを用意するときにはデータの中身であるbyte配列を転送するTransportモジュールを規定すれば良い仕様になっています。
Byte配列データをUnity側 <--> Soraサーバー <--> Javascript側で双方向に通信する方法について、次のように実装しました。

Unity側からJavascriptへ配列の転送を行う方法

Unity側からJavascript側への情報の転送はUnity公式ページに書かれています。
Unity側でexternで外部で実装されるメソッドを宣言すると、配列のポインタがJavascript側に引数として渡されます。
渡されたポインタをEmscriptenが定義しているHEAPU8配列に代入すると、Byte配列をJavascript側から取得することができます。

jslib側
Send_Data: function (dataPtr, len) {
        var startIndex = dataPtr / HEAPU8.BYTES_PER_ELEMENT;
        var byteData = HEAPU8.subarray(startIndex, startIndex + len);
        sendMessage(byteData);
    }
Unity側
    [DllImport("__Internal")]
    public static extern void Send_Data(byte[] data, int length);

Javascript側からUnityへ配列の転送を行う方法

Unity WebGLビルドではJavascriptからUnityへ情報を送る方法として、unityInstance.sendMessageがありますが、落とし穴があります。
実はsendMessageでは文字列と数字情報しか送信できません。
そのためよく使われる手段としてJson文字列を送信し、Unity側でパースを行う方法がとられます、しかし今回は毎フレームByte配列の送信を行うのでメモリアロケーションコストが嵩みそうです。
そのため何とかして配列そのままを送る方法を調べたところ
UnityForumに解決方法を考えた先人がいました。
具体的には、Emscriptenがjslib側で定義している、bufferというArrayBuffer型の変数にUnityの全てのデータが格納されています。
そのため、Unity側から配列のポインタと長さ情報を事前に送信することで、Unity側とJavascript側で共通の配列を持つことができます。
後は送信するデータより大きい配列をC#側で確保しておき、その配列を使ってデータの転送を行います。
以下がそのコード例です。

jslib側
var LibrarysebdDataWebGL = {
    byteOffset: Number,
    length: Number,
    InitJavaScriptSharedArray: function (_byteOffset, _length) {
        byteOffset = _byteOffset;
        length = _length;
        dataSharedArray = new Uint8Array(buffer, byteOffset, length);
    },
    $JavaScriptSharedArrayCopy: function(dataArray) {
        if (dataSharedArray.length === 0) {
            dataSharedArray = new Uint8Array(buffer, byteOffset, length);
        }
        for (let index = 0; index < dataArray.length; index++) {
            dataSharedArray[index] = dataArray[index];
        }

    }
};
autoAddDeps(LibrarysebdDataWebGL, '$JavaScriptSharedArrayCopy');
mergeInto(LibraryManager.library, LibrarysebdDataWebGL);

Unity側
[DllImport("__Internal")]
    public static extern void InitJavaScriptSharedArray(byte[] byteOffset, int length);

Transport

双方向に通信する方法がわかったため、Transportモジュールのコードを製作します。

jslib側
var LibrarySoraWebRTC = {
    $isHost: false,
    $isOnceHostInit: false,
    ///dataプロトコル
    ///index = 0: 0の時はConnection要求,1の時はconnectionID受け渡し,2の時はdisconnect,3の時はData受け渡し
    //それ以降: Data(初めのconnectionID受け渡し時はDataなし)
    EnterRoom: function (channelId_UTF) {
        const channelId = UTF8ToString(channelId_UTF);
        const debug = true;
        const sora = Sora.connection(["シグナリングurl"], debug);
        const metadata = {
            access_token: "アクセストークン"
        };
        const options = {
            multistream: true,
            dataChannelSignaling: true,
            dataChannels: [
                {
                    label: "データチャンネルラベル",
                    direction: "sendrecv",
                },
            ],
        };
        const parent = document.createElement('div');
        parent.id = "VoiceParent";
        navigator.mediaDevices
            .getUserMedia({ audio: true })
            .then((stream) => {
                let connection = sora.sendrecv(channelId + "@rabbit-go#10007775", metadata, options);
                window.soraConnection = connection;
                Sora_Setting(soraConnection);
                connection.connect(stream);
            })
            .catch((e) => {
                let connection = sora.recvonly(channelId + "@rabbit-go#10007775", metadata, options);
                window.soraConnection = connection;
                Sora_Setting(soraConnection);
                connection.connect();
            });
    },
    $Sora_Setting: function (connection) {
        connection.on("track", (event) => {
            if (event.track.kind == 'audio') {
                const audio = document.createElement('audio');
                audio.id = "audio-" + event.track.id;
                audio.srcObject = event.streams[0];
                audio.controls = true;
                audio.autoplay = true;
                audio.parent = parent;
            }
        });
        connection.on("notify", (message) => {
            if (message.event_type == "connection.created") {
                if (message.channel_connections == 1) {
                    console.log("I'm Host");
                    isHost = true;
                }
            }
        });
        connection.on("datachannel", (message) => {
            if (isOnceHostInit) return;
            isOnceHostInit = true;
            if (isHost) {
                unityInstance.SendMessage("NetworkManager", "StartHost");
            }
            else {
                unityInstance.SendMessage("NetworkManager", "StartClient");
            }
        });
        connection.on("message", (message) => {
            let dataArray = new Uint8Array(message.data);
            Module['JavaScriptArrayCopy'].JavaScriptSharedArrayCopy(dataArray);
            if (isHost) {
                unityInstance.SendMessage("NetworkManager", "ServerDataReceived", dataArray.length);
            }
            else {
                unityInstance.SendMessage("NetworkManager", "ClientDataReceived", dataArray.length);
            }
        });
        connection.on("removetrack", (event) => {
            parent.removeChild(document.getElementById("audio-" + event.track.id));
        });
    },
    Send_Data: function (dataPtr, len) {
        var startIndex = dataPtr / HEAPU8.BYTES_PER_ELEMENT;
        var byteData = HEAPU8.subarray(startIndex, startIndex + len);
        window.soraConnection.sendMessage("#telepathy", byteData);
    },
    ExitRoom: function () {
        window.soraConnection.disconnect();
        window.soraConnection = null;
        isOnceHostInit = false;
    }
};
autoAddDeps(LibrarySoraWebRTC, '$isHost');
autoAddDeps(LibrarySoraWebRTC, '$isOnceHostInit');
autoAddDeps(LibrarySoraWebRTC, '$Sora_Setting');
mergeInto(LibraryManager.library, LibrarySoraWebRTC);
Unity側
using Mirror;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using UnityEngine;
using UnityEngine.Scripting;

[Preserve]
public class SoraTransport : Transport
{
#if UNITY_WEBGL && !UNITY_EDITOR
    [DllImport("__Internal")]
    public static extern void Send_Data(byte[] data, int length);
    [DllImport("__Internal")]
    public static extern void ReInit();
    [DllImport("__Internal")]
    public static extern void InitJavaScriptSharedArray(byte[] byteOffset, int length);
     [DllImport("__Internal")]
    public static extern void ExitRoom();
#else
    void Send_Data(byte[] data, int length) { }
    void InitJavaScriptSharedArray(byte[] byteOffset, int length) { }
    void ExitRoom() { }
#endif
    bool serverActive = false;
    bool clientActive = false;
    int clientConnectionID = -1;
    private int maxPacketSize = 100;
    byte[] sharedBuffer;
    private void Start()
    {
        sharedBuffer = new byte[maxPacketSize * 8];
        InitJavaScriptSharedArray(sharedBuffer, sharedBuffer.Length);
    }

    public override bool Available()
    {
        return true;
    }
    public void ClientDataReceived(int length)
    {
        if (sharedBuffer[0] == WebRTCMessageType.ConnectionID && clientConnectionID == -1)
        {
            clientConnectionID = sharedBuffer[1];
            StartCoroutine(nextFrame());
            return;
        }
        if (sharedBuffer[0] == WebRTCMessageType.Disconnect && sharedBuffer[1] == clientConnectionID)
        {
            ClientDisconnect();
            return;
        }
        if (sharedBuffer[0] == WebRTCMessageType.Data && sharedBuffer[1] == clientConnectionID)
        {
            var segmentData = new ArraySegment<byte>(sharedBuffer, 2, length - 2);
            OnClientDataReceived.Invoke(segmentData, Channels.Reliable);
        }

    }
    IEnumerator nextFrame()
    {
        yield return null;
        OnClientConnected.Invoke();
        clientActive = true;
    }
    public void ServerDataReceived(int length)
    {
        if (sharedBuffer[0] == WebRTCMessageType.ConnectionRequest)
        {
            SendConnectionID();
            return;
        }
        if (sharedBuffer[0] == WebRTCMessageType.Disconnect)
        {
            OnServerDisconnected.Invoke(sharedBuffer[1]);
            return;
        }
        if (sharedBuffer[0] == WebRTCMessageType.Data)
        {
            var segmentData = new ArraySegment<byte>(sharedBuffer, 2, length - 2);
            OnServerDataReceived.Invoke(sharedBuffer[1], segmentData, Channels.Reliable);
        }


    }
    public override void ClientConnect(string address)
    {
        Send_Data(new byte[1] { WebRTCMessageType.ConnectionRequest }, 1);
    }
    public override bool ClientConnected()
    {
        return clientActive;
    }

    public override void ClientDisconnect()
    {
        if (!clientActive) { return; }
        var dissconnectMessage = new byte[2] { WebRTCMessageType.Disconnect, (byte)clientConnectionID };
        Send_Data(dissconnectMessage, 2);
        clientActive = false;
        OnClientDisconnected.Invoke();
        ExitRoom();
    }

    public override void ClientSend(ArraySegment<byte> segment, int channelId = 0)
    {
        byte[] data = new byte[segment.Count + 2];
        Array.Copy(segment.Array, segment.Offset, data, 2, segment.Count);
        data[0] = WebRTCMessageType.Data;
        data[1] = (byte)clientConnectionID;
        Send_Data(data, data.Length);
    }

    public override int GetMaxPacketSize(int channelId = 0)
    {
        return 8 * maxPacketSize - 3;
    }

    public override bool ServerActive()
    {
        return serverActive;
    }

    public override void ServerDisconnect(int connectionId)
    {
        Send_Data(new byte[2] { WebRTCMessageType.Disconnect, (byte)connectionId }, 2);
    }

    public override string ServerGetClientAddress(int connectionId)
    {
        return "";
    }

    public override void ServerSend(int connectionId, ArraySegment<byte> segment, int channelId = 0)
    {
        byte[] data = new byte[segment.Count + 2];
        Array.Copy(segment.Array, segment.Offset, data, 2, segment.Count);
        data[0] = WebRTCMessageType.Data;
        data[1] = (byte)connectionId;
        Send_Data(data, data.Length);
    }

    public override void ServerStart()
    {
        Debug.Log("Server Start");
        serverActive = true;
    }

    public override void ServerStop()
    {
        Debug.Log("Server Stop");
        ExitRoom();
        serverActive = false;
    }

    public override Uri ServerUri()
    {
        return new Uri("localhost");
    }

    public override void Shutdown()
    {
        Debug.Log("Server Shutdown");
        serverActive = false;
    }
    public void SendConnectionID()
    {
        Debug.Log("Send Conn");
        var connectionID = NextConnectionId();
        Send_Data(new byte[2] { WebRTCMessageType.ConnectionID, connectionID }, 2);
        OnServerConnected.Invoke(connectionID);
    }

    int counter;
    public byte NextConnectionId()
    {
        int id = Interlocked.Increment(ref counter);
        //byteのため
        if (id == 0xff)
        {
            throw new Exception("connection id limit reached: " + id);
        }

        return (byte)id;
    }
}

まとめ

この記事ではUnityのWebGLビルドでメタバースの基本機能となる音声対話、アバタの移動、アニメーションについてJavascriptとC#を用いて実装を行いました。
Javascript側からUnityへ配列の転送を行う方法が情報がほとんどなくて辛かったですね...
この記事が何かしらのヒントになれば幸いです!

今回のデモのソースコードはこちらです。

  1. WebRTC コトハジメ https://gist.github.com/voluntas/67e5a26915751226fdcf

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3