素人がWebRTCの仕組みを使ってUnity上で通話できるアプリを作成してみた
こんにちは。今回は素人がWebRTCの仕組みを使ってUnity上で通話できるアプリを作成してみたいと思います。C#もUnityも初めて触りますが、苦しみながら奮闘していこうと思います。
雰囲気が伝わるように、抽象度をあげてざっくり解説しています。詳しく説明が知りたい人や、厳密な説明を求めてる人にとってこの記事は参考にならないかもです。
開発経緯
友人達とマルチゲームを作ることになった。その際に、音声加工をした通話がしたいという話になった。
既存ライブラリで音声通話自体できるが、「生の音声データ(バイナリ形式でいじれるもの)」はさわれず、音声加工がしにくい。プラスで「ライブ配信アプリの技術とか元々興味あったし、せっかくなら一から作ってみるか」となった。
WebRTCとは
ライブ中継だったり、マルチプレイゲームの裏側で動いている、リアルタイム性が高いP2Pを実現するための仕組み。
WebRTCを実現するのに必要な材料
SDP(SessionDescriptionProtocol)
PCで使われているマイクの種類や、どんなコーデックを使うのか、といったストリーミング関係のメタ情報が載ったもの。
ICECandidate(ICE候補)
PCの住所(IPアドレスやポート番号)までの経路の候補
SDPは1つに決まるが、ICE候補は複数生成される。
WebRTCを実現するためのおおまかな手順
単純に考えると、自分のSDP and ICE候補を相手と交換すれば、お互いの端末同士で通信ができる。

シグナリングサーバ
SDP/ICEを直接送れたら早いけど、A-Bはそもそもつながっていないのでシグナリングサーバという仲介のサーバを経由してSDP/ICEを送りあう。シグナリングサーバは合コン会場を提供する場所みたいなイメージ。同じ会場にいる人同士で、自分のSDP/ICEを交換しあう。

STUNサーバ
もう一つ必須のサーバ、STUNサーバ。
WebRTCでP2P通信を行うにあたって、足りていないものが一つあります。それがSTUNサーバです。
ICE候補を作成するのに必須となります。
理由は、グローバルIPアドレスとプライベートIPアドレスの関係にあります。
グローバルIPアドレスがわかっても、そのIP直下のネットワーク内には複数の端末が存在します。

そこで、ICE候補を作成する前にどの端末が接続要求を出しているのかを確認するのがSTUNサーバです。
「グローバルIPアドレス△△でプライベートIPアドレスが○○の、端末Aです!」を知るための。鏡みたいなものでしょうか。
全体の構成図
シグナリングサーバのコード
シグナリングサーバのコードは以下のようになりました。
機能としては、ルーム機能と受け取ったSDP/ICEを受け渡すだけのシンプルなものです。
割と単純に実装できました。
例:指定ルームIDに入室後、指定したユーザへSDP/ICEを送る。
import express from "express";
import http from "http";
import fs from "fs";
import { Server } from "socket.io";
// expressアプリケーション
const app = express();
// nginxを使う場合は通常のHTTPサーバーとして動作
const server = http.createServer(app);
const io = new Server(server);
// 部屋とユーザーの管理用オブジェクト
const rooms: { [roomId: string]: any[] } = {};
const userMap: { [socketId: string]: string } = {}; // socketId(key)とuserIdのマッピング
// モックデータ
const userMapMock: { [socketId: string]: string } = {
"mock-socket-1": "user-alice",
"mock-socket-2": "user-bob",
"mock-socket-3": "user-charlie",
};
const roomsMock: { [roomId: string]: any[] } = {
"test-room": [
{ socketId: "mock-socket-1", userId: "user-alice" },
{ socketId: "mock-socket-2", userId: "user-bob" },
],
lobby: [{ socketId: "mock-socket-3", userId: "user-charlie" }],
};
// Socket.IOの接続イベントの処理
io.on("connection", (socket) => {
console.log("socket通信が開始されました。", socket.id);
socket.on("join-room", (data: { roomId: string; userId: string }) => {
console.log("join-room", socket.id);
// 既存の部屋メンバーリストを取得(参加前)
const existingMembers = rooms[data.roomId] || [];
// ユーザーを部屋に追加
userMap[socket.id] = data.userId;
socket.join(data.roomId);
if (!rooms[data.roomId]) {
rooms[data.roomId] = [];
}
rooms[data.roomId]!.push({ socketId: socket.id, userId: data.userId });
// デバッグ:現在のユーザーリストをコンソールに表示
showUserMap();
// 新規参加者に既存メンバーリストを送信
socket.emit("existing-members", existingMembers);
console.log("既存メンバーリストを送信:", existingMembers);
// 部屋にいる他のユーザーに通知
socket.to(data.roomId).emit("user-joined", socket.id);
// 切断イベントの処理
socket.on("disconnect", () => {
console.log("socket通信が終了しました。", socket.id);
// ユーザーを部屋から削除
delete userMap[socket.id];
if (rooms[data.roomId]) {
const index = rooms[data.roomId]!.findIndex(
(user) => user.socketId === socket.id,
);
if (index !== -1) {
rooms[data.roomId]!.splice(index, 1);
}
// 空になった部屋を削除
if (rooms[data.roomId]!.length === 0) {
delete rooms[data.roomId];
}
}
socket.to(data.roomId).emit("user-disconnected", socket.id);
});
});
// Offer送信
socket.on("offer", ({ targetId, sdp }: { targetId: string; sdp: any }) => {
console.log("offer from", socket.id, "to", targetId);
if (userMap[targetId]) {
io.to(targetId).emit("offer", { socketId: socket.id, sdp });
console.log("offerを送信しました:", targetId);
} else {
console.log("ターゲットのソケットが見つかりません:", targetId);
}
});
// Answer送信
socket.on("answer", ({ targetId, sdp }: { targetId: string; sdp: any }) => {
console.log("answer from", socket.id, "to", targetId);
if (userMap[targetId]) {
io.to(targetId).emit("answer", { socketId: socket.id, sdp });
console.log("answerを送信しました:", targetId);
} else {
console.log("ターゲットのソケットが見つかりません:", targetId);
}
});
// ICE Candidate送信
socket.on(
"ice-candidate",
({ targetId, candidate }: { targetId: string; candidate: any }) => {
console.log("ice-candidate from", socket.id, "to", targetId);
if (userMap[targetId]) {
io.to(targetId).emit("ice-candidate", {
socketId: socket.id,
candidate,
});
console.log("ice-candidateを送信しました:", targetId);
} else {
console.log("ターゲットのソケットが見つかりません:", targetId);
}
},
);
});
const showUserMap = () => {
console.log("現在の接続ユーザ\n");
console.log(userMap);
console.log(rooms);
};
// ルートエンドポイントの設定
app.get("/", (req, res) => {
res.send("Hello, WebRTC Signaling Server!");
});
const PORT = Number(process.env.PORT) || 3000;
server.listen(PORT, "0.0.0.0", () => {
console.log(`Server is running on https://localhost:${PORT}`);
console.log(`network https://192.168.1.15:${PORT}\n`);
});
正常に動作しました。
次に、さきほどの全体構成図のWebブラウザA、WebブラウザBつまりフロントエンドをUnityに置き換えます。
Unity上で実装する際に気を付けたこと
ライブラリ導入
ライブラリ(Unityでいうところのパッケージ)導入時に、慣れない箇所があったため書き置きを残しておきます。
必要なライブラリは大きく2つでした。
-
Unity公式のWebRTCパッケージ Ver 3.0.0
https://github.com/Unity-Technologies/com.unity.webrtc -
SocketIOClient(socket通信をC#で実現するためのライブラリ) Ver 3.1.2
https://www.nuget.org/packages/SocketIOClient/
UnityとC#を初めて触る人用の前提知識
- C#のライブラリは
NuGetを使ってインストールする。 - Unity用のライブラリはUnity標準搭載の
パッケージマネージャ経由でインストールする。 - UnityでC#用のパッケージを使いたいときは、
NugetForUnityという専用パッケージが必要。
特に今回気を付けたのは、UnityパッケージとC#ライブラリ両方使う場合です。
UnityパッケージとC#ライブラリ両方使う場合の注意
ズバリ言うと、実行スレッドの違いに注意する必要があります。
スレッドとは、並列に処理を進めるための実行単位です。
例えば、
- Unity描画のためのメインスレッド
- SocketIOでイベントを受け取り処理するためのスレッド
があります。
特にUnityは以下の理由でメインスレッド外からの処理割り込みを禁止しています。
Unityのメインスレッドが「今からキャラの位置を計算するぞ!」と思っている瞬間に、Socket.ioスレッドが「勝手に通信内容をキャラに反映しちゃうね!」と横から割り込むと、データが壊れたり、最悪プログラムがクラッシュしたりする。
難しくてよくわからないという人は
「Unity外部スレッドの処理はUpdate関数で実行すれば安全(Update関数はメインスレッドで処理される)」
と覚えておけば幸せになれるかもしれません。
私は以下のようにして対策しました。
//処理を順番に貯めていくくキューを用意する
private ConcurrentQueue<Action> _mainThreadActions = new ConcurrentQueue<Action>();
//Unity公式のパッケージでWebRTCを実装するめに必要なインスタンス
private Dictionary<string,RTCPeerConnection> _peerConnections = new();
~~~
.
.
.
~~~
//Update関数内で、キューに貯まった処理を実行
void Update()
{
while (_mainThreadActions.TryDequeue(out var action))//out はactionを取得して処理で使うため
{
action.Invoke();//.Invoke()で処理実行
}
}
~~~
.
.
.
~~~
//ice候補を受信した際の処理
socket.On("ice-candidate", res =>
{
try
{
string rawJson = res.ToString();
var data = res.GetValue<IceCandidateData>();
Debug.Log($"📩 ICE Candidate received from: {data.socketId}");
Debug.Log($"📩 ICE Candidate: {data.candidate.candidate}, sdpMid: {data.candidate.sdpMid}, sdpMLineIndex: {data.candidate.sdpMLineIndex}");
//WebRTC関連の変数(_peerConnections等)に触れる際は処理をメインスレッドへ受け渡す
_mainThreadActions.Enqueue(() =>
{
if (!_peerConnections.ContainsKey(data.socketId))
{
Debug.LogWarning("⚠️ PeerConnection not yet created. Ignoring ICE candidate.");
return;
}
if (data.candidate == null){
Debug.LogWarning("⚠️ Received null ICE candidate data. Ignoring.");
return;
}
var candidate = new RTCIceCandidate(new RTCIceCandidateInit
{
candidate = data.candidate.candidate,
sdpMid = data.candidate.sdpMid,
sdpMLineIndex = data.candidate.sdpMLineIndex
});
_peerConnections[data.socketId].AddIceCandidate(candidate);
Debug.Log("✅ ICE Candidate Added to PeerConnection");
});
}
catch (Exception e)
{
Debug.Log(e.Message);
}
});
確実にメインスレッド内でWebRTC関連の関数を実行できるように、Update関数内でジョブキュー _mainThreadActions 内の処理を順番に処理していく設計にしました。
このようにすることで、socket.ioで受信する "タイミングを計ることができないイベント処理" を、安全にUnityのメインスレッドで処理することができます。
完成品
Youtubeリンクをここに貼る
動画のように動作しました。
しっかり動いていますね。今回はロジックやサーバの構成、WebRTCの仕組みそのものに集中したかったため、マイクを操作するためのUI・ログやチャットを表示するためのGUIはAI書いてもらいました。
まとめ
今回は、初めてのUnityとC#でWebRTCを使った通話アプリの作成に挑戦してみました。
「SDP」や「ICE候補」「STUNサーバ」といったWebRTC特有の概念は最初は難しく感じましたが、順を追ってシグナリングサーバを構築し、図解して整理することで、なんとか通信を確立させることができました。
また、実装面での最大の学びは 「実行スレッドの違い(スレッドセーフ)」 についてです。
「別スレッド(Socket.ioの受信)から、Unityのメインスレッド(WebRTCの操作)を直接触ってはいけない」 という制約は、初心者にとって非常につまずきやすいポイントだと思います。今回採用した「ジョブキューを使って Update 関数で安全に処理を消化する」というアプローチが、同じようにUnityでネットワーク通信を実装しようとしている方の参考になれば幸いです。
とりあえず「通話する」という土台は完成したので、今後は元々の目的であった「マルチプレイゲームへの組み込み」や、「取得した生音声データ(バイナリ)のリアルタイム加工」などにも引き続き挑戦していきたいと思います!
最後まで読んでいただき、ありがとうございました!


