🎮

WebRTCのDataChannelを使ってUnityでリアルタイム通信するための仕組みを作る

2020/12/20に公開

この記事はUnityアドベントカレンダー20日目の記事です。

まえがき

この記事の内容をざっくり言うと、UNetの有力な後継,MirrorのTransportをWebRTCのDataChannelに置き換えてみた。っていう話です。あとUnityとWebRTCを組み合わせて遊ぶの楽しいよ!!っていう話です。

筆者は普段cocos2dxやUnityでスマホゲームのクライアントサイドを担当しているソフトウェアエンジニアです。この記事に書いてあることはほとんど独学なので、何か間違ってる可能性があります。その場合はコメントでご指摘頂けると幸いです。

はじめに

多くのUnityの開発者にとって、WebRTCという単語はあまり馴染みがない言葉だと思います。WebRTCとは、ひとことで言うと、ブラウザを使ってリアルタイムにコミュニケーションをするための巨大な仕様群のことです。”リアルタイムなコミュニケーション”とは映像や音声、文字列を低遅延でやりとりすることです。これをブラウザで実現するためには様々な技術を組み合わせて、仕様を策定する必要があります。WebRTCは単一のプロトコルや技術のことではなく、たくさんの技術が組み合わさってできた巨大な仕様の塊です。WebRTCは様々なところで使われています。身近なところだとDiscordやzoomなどで利用されているようです。

その便利さからブラウザ以外でも使われることがあります。Unityも昨年ごろUnity Tech公式でWebRTCのパッケージの提供が始まりました。また、それを利用したUnity Render Streamingというものが開発&公開されています。

Unity Render StreamingはPC上で実行しているUnityアプリケーション内のカメラの映像を送信して、ブラウザで受信表示する仕組みです。利点としてはモバイルなど端末スペックに制約があるデバイスでも、リッチな映像を表示できます。すでに商用利用されていて建築関係で使われているようです。

自分は普段は業務でcocos2dxやUnityを使ってスマホゲームのクライアントサイドの開発を担当しています。サーバーサイドや通信関係は守備範囲外であまり興味もなかったんですが、WebRTCはいろいろ面白そうだったので、何か作ってみることにしました。WebRTCは映像や音声だけでなく任意のデータを送受信することも可能です。映像や音声のやりとりにはMediaChannelを。任意のデータはDataChanenelでやりとりします。このDataChannelをゲームでも利用すればP2Pでリアルタイム通信をするゲームが作れるかもしれません。ということで、試しに作ってみよう!というのがこの記事の主題です。

実装方針

Unity Tech製のWebRTCパッケージを使う

Unityで利用できるWebRTCパッケージはUnity Tech製以外にも存在します。有力なのはmicrosoftのMR-WebRTCです。あとはPixivも公開しています。

自分が最初にしっかり触ったのがUnity Tech製のWebRTCパッケージだったこと、シンプルで使いやすかったこと、このままアップデートで順調に機能追加されていけば、UnityでWebRTCをやるときのデファクトスタンダードになる可能性が高い。といった点でUnity Tech製のWebRTCパッケージを利用することにします。

https://github.com/Unity-Technologies/com.unity.webrtc

Mirrorを使う

WebRTCのDataChannelを使ったライブラリをゼロから実装するのは大変です。そこでUNetの有力な後継であるMirrorを利用することを思いつきました。MirrorはTransportクラスを継承したクラスを作成することで通信プロトコルを自由にカスタマイズできます。これを利用します。

https://github.com/vis2k/Mirror

他の候補としては先日Unityに吸収されて公式パッケージになったMLAPIがあります。そっちは正直全然詳しくないので、選択肢としてはなしです。簡単に調べた感じだとMirrorと同様にTransport層を自由にカスタマイズできそうです。

シグナリングにはAyame Laboを使う

制約はありますが、無料で商用利用できるAyame Laboというシグナリングサーバーを時雨堂様が公開してくださっています。これを利用します。

https://ayame-labo.shiguredo.jp/

シグナリングとかシグナリングサーバってなに?

シグナリングとはピア間で接続を確立するために必要な準備のことです。そしてこれに必要なサーバーをシグナリングサーバーと呼びます。シグナリングには以下のような2つの役割があります。

  • ピア間の通信経路を確率する
  • 何ができるピアなのか情報交換する

ピア間の通信経路を確率する

通常のサーバ/クライアントモデルでは、概ねクライアント側は接続すべきサーバの情報が事前に分かっています。URLが分かればサーバのIPアドレスが分かるので接続できます。しかしP2Pの場合はそうはいきません。お互いにお互いのIPアドレスは不明です。それを解決するためにシグナリングサーバを使います。シグナリングサーバの情報(URL,IPアドレス)は事前にそれぞれのピアにもたせて、シグナリングサーバを仲介してお互いのIPアドレスや通信経路を探ります。

何ができるピアなのか情報交換する

ピアによってできることが違います。例えば映像の送信だけしかできない。音声の受信しかできない。Dataの送受信しかできないなど。映像の送信しかできないピアと、映像の受信ができないピアが接続しても意味がありません。また、単に映像を送受信するといっても、ピアによってエンコード・デコードできるコーデックや解像度などに違いがあります。そのへんもすり合わせる必要があります。そのすり合わせがシグナリングです。

ということで、シグナリングサーバーが必要になるわけですが、無料で商用利用できるAyame Laboというシグナリングサーバを時雨堂様が公開してくださっています。

Ayame Laboについて

https://github.com/shiguredo/ayame-labo-doc

https://gist.github.com/voluntas/396167bd197ba005ae5a9e8c5e60f7cd#id13

  • サインアップすれば無料で商用利用可能
  • 1対1でしか通信できない。
  • 1ヶ月5TBまで

といった感じで制限はありますが、無料で商用利用可能なのが非常に魅力的です。アイディア次第でいろいろ遊べそうです。

Ayame LaboはVultrというレンタルサーバで動いてるそうです。

とのことなので、自前でサーバーを立てたとしても、ほとんど無料同然で運用できる気がします。

成果物

ということで方針に沿って実装してみたものがこれです。

https://github.com/tarakoKutibiru/MirrorP2PTransport

使い方

基本はMirrorの他のTransportと同様です。NetworkManagerコンポーネントがアタッチされたオブジェクトにMirrorP2PTranportコンポーネントをアタッチします。

他のTransportと違うのはシグナリングサーバーの情報が必要なところです。SignalingURL.SinalingKey,RoomIdを設定する必要があります。SignalingURLはAyameLaboの場合wss://ayame-labo.shiguredo.jp/signalingです。SignalingKeyはAyame Laboにサインアップして入手します。

Ayame LaboにアクセスしてGitHubアカウントを使ってサインアップします。

サインアップしてログインすると、上記のようにシグナリングキーが表示されるので、これをUntiyのMirrorP2PNetworkingコンポーネントにコピペしましょう。

RoomIDは<GitHubのID>@<任意の文字列>です。適当に作成します。

最終的にはこんな感じです。

あとはMirrorの通常の使い方ができるはずで、Sync Transformを使ったりいろいろできるはずです。

Sampleを動かす

次にサンプルの動かし方です。

Assets/MirrorP2PTransport/Samples/Home/HomeScene.unityを開きます

サンプルでは、Ayameの情報はScriptableObjectで設定できるようにしています。

Asetts/MirrorP2PTransport/Samples/Common/Resourceディレクトリ下で右クリック Create/MirrorP2PTransport/AyameSignalingSettingsと選択してAyameSignalingSettings.assetを作成して、これに必要な情報を追記します。

あとは実行するとそれぞれのサンプルを実行して見れるはずです。Mirrorのサンプルから移植したものや、オリジナルのものもあります。Pongが一番シンプルでわかりやすいと思います。

実装の解説

MirrorP2PTransport本体はこれです。ServerクラスとClientクラスを作成管理する役割を持ちます。

そしてServer/Clientの共通の親であるCommonクラスが通信の要です。Commonクラスで

  • AyameSignalingクラスを利用してAyame Laboにアクセス、相手ピアと接続を確立させる。
  • DataChannelを作成保持利用して相手ピアに任意のデータを送信、または受信する。

といったことを行っています。

WebRTCのシグナリングの具体的な流れは規格化されていません。なので、各自好き勝手にシグナリングの仕組みを設計します。例えばUnity Render Streamingで利用するWebAPPのシグナリングの仕様と、Ayame Laboのシグナリングは少し異なります。今回で言うとAyame Laboを利用するために、Unityアプリ側で対応する必要があります。

Ayameの仕様はここで確認できます。

実際のシグナリング処理を行うのがAyameSignalingクラスです。WebSocketでAyame Laboと接続して、必要なコマンド(register,accept,offer,answer,candidate,ping,bye)を受信、送信する機能を持ちます。

AyameSignalingのソースコード
AyameSignaling.cs
using Ayame.Signaling;
using System;
using System.Security.Authentication;
using System.Text;
using System.Threading;
using Unity.WebRTC;
using UnityEngine;
using WebSocketSharp;

namespace Mirror.WebRTC
{
    public class AyameSignaling : ISignaling
    {
        public delegate void OnAcceptHandler(AyameSignaling signaling);

        private string m_url;
        private float m_timeout;
        private bool m_running;
        private Thread m_signalingThread;
        private AutoResetEvent m_wsCloseEvent;
        private WebSocket m_webSocket;
        private string clientId;

        public string m_signalingKey { get; private set; }
        public string m_roomId { get; private set; }
        public AcceptMessage m_acceptMessage { get; private set; } = null;

        public AyameSignaling(string url, string signalingKey, string roomId, float timeout)
        {
            this.m_url = url;
            this.m_signalingKey = signalingKey;
            this.m_roomId = roomId;
            this.m_timeout = timeout;
            this.m_wsCloseEvent = new AutoResetEvent(false);

            this.clientId = RandomString(17);
        }

        string RandomString(int strLength)
        {
            string result = "";
            string charSet = "0123456789";

            System.Random rand = new System.Random();
            while (strLength != 0)
            {
                result += charSet[Mathf.FloorToInt(rand.Next(0, charSet.Length - 1))];
                strLength--;
            }
            return result;
        }

        public void Start()
        {
            this.m_running = true;
            m_signalingThread = new Thread(WSManage);
            m_signalingThread.Start();
        }

        public void Stop()
        {
            m_running = false;
            m_webSocket?.Close();
            m_signalingThread.Abort();
        }

        public event OnAcceptHandler OnAccept;
        public event OnOfferHandler OnOffer;
#pragma warning disable 0067
        public event OnAnswerHandler OnAnswer;
#pragma warning restore 0067
        public event OnIceCandidateHandler OnIceCandidate;


        public void SendOffer(string connectionId, RTCSessionDescription offer)
        {
            Debug.Log("Signaling: SendOffer");

            OfferMessage offerMessage = new OfferMessage();
            offerMessage.sdp = offer.sdp;

            this.WSSend(JsonUtility.ToJson(offerMessage));
        }

        public void SendAnswer(string connectionId, RTCSessionDescription answer)
        {
            Debug.Log("Signaling: SendAnswer");
            AnswerMessage answerMessage = new AnswerMessage();
            answerMessage.sdp = answer.sdp;

            this.WSSend(JsonUtility.ToJson(answerMessage));
        }

        public void SendCandidate(string connectionId, RTCIceCandidate candidate)
        {
            Debug.Log("Signaling: SendCandidate");

            CandidateMessage candidateMessage = new CandidateMessage();

            Ice ice = new Ice();
            ice.candidate = candidate.candidate;
            ice.sdpMid = candidate.sdpMid;
            ice.sdpMLineIndex = candidate.sdpMLineIndex;

            candidateMessage.ice = ice;
            this.WSSend(JsonUtility.ToJson(candidateMessage));
        }

        public void WSManage()
        {
            while (m_running)
            {
                WSCreate();

                m_wsCloseEvent.WaitOne();

                Thread.Sleep((int)(m_timeout * 1000));
            }

            Debug.Log("Signaling: WS managing thread ended");
        }

        private void WSCreate()
        {
            m_webSocket = new WebSocket(m_url);
            if (m_url.StartsWith("wss"))
            {
                m_webSocket.SslConfiguration.EnabledSslProtocols =
                    SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12;
            }

            m_webSocket.OnOpen += WSConnected;
            m_webSocket.OnMessage += WSProcessMessage;
            m_webSocket.OnError += WSError;
            m_webSocket.OnClose += WSClosed;

            Monitor.Enter(m_webSocket);

            Debug.Log($"Signaling: Connecting WS {m_url}");
            m_webSocket.ConnectAsync();
        }

        private void WSProcessMessage(object sender, MessageEventArgs e)
        {
            var content = Encoding.UTF8.GetString(e.RawData);
            Debug.Log($"Signaling: Receiving message: {content}");

            try
            {
                var message = JsonUtility.FromJson<Message>(content);
                string type = message.type;

                switch (type)
                {
                    case "accept":
                        {
                            AcceptMessage acceptMessage = JsonUtility.FromJson<AcceptMessage>(content);
                            this.m_acceptMessage = acceptMessage;
                            this.OnAccept?.Invoke(this);
                            break;
                        }

                    case "offer":
                        {
                            OfferMessage offerMessage = JsonUtility.FromJson<OfferMessage>(content);
                            DescData descData = new DescData();
                            descData.connectionId = this.m_acceptMessage.connectionId;
                            descData.sdp = offerMessage.sdp;

                            this.OnOffer?.Invoke(this, descData);

                            break;
                        }

                    case "answer":
                        {
                            AnswerMessage answerMessage = JsonUtility.FromJson<AnswerMessage>(content);
                            DescData descData = new DescData();
                            descData.connectionId = this.m_acceptMessage.connectionId;
                            descData.sdp = answerMessage.sdp;

                            this.OnAnswer?.Invoke(this, descData);

                            break;
                        }

                    case "candidate":
                        {
                            CandidateMessage candidateMessage = JsonUtility.FromJson<CandidateMessage>(content);

                            CandidateData candidateData = new CandidateData();
                            candidateData.connectionId = this.m_acceptMessage.connectionId;
                            candidateData.candidate = candidateMessage.ice.candidate;
                            candidateData.sdpMLineIndex = candidateMessage.ice.sdpMLineIndex;
                            candidateData.sdpMid = candidateMessage.ice.sdpMid;

                            this.OnIceCandidate?.Invoke(this, candidateData);

                            break;
                        }

                    case "ping":
                        {
                            PongMessage pongMessage = new PongMessage();
                            this.WSSend(JsonUtility.ToJson(pongMessage));

                            break;
                        }

                    case "bye":
                        {
                            // TODO:
                            break;
                        }

                    default:
                        {
                            Debug.LogError("Signaling: Received message from unknown peer");
                            break;
                        }
                }
            }
            catch (Exception ex)
            {
                Debug.LogError("Signaling: Failed to parse message: " + ex);
            }
        }

        private void WSConnected(object sender, EventArgs e)
        {
            RegisterMessage registerMessage = new RegisterMessage();
            registerMessage.roomId = this.m_roomId;
            registerMessage.signalingKey = this.m_signalingKey;
            registerMessage.clientId = this.clientId;

            Debug.Log("Signaling: WS connected.");
            this.WSSend(JsonUtility.ToJson(registerMessage));
        }

        private void WSError(object sender, ErrorEventArgs e)
        {
            Debug.LogError($"Signaling: WS connection error: {e.Message}");
        }

        private void WSClosed(object sender, CloseEventArgs e)
        {
            Debug.LogError($"Signaling: WS connection closed, code: {e.Code}");

            m_wsCloseEvent.Set();
            m_webSocket = null;
        }

        private void WSSend(object data)
        {
            if (m_webSocket == null || m_webSocket.ReadyState != WebSocketState.Open)
            {
                Debug.LogError("Signaling: WS is not connected. Unable to send message");
                return;
            }

            if (data is string s)
            {
                Debug.Log("Signaling: Sending WS data: " + s);
                m_webSocket.Send(s);
            }
            else
            {
                string str = JsonUtility.ToJson(data);
                Debug.Log("Signaling: Sending WS data: " + str);
                m_webSocket.Send(str);
            }
        }

    }
}

このシグナリングの仕組みを使うのがMirrorP2PCommonクラスです。映像や音声のやり取りは不要なのでMediaChannelは使いません。AyameSignalingクラスを利用してAyame Laboと接続したあとに、OfferやAnswerでDataChannelを作成してシグナリングを試みます。

余談ですがこのOfferとかAnswerの実装は苦労しました。シグナリングの仕組みや必要性を雑多に理解しても、実際にどう実装すれば良いのかわかりませんでした。Channelを作成してsetLocalDescriptionしてsendOffer。Offerを受け取ったらsetRemoteDescriptionして...といった実際の細かい流れが分からず。いまでもちょっと理解が怪しいです。

コルーチンやWhile文で待機するのも、微妙な気がしたので、UniTaskで非同期になるように実装していますが、UniTaskも不慣れなので、コード的には微妙な気がします。まあでも動くので良しとしました。Done is better than なんとかってやつです。

Common.csのソースコード
Common.cs
using Ayame.Signaling;
using Cysharp.Threading.Tasks;
using System.Collections.Generic;
using Unity.WebRTC;
using UnityEngine;

namespace Mirror.WebRTC
{
    using DataChannelDictionary = Dictionary<int, RTCDataChannel>;

    public class Common
    {
        protected string signalingURL = "";
        protected string signalingKey = "";
        protected string roomId = "";

        [SerializeField, Tooltip("Array to set your own STUN/TURN servers")]
        private RTCIceServer[] iceServers = new RTCIceServer[]
        {
            new RTCIceServer()
            {
                urls = new string[] { "stun:stun.l.google.com:19302" }
            }
        };

        [SerializeField, Tooltip("Time interval for polling from signaling server")]
        private float interval = 5.0f;

        private AyameSignaling signaling = null;
        private readonly Dictionary<string, RTCPeerConnection> peerConnections = new Dictionary<string, RTCPeerConnection>();
        private readonly Dictionary<RTCPeerConnection, DataChannelDictionary> m_mapPeerAndChannelDictionary = new Dictionary<RTCPeerConnection, DataChannelDictionary>();
        private RTCConfiguration configuration;

        public void Start()
        {
            this.configuration = default;
            this.configuration.iceServers = this.iceServers;

            if (this.signaling == null)
            {
                this.signaling = new AyameSignaling(signalingURL, signalingKey, roomId, interval);

                this.signaling.OnAccept += OnAccept;
                this.signaling.OnAnswer += OnAnswer;
                this.signaling.OnOffer += OnOffer;
                this.signaling.OnIceCandidate += OnIceCandidate;
            }
            this.signaling.Start();
        }

        public virtual void Stop()
        {
            if (this.signaling != null)
            {
                this.signaling.Stop();
                this.signaling = null;

                this.peerConnections.Clear();
                this.m_mapPeerAndChannelDictionary.Clear();
                this.configuration = default;
            }
        }

        void OnAccept(AyameSignaling ayameSignaling)
        {
            AcceptMessage acceptMessage = ayameSignaling.m_acceptMessage;

            bool shouldSendOffer = acceptMessage.isExistClient;

            var configuration = GetSelectedSdpSemantics();

            this.iceServers = configuration.iceServers;
            this.configuration.iceServers = this.iceServers;

            // 相手からのOfferを待つ
            if (!shouldSendOffer) return;

            this.SendOffer(acceptMessage.connectionId, configuration).Forget();
        }

        async UniTask<bool> SendOffer(string connectionId, RTCConfiguration configuration)
        {
            var pc = new RTCPeerConnection(ref configuration);
            this.peerConnections.Add(connectionId, pc);

            // create data chennel
            RTCDataChannelInit dataChannelOptions = new RTCDataChannelInit(true);
            RTCDataChannel dataChannel = pc.CreateDataChannel("dataChannel", ref dataChannelOptions);
            dataChannel.OnMessage = bytes => OnMessage(dataChannel, bytes);
            dataChannel.OnOpen = () => OnOpenChannel(connectionId, dataChannel);
            dataChannel.OnClose = () => OnCloseChannel(connectionId, dataChannel);

            pc.OnDataChannel = new DelegateOnDataChannel(channel => { OnDataChannel(pc, channel); });
            pc.SetConfiguration(ref this.configuration);
            pc.OnIceCandidate = new DelegateOnIceCandidate(candidate =>
            {
                this.signaling.SendCandidate(connectionId, candidate);
            });

            pc.OnIceConnectionChange = new DelegateOnIceConnectionChange(state =>
            {
                if (state == RTCIceConnectionState.Disconnected)
                {
                    pc.Close();
                    this.peerConnections.Remove(connectionId);

                    this.OnDisconnected();
                }
            });

            RTCOfferOptions options = new RTCOfferOptions();
            options.iceRestart = false;
            options.offerToReceiveAudio = false;
            options.offerToReceiveVideo = false;

            var offer = pc.CreateOffer(ref options);
            await offer;
            if (offer.IsError) return false;

            var desc = offer.Desc;
            var localDescriptionOperation = pc.SetLocalDescription(ref desc);

            this.signaling.SendOffer(connectionId, pc.LocalDescription);

            return true;
        }

        void OnOffer(ISignaling signaling, DescData e)
        {
            if (this.peerConnections.ContainsKey(e.connectionId)) return;

            RTCSessionDescription sessionDescriotion;
            sessionDescriotion.type = RTCSdpType.Offer;
            sessionDescriotion.sdp = e.sdp;

            this.SendAnswer(e.connectionId, sessionDescriotion).Forget();
        }

        async UniTask<bool> SendAnswer(string connectionId, RTCSessionDescription sessionDescriotion)
        {
            var pc = new RTCPeerConnection();
            this.peerConnections.Add(connectionId, pc);

            pc.OnDataChannel = new DelegateOnDataChannel(channel => { OnDataChannel(pc, channel); });
            pc.SetConfiguration(ref this.configuration);
            pc.OnIceCandidate = new DelegateOnIceCandidate(candidate =>
            {
                this.signaling.SendCandidate(connectionId, candidate);
            });

            pc.OnIceConnectionChange = new DelegateOnIceConnectionChange(state =>
            {
                if (state == RTCIceConnectionState.Disconnected)
                {
                    pc.Close();
                    this.peerConnections.Remove(connectionId);

                    this.OnDisconnected();
                }
            });

            var remoteDescriptionOperation = pc.SetRemoteDescription(ref sessionDescriotion);
            await remoteDescriptionOperation;
            if (remoteDescriptionOperation.IsError) return false;

            RTCAnswerOptions options = default;

            var answer = pc.CreateAnswer(ref options);
            await answer;

            var desc = answer.Desc;
            var localDescriptionOperation = pc.SetLocalDescription(ref desc);
            await localDescriptionOperation;
            if (localDescriptionOperation.IsError) return false;

            this.signaling.SendAnswer(connectionId, desc);

            return true;
        }

        void OnAnswer(ISignaling signaling, DescData e)
        {
            RTCSessionDescription desc = new RTCSessionDescription();
            desc.type = RTCSdpType.Answer;
            desc.sdp = e.sdp;

            RTCPeerConnection pc = this.peerConnections[e.connectionId];
            pc.SetRemoteDescription(ref desc);
        }

        void OnIceCandidate(ISignaling signaling, CandidateData e)
        {
            if (!this.peerConnections.TryGetValue(e.connectionId, out var pc)) return;

            RTCIceCandidate​ iceCandidate = default;
            iceCandidate.candidate = e.candidate;
            iceCandidate.sdpMLineIndex = e.sdpMLineIndex;
            iceCandidate.sdpMid = e.sdpMid;

            pc.AddIceCandidate(ref iceCandidate);
        }

        /// <summary>
        /// 他方のピアで作成されたDataChannelが接続されたときに呼ばれる。
        /// </summary>
        /// <param name="pc"></param>
        /// <param name="channel"></param>
        void OnDataChannel(RTCPeerConnection pc, RTCDataChannel channel)
        {
            if (!m_mapPeerAndChannelDictionary.TryGetValue(pc, out var channels))
            {
                channels = new DataChannelDictionary();
                m_mapPeerAndChannelDictionary.Add(pc, channels);
            }
            channels.Add(channel.Id, channel);

            channel.OnMessage = bytes => OnMessage(channel, bytes);
            channel.OnClose = () => OnCloseChannel(this.signaling.m_acceptMessage.connectionId, channel);

            this.OnConnected();
        }

        /// <summary>
        /// 自分のピアで作成したDataChannelの接続が確立されたときに呼ばれる。
        /// </summary>
        /// <param name="channel"></param>
        void OnOpenChannel(string connectionId, RTCDataChannel channel)
        {
            var pc = this.peerConnections[connectionId];

            if (!m_mapPeerAndChannelDictionary.TryGetValue(pc, out var channels))
            {
                channels = new DataChannelDictionary();
                m_mapPeerAndChannelDictionary.Add(pc, channels);
            }
            channels.Add(channel.Id, channel);

            channel.OnMessage = bytes => OnMessage(channel, bytes);
            channel.OnClose = () => OnCloseChannel(connectionId, channel);

            this.OnConnected();
        }

        /// <summary>
        /// 自分のピアで作成したDataChannelの接続が切れたとき
        /// </summary>
        /// <param name="channel"></param>
        void OnCloseChannel(string connectionId, RTCDataChannel channel)
        {
            this.OnDisconnected();
        }

        protected virtual void OnMessage(RTCDataChannel channel, byte[] bytes)
        {
            string text = System.Text.Encoding.UTF8.GetString(bytes);
            this.OnMessage(text);
        }

        protected virtual void OnMessage(string message)
        {
            Debug.LogFormat("OnMessage {0}", message);
        }

        public void SendMessage(string message)
        {
            RTCDataChannel dataChannel = this.GetDataChannel("dataChannel");
            if (dataChannel == null) return;

            if (dataChannel.ReadyState != RTCDataChannelState.Open)
            {
                Debug.LogError("Not Open.");
                return;
            }

            dataChannel.Send(message);

            Debug.Log("Send Message");
        }

        protected bool SendMessage(byte[] bytes)
        {
            RTCDataChannel dataChannel = this.GetDataChannel("dataChannel");
            if (dataChannel == null) return false;

            if (dataChannel.ReadyState != RTCDataChannelState.Open)
            {
                Debug.LogError("Not Open.");
                return false;
            }

            dataChannel.Send(bytes);

            Debug.Log("Send Message");

            return true;
        }

        protected virtual void OnConnected()
        {

        }

        protected virtual void OnDisconnected()
        {

        }

        protected bool IsConnected()
        {
            RTCDataChannel dataChannel = this.GetDataChannel("dataChannel");
            if (dataChannel == null) return false;
            return dataChannel.ReadyState == RTCDataChannelState.Open;
        }

        RTCDataChannel GetDataChannel(string label)
        {
            foreach (var dictionary in m_mapPeerAndChannelDictionary.Values)
            {
                foreach (RTCDataChannel dataChannel in dictionary.Values)
                {
                    if (dataChannel.Label == label) return dataChannel;
                }
            }

            return null;
        }

        RTCConfiguration GetSelectedSdpSemantics()
        {
            RTCConfiguration config = default;
            var rtcIceServers = new List<RTCIceServer>();

            foreach (var iceServer in this.signaling.m_acceptMessage.iceServers)
            {
                RTCIceServer rtcIceServer = new RTCIceServer();
                rtcIceServer.urls = iceServer.urls.ToArray();
                rtcIceServer.username = iceServer.username;
                rtcIceServer.credential = iceServer.credential;
                rtcIceServer.credentialType = RTCIceCredentialType.OAuth;

                rtcIceServers.Add(rtcIceServer);
            }

            config.iceServers = rtcIceServers.ToArray();

            return config;
        }
    }
}

おわりに

ということで、WebRTCのDataChannelでゲームを作るための基盤となるMirrorP2PTransportの紹介でした。とは言っても絶賛開発中で完全ではないです。

  • 1.NAT超え未確認
  • 2.切断から再接続が出来ないっぽい
  • 3.1対1の通信にしか対応していない(Ayameの制限)

といった問題があります。

...NAT超えの確認ってどうやってするのが良いんでしょうか?とりあえず自分は、LANケーブルで接続したWindows機と、スマホのDocomo回線でテザリングしたMacbookProを用意して試してみましたが、何らかの理由で接続できませんでした。接続できない理由を探るのも大変で、ネットワーク環境やPC環境のせいなのか、自分の実装に不備があるのか、原因の切り分けが難しく難儀しています。

3に関してはHostが複数のRoomIDを使ってAyameと複数接続すれば解決できる気がしています。ちょっとトリッキーな気がするので、実現できるのか分かりませんが、そのうち試してみたいです。でもまあまずは1対1の通信に注力して落ち着いたらやってみたいと思っています。

UnityとWebRTCの組み合わせは結構面白いと思います。例えばAR Foundationと組み合わせればUE4のVirtual CameraやVirtual Productionのようなことができるかもしれません。なんというかARをそのまんまARとしてだけ使うのではなく、変な使い方をするともっと面白くなるんじゃないか?っていう予感があります。

関連プロジェクト

拙作ですが、UnityのWebRTC関連で以下のようなプロジェクトを公開しています。

参考文献

WebRTCについてもっと詳しく知りたい人は以下の記事や書籍がおすすめです。

https://qiita.com/daitasu/items/ae21b16361eb9f65ed43

https://gist.github.com/voluntas/67e5a26915751226fdcf

https://hpbn.co/webrtc/

日本語訳はこちら

追記

同様の試みをしてらっしゃる方を見つけたのでリンクを貼っておきます。
また Mirror の公式 Discord に WebRTC のチャンネルが出来たようです。気になる方はそちらもチェックしても良いかもしれません。

https://github.com/snotball/mirror-webrtc

Discussion