Unityでリアルタイム通信を実現したいけど、何から始めればいいのか悩んでいませんか?WebRTCは、低遅延で音声や映像をやり取りできる強力な技術ですが、Unityとどう組み合わせるかは少しハードルが高いかもしれません。
本記事では、WebRTCプラットフォームを運営するSkyWayが、WebRTCとUnityを連携させ、マルチプレイヤーゲームにリアルタイムなコミュニケーション機能を簡単に組み込むためのステップを、実装方法やサンプルを交えてわかりやすく解説します。
- WebRTCとは
- Unityとは
- WebRTC Unityで出来ること
- WebRTCをUnityで利用するには
- WebRTC Unityの実装方法
- SkyWayのUnity SDK(β版)でもっと簡単に実装
- まとめ
代表的なWebRTCプラットフォームとして、NTTコミュニケーションズが開発、運営する「SkyWay」があります。 「SkyWay」とは、ビデオ・音声通話をアプリケーションに簡単に実装できる国産SDKです。⇒概要資料をダウンロードする(無料)
WebRTCとは
WebRTC (Web Real-Time Communication) とは、ブラウザやアプリケーション間で音声や映像、データをリアルタイムにやり取りできるオープンな技術です。従来のサーバーを介さず、P2P(ピアツーピア)通信により低遅延でデータを交換できます。
以下の記事にてWebRTCを詳しく解説しているので、ご参考ください。
Unityとは
Unityは、ゲームやアプリケーションの開発に特化したマルチプラットフォーム対応のゲームエンジンです。2D、3Dの両方に対応しており、主にC#を使用してプログラミングが行えます。Unityの特徴は、その簡便なワークフローと、PC、モバイル、Web、VR/ARなど多様なプラットフォームへのビルドが可能な点です。
WebRTC Unityで出来ること
WebRTCをUnityで利用することで、リアルタイムの音声、映像、データの通信が可能になります。具体的には、P2P(ピアツーピア)通信を使ったビデオチャットや、複数デバイス間での映像ストリーミングが実現できます。また、Webブラウザや他のUnityアプリと直接データをやり取りでき、低遅延のオンラインマルチプレイゲームやリモートコントロールアプリの開発に活用できます。これにより、インタラクティブでリアルタイムな体験が可能になります。
WebRTCをUnityで利用するには
WebRTCをUnityで利用するには、公式のライブラリ、もしくはサードパーティのライブラリを利用します。これらのライブラリはUnity公式であるユニティ・テクノロジーズ社や、サードパーティとして時雨堂社などが提供しており、P2P通信を使ったリアルタイムの音声、映像、データ送受信が可能です。
Unity向けのWebRTC ライブラリを利用する
ユニティ・テクノロジーズ
UnityでWebRTCを活用するには、ユニティ・テクノロジーズが提供する公式のWebRTCライブラリ「WebRTC package for Unity」を使用します。このライブラリを導入することで、ブラウザや他のデバイスとリアルタイムに音声や映像をP2Pで通信できます。
また、Unityのカメラ映像を配信し、ブラウザなどからの操作をUnityアプリに反映させるためのライブラリ「Streaming server for Unity」も公開されています。
公式のUnity WebRTCパッケージは、使いやすく、複数のプラットフォーム(Windows、iOS、Androidなど)に対応しています。サンプルプロジェクトやドキュメントが整備されているため、初心者でも導入が簡単です。
時雨堂
時雨堂は、WebRTC SFU(Selective Forwarding Unit)である「Sora」のUnity向けSDKを提供しています。Soraは、サーバーを介して複数のクライアント間で効率的にリアルタイム通信を行う仕組みを持ち、Unityアプリでもこれを簡単に利用できます。時雨堂のSDKは、マルチプラットフォーム対応で、HoloLens2などのデバイスでも動作可能です。WebRTCの高度な機能をUnityで活用したい場合に最適なソリューションです。
WebRTC Unityの実装方法
ここからは実際にUnityとUnity公式のWebRTCパッケージを使用し、ブラウザ上でリアルタイム通信を実装する方法をご紹介します。
動作環境
今回開発に利用した環境は次のとおりです。バージョンによっては正常にインストールできない場合や動作しない可能性があります。
- Mac OS 14.5
- MacBook Pro(Apple M1 Pro)
- Unity 2022.3.2f1
- NodeJS v22.6.0
システム構成
今回開発するシステム構成は以下のようになります。
各コンポーネントの役割は以下の通りです。
- Signalingサーバー
- NodeJSで開発
- WebRTCクライアント
- Unityで開発
Signalingサーバーの実装
今回はSignlingサーバーをNodeJSで実装します。
はじめに、NodeJSでWebSocketサーバーを構築します。
WebSocketのパッケージをインストールします。
npm install ws
app.jsファイルを作成し、WebSocketサーバーの処理を記述します。
const WebSocket = require('ws'); const server = new WebSocket.Server({ port: 3000 }); console.log('Signaling server listened to ws://localhost:3000/'); server.on('connection', (client) => { console.log('Client connected'); client.on('message', (data) => { console.log(`Received: ${data.length}`); server.clients.forEach(c => { if (c != client) { c.send(data) } }) }); });
WebSocketサーバーがクライアントから接続されると、そのクライアントからのメッセージを受け付けます。
その後クライアントからメッセージを受信した場合は、受信したメッセージを他の全てのクライアントに送信します。
WebRTCクライアントの実装
環境構築
Unityのプロジェクトを作成します。
今回処理を記述するソースファイルとしてWebRTC.cs
を作成し、シーン上のGameObjectにアタッチします。
以下の公式サイトの手順い従い、Unity公式のWebRTCパッケージをインストールします。
https://docs.unity3d.com/ja/Packages/com.unity.webrtc@3.0/manual/install.html
それでは、次の節からコードを記述していきます。
メイン処理の記述
Startにメインの処理を記述をします。
void Start() { // Signalingサーバーへ接続 Connect(); // PeerConnectionの初期化 InitPeerConnection(); // メッセージの定期受信を開始 StartCoroutine(PollingMessage()); // AudioStreamTrackを作成し、PeerConnectionに追加 AddAudioStreamTrack(); }
また、やり取りするメッセージの処理を簡略化するため、Signalingサーバーと送受信するメッセージの構造体を定義します。
[System.Serializable] public class Message { public string type; public string data; public Message(string type, string data) { this.type = type; this.data = data; } }
Messageはタイプ(Offer/Answer/Candidate)を表すtypeと、中身のデータを表すdataをプロパティに持ちます。
Signalingサーバーへメッセージを送信する処理を追加します。
// Signalingサーバーへメッセージの送信 IEnumerator SendMessage(Message message) { var json = JsonUtility.ToJson(message); var bytes = Encoding.Default.GetBytes(json); var segment = new ArraySegment<byte>(bytes); yield return webSocket.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None); }
MessageをJSON文字列に変換し、ByteデータとしてWebSocketクライアントに書き込みます。
Signalingサーバーへ接続
SignalingサーバーへWebSocketで接続する処理を追加します。
// SignalingサーバーにWebSocketで接続 void Connect() { webSocket = new ClientWebSocket(); var uri = new Uri("ws://localhost:3000"); webSocket.ConnectAsync(uri, CancellationToken.None).Wait(); }
Uriの引数にはSignalingサーバーのurlを指定します。
PeerConnectionの初期
PeerConnectionを作成して初期化します。
// PeerConnectionの初期化とイベントの登録 void InitPeerConnection() { // PeerConnectionの作成 var config = new RTCConfiguration(); string[] iceServerUrls = { "stun:stun.l.google.com:19302" }; RTCIceServer[] iceServers = { new RTCIceServer() { urls = iceServerUrls } }; config.iceServers = iceServers; peerConnection = new RTCPeerConnection(ref config); // イベントの登録 peerConnection.OnNegotiationNeeded = () => { StartCoroutine(CreateOffer()); }; peerConnection.OnIceCandidate = candidate => { StartCoroutine(SendMessage(new Message("candidate", candidate.Candidate))); }; peerConnection.OnTrack = e => { if (e.Track is AudioStreamTrack audioStreamTrack) { remoteAudioSource.SetTrack(audioStreamTrack); remoteAudioSource.loop = true; remoteAudioSource.Play(); } }; }
PeerConnectionの初期化時に、iceServerとしてGoogleが提供している公開STUNサーバーのurlを設定しています。これにより、異なるネットワーク同士のクライアントでも接続を行うことができます。
上記ではSTUNサーバのみ設定しているため、環境によっては接続できない場合があります。 より多くの環境で接続できるようにするためにはTURNサーバを設定してください。
PeerConnectionの以下のイベントにハンドラを設定します。
OnNegotiationNeededイベント:
PeerConnectionにメディアが追加され、メディア接続が可能な状態になった際に発火するイベント。
今回は、Offer SDPを作成してSignalingサーバーへ送信します。
OnIceCandidateイベント:
ネットワーク経路情報が収集された際に発火するイベント。
今回は、経路情報をSignalingサーバーへ送信し、対向のクライアントに伝えます。
OnTrackイベント:
メディアの受信が開始された際に発火するイベント。
引数でMediaStreamTrackを受け取ることができるので、AudioStreamTrackとして再生します。
続いて、Offer SDPを作成してPeerConnectionへ追加する処理を追加します。
// Offer SDPの作成とPeerConnectionへ追加 IEnumerator CreateOffer() { var op = peerConnection.CreateOffer(); yield return op; var offer = op.Desc; yield return peerConnection.SetLocalDescription(ref offer); StartCoroutine(SendMessage(new Message("offer", peerConnection.LocalDescription.sdp))); }
メッセージの定期受信を開始
Signalingサーバーから定期的にメッセージの受信を行う処理を追加します。
// 定期的にSignalingサーバーからメッセージを受信 IEnumerator PollingMessage() { var buffer = new byte[4096]; while (true) { // Signalingサーバーからデータを受信 var byteArray = new ArraySegment<byte>(buffer); var receiveTask = Task.Run(() => webSocket.ReceiveAsync(byteArray, CancellationToken.None)); yield return new WaitUntil(() => receiveTask.IsCompleted); // データがなかった場合はリトライ var receiveResult = receiveTask.Result; if (!receiveResult.EndOfMessage) { continue; } // 取得したデータからメッセージを作成 int size = receiveResult.Count; byteArray = new ArraySegment<byte>(buffer, size, buffer.Length - size); var dataString = Encoding.UTF8.GetString(buffer, 0, size); var message = JsonUtility.FromJson<Message>(dataString); // メッセージのハンドリング yield return OnReceivedMessage(message); } }
メッセージを取得を試みて、取得可能なデータが存在しない場合はリトライを行います。
データを取得した場合はバイト配列で受信するため、JSON文字列に変換してからMessageの構造体にパースします。
続いて、メッセージの受信時の処理を追加します。
// メッセージの受信時の処理 IEnumerator OnReceivedMessage(Message message) { Debug.Log("onReceivedMessage: " + message.type); if (message.type == "offer") { Debug.Log("onReceivedOffer: " + message.type); yield return SetRemoteSdp(message); var op2 = peerConnection.CreateAnswer(); yield return op2; var answer = op2.Desc; yield return peerConnection.SetLocalDescription(ref answer); yield return SendMessage(new Message("answer", answer.sdp)); } else if (message.type == "answer") { Debug.Log("onReceivedAnswer: " + message.type); yield return SetRemoteSdp(message); } else { Debug.Log("onReceivedCandidate: " + message.type); var init = new RTCIceCandidateInit { candidate = message.data }; init.sdpMid = "0"; var candidate = new RTCIceCandidate(init); peerConnection.AddIceCandidate(candidate); } }
メッセージを受信したとき、それぞれのタイプについての処理を記述します。
offer
: 対向から渡ってきたSDPをRemoteSDPとして登録し、こちらからもSDPを発行して送信します。answer
: 対向から渡ってきたSDPをRemoteSDPとして登録します。candidate
: 対向から渡ってきたネットワーク経路情報をPeerConnectionに登録します。mid
はこのセッション内のメディアストリームを識別するためのIDです。ここでは0を使用します。
受信したOffer/Answer SDPをPeerConnectionへ追加する処理を追加します。
// 受信したSDPをPeerConnectionへ追加 IEnumerator SetRemoteSdp(Message message) { RTCSessionDescription sdp = new RTCSessionDescription(); sdp.type = message.type == "offer" ? RTCSdpType.Offer : RTCSdpType.Answer; sdp.sdp = message.data; return peerConnection.SetRemoteDescription(ref sdp); }
Answer SDPの作成とPeerConnectionへ追加する処理を追加します。
// Answer SDPの作成とPeerConnectionへ追加 IEnumerator CreateAnswer() { var op = peerConnection.CreateAnswer(); yield return op; var answer = op.Desc; yield return peerConnection.SetLocalDescription(ref answer); }
音声の送信
AudioSourceからAudioStreamTrackを作成し、PeerConnectionに追加する処理を追加します。
// AudioStreamTrackを作成し、PeerConnectionに追加 void AddAudioStreamTrack() { var mic = Microphone.devices[0]; Debug.Log("" + mic); localAudioSource.clip = Microphone.Start(mic, true, 1, 48000); while (!(Microphone.GetPosition(mic) > 480)) { } localAudioSource.loop = true; localAudioSource.Play(); var audioStreamTrack = new AudioStreamTrack(localAudioSource); audioStreamTrack.Loopback = false; peerConnection.AddTrack(audioStreamTrack); }
MicroPhone.Start
の第4引数にサンプリングレートを渡します。
また、 Microphone.GetPosition
によって音源位置が480(10ms分のデータ)となるまで待機します。この処理を行わないと正しく音声が取得できないためご注意ください。
AudioStreamTrackをPeerConnectionに追加すると前述のOnNegotiationNeededイベントが発火します。
最終的なコードは以下のようになります。
using UnityEngine; using Unity.WebRTC; using System.Collections; using System.Net.WebSockets; using System; using System.Threading; using System.Text; using System.Threading.Tasks; public class webrtc : MonoBehaviour { [System.Serializable] public class Message { public string type; public string data; public Message(string type, string data) { this.type = type; this.data = data; } } public AudioSource localAudioSource; public AudioSource remoteAudioSource; RTCPeerConnection peerConnection; ClientWebSocket webSocket; MediaStream receivedStream; void Start() { // Signalingサーバーへ接続 Connect(); // PeerConnectionの初期化 InitPeerConnection(); // メッセージの定期受信を開始 StartCoroutine(PollingMessage()); // AudioStreamTrackを作成し、PeerConnectionに追加 AddAudioStreamTrack(); } // SignalingサーバーにWebSocketで接続 void Connect() { webSocket = new ClientWebSocket(); var uri = new Uri("ws://localhost:3000"); webSocket.ConnectAsync(uri, CancellationToken.None).Wait(); } // Signalingサーバーへメッセージの送信 IEnumerator SendMessage(Message message) { var json = JsonUtility.ToJson(message); var bytes = Encoding.Default.GetBytes(json); var segment = new ArraySegment<byte>(bytes); yield return webSocket.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None); } // 定期的にSignalingサーバーからメッセージを受信 IEnumerator PollingMessage() { var buffer = new byte[4096]; while (true) { // Signalingサーバーからデータを受信 var byteArray = new ArraySegment<byte>(buffer); var receiveTask = Task.Run(() => webSocket.ReceiveAsync(byteArray, CancellationToken.None)); yield return new WaitUntil(() => receiveTask.IsCompleted); // データがなかった場合はリトライ var receiveResult = receiveTask.Result; if (!receiveResult.EndOfMessage) { continue; } // 取得したデータからメッセージを作成 int size = receiveResult.Count; byteArray = new ArraySegment<byte>(buffer, size, buffer.Length - size); var dataString = Encoding.UTF8.GetString(buffer, 0, size); var message = JsonUtility.FromJson<Message>(dataString); // メッセージのハンドリング yield return OnReceivedMessage(message); } } // メッセージの受信時の処理 IEnumerator OnReceivedMessage(Message message) { Debug.Log("onReceivedMessage: " + message.type); if (message.type == "offer") { Debug.Log("onReceivedOffer: " + message.type); yield return SetRemoteSdp(message); var op2 = peerConnection.CreateAnswer(); yield return op2; var answer = op2.Desc; yield return peerConnection.SetLocalDescription(ref answer); yield return SendMessage(new Message("answer", answer.sdp)); } else if (message.type == "answer") { Debug.Log("onReceivedAnswer: " + message.type); yield return SetRemoteSdp(message); } else { Debug.Log("onReceivedCandidate: " + message.type); var init = new RTCIceCandidateInit { candidate = message.data }; init.sdpMid = "0"; var candidate = new RTCIceCandidate(init); peerConnection.AddIceCandidate(candidate); } } // PeerConnectionの初期化とイベントの登録 void InitPeerConnection() { // PeerConnectionの作成 var config = new RTCConfiguration(); string[] iceServerUrls = { "stun:stun.l.google.com:19302" }; RTCIceServer[] iceServers = { new RTCIceServer() { urls = iceServerUrls } }; config.iceServers = iceServers; peerConnection = new RTCPeerConnection(ref config); // イベントの登録 peerConnection.OnNegotiationNeeded = () => { StartCoroutine(CreateOffer()); }; peerConnection.OnIceCandidate = candidate => { StartCoroutine(SendMessage(new Message("candidate", candidate.Candidate))); }; peerConnection.OnTrack = e => { if (e.Track is AudioStreamTrack audioStreamTrack) { remoteAudioSource.SetTrack(audioStreamTrack); remoteAudioSource.loop = true; remoteAudioSource.Play(); } }; } // AudioStreamTrackを作成し、PeerConnectionに追加 void AddAudioStreamTrack() { var mic = Microphone.devices[0]; localAudioSource.clip = Microphone.Start(mic, true, 1, 48000); while (!(Microphone.GetPosition(mic) > 480)) { } localAudioSource.loop = true; localAudioSource.Play(); var audioStreamTrack = new AudioStreamTrack(localAudioSource); // 明示的に指定しないとループバックが発生します。 // ループバックとは、自分がマイクから入力した音声が、自分のスピーカーから聞こえる現象です。 audioStreamTrack.Loopback = false; peerConnection.AddTrack(audioStreamTrack); } // Offer SDPの作成とPeerConnectionへ追加 IEnumerator CreateOffer() { var op = peerConnection.CreateOffer(); yield return op; var offer = op.Desc; yield return peerConnection.SetLocalDescription(ref offer); StartCoroutine(SendMessage(new Message("offer", peerConnection.LocalDescription.sdp))); } // Answer SDPの作成とPeerConnectionへ追加 IEnumerator CreateAnswer() { var op = peerConnection.CreateAnswer(); yield return op; var answer = op.Desc; yield return peerConnection.SetLocalDescription(ref answer); } // 受信したSDPをPeerConnectionへ追加 IEnumerator SetRemoteSdp(Message message) { RTCSessionDescription sdp = new RTCSessionDescription(); sdp.type = message.type == "offer" ? RTCSdpType.Offer : RTCSdpType.Answer; sdp.sdp = message.data; return peerConnection.SetRemoteDescription(ref sdp); } }
動作確認
Unityのナビゲーションメニューから、 Window->Analysis->WebRTC Stats
と選択し、メディア通信の統計情報を表示します。
表示されたウィンドウの左ペインから peer-xxxxxx
となっているボタンを選択し、右ペインのボタンを押下することで表示する統計情報を切り替えます。
送信側
peerのoutbound-rtpを選択すると、以下のようなグラフが表示されます。
packetsSent
の値が動いていることから、音声が送信できていることが確認できます。
受信側
peerのinbound-rtpを選択すると、以下のようなグラフが表示されます。
packetsReceived
の値が動いていることから、音声が受信できていることが確認できます。
以上で動作確認は完了です。
SkyWayのUnity SDK(β版)でもっと簡単に実装
WebRTCの実装には、専門的な知識かつ膨大な開発工数が必要ですが、SDK(Software Development Kit)を使用する方法もあります。SDKを使用することで、様々なサーバーの構築や管理を簡略化し、効率的に開発を進めることができます。
WebRTC SDK「SkyWay」
WebRTCの代表的なSDKとして、NTTコミュニケーションズが開発、運営する「SkyWay」があります。「SkyWay」とは、ビデオ・音声通話をアプリケーションに簡単に実装できる国産SDKです。
SkyWayでは、UnityのSDK(β版)を提供しているので、Unityの実装も可能です。
大きな特徴としては、以下が挙げられます。
- スピーディーな開発ができる:
開発資料が豊富かつ日本語でわかりやすい、国内エンジニアがサポートしてくれる - 信頼性・安全性が高い:
NTTコミュニケーションズが開発、運営する国産SDK。サービス歴は10年以上で、累計導入サービス数も21,000件以上 - 無料で開発スタート:
開発検証用として、Freeプランあり。テスト検証期間中は無料で利用可能。商用サービス提供後も基本利用料11万 + 従量課金制で安心。
WebRTCのSDKとして提供されているものは、海外製が多いため、開発ドキュメントも英語か和訳のもので開発しにくい傾向にあります。 「SkyWay」であれば、NTTグループが開発、運営する安心の国産SDKかつ、国内エンジニアがサポートしてくれるため、開発運用工数も大幅に削減でき、開発のしやすさからもおすすめです。
テスト検証用は無料のため、ぜひアカウント登録をしてみください。
UnityのSDK(β版)を提供しています。日本語の開発ドキュメントでわかりやすいので、ぜひご参考ください。
まとめ
WebRTCは、リアルタイムの音声・映像・データ通信を可能にする技術で、Unityと組み合わせることで、P2Pのビデオチャットやオンラインマルチプレイゲームの開発が可能になります。本記事では、UnityでWebRTCを実装する手順を詳しく解説し、公式のWebRTCパッケージを用いた環境構築、Signalingサーバーの実装、PeerConnectionの設定、音声通信の実装までを紹介しました。解説したサンプルコードを参考に、WebRTCを活用したリアルタイム通信をぜひ試してみてください。また、WebRTCのUnity SDK(β版)の「SkyWay」を利用すれば、簡単にリアルタイム通信の実装が可能なのでおすすめです。