
1. 项目概述从游戏引擎到实时通信的跨界之旅当大家听到Unity时第一反应往往是“游戏引擎”。没错从《原神》到《王者荣耀·世界》无数我们耳熟能详的3A大作和独立精品都诞生于此。但今天我想带你跳出这个思维定式看看Unity的另一面——一个强大的跨平台应用开发框架。我们这次的目标不是打怪升级而是用Unity结合WebRTC技术亲手搭建一个属于自己的实时视频通信应用。这听起来可能有点跨界但当你真正上手后会发现Unity的组件化开发模式、强大的UI系统UGUI/UI Toolkit以及对多平台WebGL、PC、移动端近乎无缝的打包支持让它成为快速原型验证和构建富媒体交互应用的绝佳选择。而WebRTC作为现代浏览器和移动端实现实时音视频通信的“事实标准”其点对点、低延迟的特性正好补足了Unity在网络通信方面的原生短板。这个组合能让你在熟悉的Unity编辑器中就创造出类似Zoom、腾讯会议核心功能的演示应用无论是用于远程协作、在线教育还是物联网设备的监控界面都大有可为。我最初接触这个想法是因为一个智能家居项目需要远程查看Unity渲染的3D场景实时画面。在尝试了传统的RTMP推流延迟高、配置复杂和Unity自带的网络同步方案数据量大、不适合视频后最终将目光锁定在了WebRTC上。经过一番摸索和踩坑我成功地将两者融合过程比想象中更有趣也更具启发性。所以这篇文章不仅是一份“保姆级”的教程更是一次技术思路的拓展分享。无论你是Unity开发者想为你的游戏添加语音聊天室还是WebRTC开发者想寻求更丰富的客户端表现力亦或是单纯对实时通信技术感兴趣希望有一个看得见、摸得着的项目来练手这篇内容都将为你提供一条清晰的路径。我会附上完整的项目工程你可以直接导入Unity Hub运行和修改。2. 核心思路与技术选型解析2.1 为什么是Unity WebRTC在决定技术栈时我们通常需要考虑开发效率、性能、跨平台能力和生态支持。Unity WebRTC这个组合在这几个维度上表现如何呢首先看开发效率。Unity的拖拽式组件Component开发和基于C#的脚本逻辑对于构建带有复杂UI交互的应用程序非常高效。你可以快速搭建一个包含本地视频预览窗口、远程视频显示窗口、连接按钮、断开按钮的界面而无需像在纯前端开发中那样手动处理大量的DOM和CSS。所有UI元素都是游戏对象GameObject逻辑绑定清晰直观。其次是性能与资源利用。Unity可以非常方便地获取摄像头WebCamTexture和屏幕ScreenCapture的画面并将其渲染到RawImage或RenderTexture上。更重要的是Unity可以将这些纹理数据直接送入WebRTC的视频轨道避免了在CPU和GPU之间来回拷贝图像数据如果优化得当这对于维持高帧率和低延迟至关重要。相比之下在浏览器中使用Canvas处理视频帧再进行编码性能开销会更大。第三是跨平台能力。这是Unity的看家本领。通过这个项目你几乎可以零成本地将应用发布到Windows、macOS、Linux、Android、iOS以及关键的WebGL平台。这意味着你写的同一套代码可以作为一个独立的桌面应用、一个手机App或者直接嵌入网页运行。WebRTC本身在主流浏览器和移动端也有很好的支持两者结合实现了真正的“一次编写处处运行”。最后是生态与未来扩展性。Unity庞大的资源商店Asset Store和社区提供了大量现成的UI组件、动画系统、3D模型资源。一旦你的实时视频通信基础功能跑通你可以很容易地为其添加虚拟背景通过绿幕抠像、AR面具、在3D场景中放置视频屏幕等炫酷功能这些都是纯Web前端较难实现或实现起来成本较高的。当然这个组合也有挑战。主要在于WebRTC与Unity的集成并非开箱即用。Unity官方并没有提供官方的WebRTC包旧版本有实验性包但已不维护我们需要借助第三方库或自己进行封装。这将是本项目的核心难点也是我们需要重点攻克的部分。2.2 WebRTC核心概念与在Unity中的映射在动手写代码之前我们必须理解WebRTC的几个核心概念并知道它们在Unity项目中对应什么。信令SignalingWebRTC本身不负责发现和连接对等端。两个客户端Peer需要通过网络交换一些元数据信息才能建立连接这个过程就叫信令。交换的信息包括会话描述协议SDP和交互式连接建立ICE候选地址。在Unity中我们需要自己实现一个信令服务器。为了方便本项目将使用一个非常简单的基于WebSocket的Node.js服务器作为示例。在实际产品中你可能需要更健壮的信令服务可能基于Socket.IO、SignalR等。对等连接RTCPeerConnection这是WebRTC的核心对象代表一个端到端的连接。它负责处理音视频流的传输、编码解码、网络地址穿越NAT穿透等。在Unity中我们将通过一个C#的WebRTC库例如com.unity.webrtc来创建和操作这个对象。媒体流MediaStream与轨道Track媒体流包含一个或多个轨道如音频轨道AudioTrack和视频轨道VideoTrack。在Unity中我们的视频源可能来自WebCamTexture摄像头或RenderTexture屏幕、3D摄像机视图。我们需要将这些Unity的纹理数据“转换”成WebRTC能识别的视频轨道。数据通道RTCDataChannel除了音视频WebRTC还允许你建立双向的、低延迟的数据通道用来传输任意二进制数据。这在Unity中潜力巨大可以用来同步游戏状态、传输控制指令比如远程操控机器人、发送文本聊天消息等。ICEInteractive Connectivity Establishment这是一套用于在复杂网络环境尤其是经过NAT和防火墙下建立连接的框架。它会收集所有可能的连接地址候选地址并通过信令服务器交换尝试找出最优的连接路径。这个过程对开发者基本是透明的但理解它有助于排查网络连接问题。在Unity中实现这些概念关键在于找到一个可靠的“桥梁”库。目前社区比较活跃的选择是Unity官方维护的com.unity.webrtc包尽管它标记为“实验性”但在2022 LTS及以后版本中相当稳定以及一些第三方封装如MixedReality-WebRTC。本项目将基于com.unity.webrtc进行因为它与Unity编辑器集成度最好文档相对齐全。注意com.unity.webrtc包对Unity版本有要求通常需要2021.3 LTS或更新版本。并且其WebGL后端功能仍在完善中某些高级特性可能受限。对于生产环境需要进行充分的跨平台测试。3. 环境准备与项目初始化3.1 Unity环境与必要包安装首先确保你有一个合适的Unity版本。我推荐使用2022.3 LTS或更高版本长期支持版更加稳定。通过Unity Hub创建一个新的3D核心模板项目命名为UnityWebRTCVideoChat。项目创建好后我们需要安装核心的WebRTC支持包。Unity已经将许多扩展功能移到了Package Manager中。打开Window - Package Manager点击左上角的“”号选择“Add package by name...”。在弹出的窗口中输入以下包名并安装com.unity.webrtc 这是核心的WebRTC实现。安装时Package Manager可能会提示安装相关的依赖项如com.unity.render-pipelines.core等一并确认安装。com.unity.nuget.newtonsoft-json 我们的信令客户端与服务器通信时需要解析JSON数据Newtonsoft.Json是C#中最常用的JSON库。同样通过“Add package by name...”安装。安装完成后在Package Manager中切换到“My Registries”或“Unity Registry”搜索并安装Input System包如果尚未安装。新的输入系统在处理UI交互时更加强大和灵活我们将用它来处理按钮点击事件。3.2 信令服务器搭建Node.js WebSocket如前所述WebRTC需要信令服务器来交换SDP和ICE信息。为了简单和跨平台我们使用Node.js和ws库搭建一个极简的WebSocket服务器。首先确保你的电脑上安装了Node.js建议版本16。在项目根目录Assets文件夹同级创建一个名为SignalingServer的文件夹。在该文件夹下初始化一个Node.js项目并安装依赖cd SignalingServer npm init -y npm install ws然后创建一个server.js文件写入以下代码const WebSocket require(ws); const server new WebSocket.Server({ port: 8080 }); const clients new Map(); // 保存客户端ID与WebSocket连接的映射 let clientIdCounter 0; server.on(connection, (socket) { const clientId client_${clientIdCounter}; clients.set(clientId, socket); console.log(客户端 ${clientId} 已连接); // 通知新客户端其自身的ID socket.send(JSON.stringify({ type: assign-id, id: clientId })); // 广播给其他客户端有新成员加入简单起见这里广播给所有人 broadcast({ type: user-joined, id: clientId }, clientId); socket.on(message, (message) { try { const data JSON.parse(message); console.log(来自 ${clientId} 的消息:, data.type); // 根据消息类型处理 switch (data.type) { case offer: case answer: case ice-candidate: // 将这些信令消息转发给目标客户端 const targetSocket clients.get(data.target); if (targetSocket targetSocket.readyState WebSocket.OPEN) { // 附加上发送者的ID方便接收方知道是谁发来的 data.sender clientId; targetSocket.send(JSON.stringify(data)); } break; case list-users: // 请求列出所有在线用户排除自己 const users Array.from(clients.keys()).filter(id id ! clientId); socket.send(JSON.stringify({ type: user-list, users })); break; // 可以添加更多消息类型如“断开连接”、“文本聊天”等 } } catch (error) { console.error(解析消息失败:, error); } }); socket.on(close, () { console.log(客户端 ${clientId} 已断开); clients.delete(clientId); // 广播用户离开 broadcast({ type: user-left, id: clientId }); }); }); function broadcast(message, excludeId null) { const data JSON.stringify(message); for (const [id, socket] of clients) { if (id ! excludeId socket.readyState WebSocket.OPEN) { socket.send(data); } } } console.log(信令服务器运行在 ws://localhost:8080);这个服务器逻辑非常直白每个新连接的客户端会被分配一个唯一ID如client_1。服务器维护一个所有连接客户的映射表。当客户端A想要呼叫客户端B时A会通过WebSocket发送一个offer消息给服务器服务器将其转发给B。B收到offer后创建answer并发送回服务器服务器再转发给A。ICE候选地址ice-candidate的交换过程类似。客户端可以请求当前在线用户列表list-users。要运行服务器在SignalingServer目录下执行node server.js看到“信令服务器运行在 ws://localhost:8080”的输出说明服务器已就绪。请保持这个终端窗口打开。实操心得在实际项目中这个服务器需要增加房间Room管理、身份验证、状态持久化等功能。这里为了聚焦Unity客户端实现做了最大程度的简化。你可以使用更成熟的方案如Socket.IO带房间概念或PeerJS提供了更上层的抽象来替代。4. Unity客户端核心实现详解4.1 场景与UI搭建回到Unity编辑器我们先搭建一个简单的UI。在场景中创建一个Canvas。将其Render Mode设置为Screen Space - OverlayUI Scale Mode设置为Scale With Screen Size参考分辨率设为1920x1080。在Canvas下创建两个RawImage组件分别命名为LocalVideoRawImage和RemoteVideoRawImage。调整它们的大小和位置一个用于显示本地摄像头画面一个用于显示远程视频。你可以为它们添加一个边框Image作为装饰。创建几个Button和对应的TextConnectButton 连接信令服务器。CallButton 呼叫另一个用户初始状态为不可交互。HangUpButton 挂断通话初始状态为不可交互。可以再创建一个Dropdown或ScrollView用来显示在线用户列表。创建一个空物体GameObject命名为WebRTCManager我们将在它上面挂载我们的核心控制脚本。4.2 核心脚本WebRTCManager在Assets/Scripts文件夹下创建C#脚本WebRTCManager.cs。这个脚本将承载所有WebRTC和信令逻辑。由于代码较长我将分块解释关键部分。首先引入必要的命名空间并定义一些消息数据类用于和信令服务器通信using UnityEngine; using UnityEngine.UI; using Unity.WebRTC; using System.Collections.Generic; using System.Text; using Newtonsoft.Json; using System; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; // 定义与信令服务器通信的消息结构 [System.Serializable] public class SignalingMessage { public string type; // offer, answer, ice-candidate, assign-id, user-list, etc. public string sender; public string target; public string id; // 用于assign-id, user-joined, user-left public string sdp; // 用于offer/answer的SDP字符串 public RTCIceCandidateInit candidate; // 用于ice-candidate public Liststring users; // 用于user-list } public class WebRTCManager : MonoBehaviour { // UI引用 [Header(UI References)] public RawImage localVideoImage; public RawImage remoteVideoImage; public Button connectButton; public Button callButton; public Button hangUpButton; public Dropdown userDropdown; // 配置 [Header(Configuration)] public string signalingServerUrl ws://localhost:8080; // 私有变量 private ClientWebSocket webSocket; private string localClientId; private Dictionarystring, RTCPeerConnection peerConnections new Dictionarystring, RTCPeerConnection(); private MediaStream localStream; private VideoStreamTrack localVideoTrack; private CancellationTokenSource cancellationTokenSource; // WebRTC配置 private RTCConfiguration rtcConfiguration new RTCConfiguration { iceServers new[] { new RTCIceServer { urls new[] { stun:stun.l.google.com:19302 } } } }; }这里我们定义了SignalingMessage类来序列化/反序列化JSON消息。RTCConfiguration中配置了STUN服务器帮助设备在NAT后获取公网地址。Google的公共STUN服务器stun.l.google.com:19302是一个常用的免费选择。对于更复杂的网络环境你可能还需要配置TURN服务器但本项目为简化起见暂不涉及。4.3 信令客户端连接与消息处理接下来实现连接信令服务器和处理消息的核心协程。Unity中处理WebSocket的异步操作我推荐使用Task和async/await并在主线程中调度结果。private async void Start() { // 初始化WebRTC必须在任何WebRTC调用之前执行 WebRTC.Initialize(); // 设置UI按钮初始状态 callButton.interactable false; hangUpButton.interactable false; // 绑定按钮事件 connectButton.onClick.AddListener(ConnectToSignalingServer); callButton.onClick.AddListener(StartCall); hangUpButton.onClick.AddListener(HangUp); } private async void ConnectToSignalingServer() { try { connectButton.interactable false; webSocket new ClientWebSocket(); await webSocket.ConnectAsync(new Uri(signalingServerUrl), CancellationToken.None); Debug.Log(已连接到信令服务器); // 启动接收消息的循环 cancellationTokenSource new CancellationTokenSource(); _ ReceiveMessagesAsync(cancellationTokenSource.Token); // 获取本地媒体流摄像头 await StartLocalCamera(); } catch (Exception ex) { Debug.LogError($连接信令服务器失败: {ex.Message}); connectButton.interactable true; } } private async Task ReceiveMessagesAsync(CancellationToken token) { var buffer new byte[4096]; while (webSocket ! null webSocket.State WebSocketState.Open !token.IsCancellationRequested) { try { var result await webSocket.ReceiveAsync(new ArraySegmentbyte(buffer), token); if (result.MessageType WebSocketMessageType.Text) { string message Encoding.UTF8.GetString(buffer, 0, result.Count); ProcessSignalingMessage(message); } } catch (OperationCanceledException) { break; // 任务被取消正常退出 } catch (Exception ex) { Debug.LogError($接收消息出错: {ex.Message}); break; } } } private void ProcessSignalingMessage(string jsonMessage) { // 在主线程中处理消息因为涉及UI和Unity对象操作 UnityMainThreadDispatcher.Instance.Enqueue(() { try { var msg JsonConvert.DeserializeObjectSignalingMessage(jsonMessage); Debug.Log($收到信令消息: {msg.type}); switch (msg.type) { case assign-id: localClientId msg.id; Debug.Log($我的ID是: {localClientId}); // 获取在线用户列表 SendSignalingMessage(new SignalingMessage { type list-users }); break; case user-list: UpdateUserDropdown(msg.users); callButton.interactable (msg.users ! null msg.users.Count 0); break; case user-joined: // 有新用户加入刷新列表简单实现重新请求列表 SendSignalingMessage(new SignalingMessage { type list-users }); break; case user-left: // 有用户离开如果正在通话则挂断并刷新列表 if (peerConnections.ContainsKey(msg.id)) { ClosePeerConnection(msg.id); } SendSignalingMessage(new SignalingMessage { type list-users }); break; case offer: HandleOffer(msg.sender, msg.sdp); break; case answer: HandleAnswer(msg.sender, msg.sdp); break; case ice-candidate: HandleIceCandidate(msg.sender, msg.candidate); break; } } catch (Exception ex) { Debug.LogError($处理信令消息失败: {ex.Message}\n消息内容: {jsonMessage}); } }); } private void SendSignalingMessage(SignalingMessage message) { if (webSocket?.State WebSocketState.Open) { string json JsonConvert.SerializeObject(message); byte[] bytes Encoding.UTF8.GetBytes(json); webSocket.SendAsync(new ArraySegmentbyte(bytes), WebSocketMessageType.Text, true, CancellationToken.None); } }这里有几个关键点WebRTC.Initialize()必须在任何其他WebRTC API调用前执行通常放在Start()或Awake()中。我们使用ClientWebSocketSystem.Net.WebSockets进行通信。所有网络操作都在异步方法中完成。ProcessSignalingMessage中使用了UnityMainThreadDispatcher。因为WebSocket回调可能在非主线程触发而修改UI、操作Unity对象必须在主线程。你需要一个简单的调度器网上有很多现成代码或者可以自己用QueueAction和Update()方法实现一个。收到assign-id后我们立即请求用户列表并更新UI下拉框。4.4 本地媒体流获取与渲染在连接信令服务器成功后我们需要启动本地摄像头并创建视频轨道。private async Task StartLocalCamera() { // 请求摄像头权限在WebGL和某些平台需要 // 注意Unity Editor中可能直接返回第一个可用设备 var devices await WebCamTexture.RequestPermissionsAsync(); if (!devices) { Debug.LogError(未获得摄像头权限); return; } WebCamDevice[] webcamDevices WebCamTexture.devices; if (webcamDevices.Length 0) { Debug.LogError(未找到摄像头设备); return; } // 使用第一个摄像头 var webcamTexture new WebCamTexture(webcamDevices[0].name, 1280, 720, 30); webcamTexture.Play(); // 将摄像头画面显示在UI的RawImage上 localVideoImage.texture webcamTexture; // 创建VideoStreamTrack // 注意Unity.WebRTC提供了从WebCamTexture创建Track的便捷方法 localVideoTrack new VideoStreamTrack(webcamTexture); localStream new MediaStream(); localStream.AddTrack(localVideoTrack); Debug.Log(本地摄像头已启动); }这里使用WebCamTexture获取摄像头数据并利用new VideoStreamTrack(webcamTexture)直接创建WebRTC视频轨道这是Unity WebRTC包提供的非常方便的API。localStream对象代表本地的媒体流目前只包含一个视频轨道你可以用类似方法添加音频轨道AudioStreamTrack。4.5 建立对等连接发起与应答这是WebRTC最核心的部分。当用户A点击“呼叫”用户B时A作为“发起方”OfferB作为“接收方”Answer。发起方Offer流程private async void StartCall() { if (userDropdown.options.Count 0) return; string targetUserId userDropdown.options[userDropdown.value].text; var peerConnection new RTCPeerConnection(ref rtcConfiguration); peerConnections[targetUserId] peerConnection; // 添加本地轨道到连接中 foreach (var track in localStream.GetTracks()) { peerConnection.AddTrack(track, localStream); } // 设置ICE候选地址收集回调 peerConnection.OnIceCandidate candidate { // 将收集到的ICE候选地址通过信令发送给对方 SendSignalingMessage(new SignalingMessage { type ice-candidate, target targetUserId, candidate new RTCIceCandidateInit { candidate candidate.Candidate, sdpMid candidate.SdpMid, sdpMLineIndex candidate.SdpMLineIndex } }); }; // 设置远程流到达回调 peerConnection.OnTrack e { if (e.Track is VideoStreamTrack remoteVideoTrack) { // 注意这个回调可能在非主线程触发 UnityMainThreadDispatcher.Instance.Enqueue(() { remoteVideoTrack.OnVideoReceived tex { // 将接收到的远程视频纹理显示在UI上 remoteVideoImage.texture tex; }; }); } }; // 创建Offer var offerOptions new RTCOfferOptions(); var offer await peerConnection.CreateOffer(ref offerOptions); await peerConnection.SetLocalDescription(ref offer); // 将本地SDP描述Offer通过信令发送给对方 SendSignalingMessage(new SignalingMessage { type offer, target targetUserId, sdp peerConnection.LocalDescription.sdp }); hangUpButton.interactable true; }接收方Answer处理Offer的流程private async void HandleOffer(string senderId, string sdp) { // 如果已存在连接先关闭本例只支持一对一实际可扩展为多对多 if (peerConnections.ContainsKey(senderId)) { ClosePeerConnection(senderId); } var peerConnection new RTCPeerConnection(ref rtcConfiguration); peerConnections[senderId] peerConnection; // 添加本地轨道 foreach (var track in localStream.GetTracks()) { peerConnection.AddTrack(track, localStream); } peerConnection.OnIceCandidate candidate { SendSignalingMessage(new SignalingMessage { type ice-candidate, target senderId, candidate new RTCIceCandidateInit { candidate candidate.Candidate, sdpMid candidate.SdpMid, sdpMLineIndex candidate.SdpMLineIndex } }); }; peerConnection.OnTrack e { if (e.Track is VideoStreamTrack remoteVideoTrack) { UnityMainThreadDispatcher.Instance.Enqueue(() { remoteVideoTrack.OnVideoReceived tex { remoteVideoImage.texture tex; }; }); } }; // 设置远程描述即对方发来的Offer var remoteDesc new RTCSessionDescription { type RTCSdpType.Offer, sdp sdp }; await peerConnection.SetRemoteDescription(ref remoteDesc); // 创建Answer var answer await peerConnection.CreateAnswer(); await peerConnection.SetLocalDescription(ref answer); // 将Answer发送回去 SendSignalingMessage(new SignalingMessage { type answer, target senderId, sdp peerConnection.LocalDescription.sdp }); hangUpButton.interactable true; } private async void HandleAnswer(string senderId, string sdp) { if (peerConnections.TryGetValue(senderId, out var peerConnection)) { var remoteDesc new RTCSessionDescription { type RTCSdpType.Answer, sdp sdp }; await peerConnection.SetRemoteDescription(ref remoteDesc); } }处理ICE候选地址private async void HandleIceCandidate(string senderId, RTCIceCandidateInit candidateInit) { if (peerConnections.TryGetValue(senderId, out var peerConnection)) { var candidate new RTCIceCandidate(candidateInit); await peerConnection.AddIceCandidate(candidate); } }挂断连接private void HangUp() { foreach (var pc in peerConnections.Values) { pc.Close(); } peerConnections.Clear(); remoteVideoImage.texture null; hangUpButton.interactable false; callButton.interactable (userDropdown.options.Count 0); } private void ClosePeerConnection(string peerId) { if (peerConnections.TryGetValue(peerId, out var pc)) { pc.Close(); peerConnections.Remove(peerId); } // 如果挂断的是当前正在显示的对端清空远程视频 if (remoteVideoImage.texture ! null) // 这里判断可以更精确 { remoteVideoImage.texture null; } hangUpButton.interactable peerConnections.Count 0; }4.6 主线程调度器实现由于WebSocket回调和WebRTC的某些事件如OnTrack可能在非Unity主线程触发我们必须将涉及GameObject、UI和Texture的操作派发回主线程。这里提供一个简单的实现using UnityEngine; using System; using System.Collections.Generic; using System.Threading; public class UnityMainThreadDispatcher : MonoBehaviour { private static UnityMainThreadDispatcher _instance; private static readonly QueueAction _executionQueue new QueueAction(); private static readonly object _lockObject new object(); public static UnityMainThreadDispatcher Instance { get { if (_instance null) { var go new GameObject(UnityMainThreadDispatcher); _instance go.AddComponentUnityMainThreadDispatcher(); DontDestroyOnLoad(go); } return _instance; } } public void Enqueue(Action action) { lock (_lockObject) { _executionQueue.Enqueue(action); } } private void Update() { lock (_lockObject) { while (_executionQueue.Count 0) { _executionQueue.Dequeue()?.Invoke(); } } } }将这个脚本挂载到场景中的一个空物体上或者通过Instance属性访问时会自动创建。在需要切回主线程的地方调用UnityMainThreadDispatcher.Instance.Enqueue(() { /* 你的代码 */ });即可。5. 项目运行、测试与打包5.1 本地测试运行启动信令服务器在终端中进入SignalingServer目录运行node server.js。运行Unity项目在Unity编辑器中将WebRTCManager脚本挂载到之前创建的空物体上并将UI元素RawImages, Buttons, Dropdown拖拽到脚本的对应公开字段中。点击Play按钮运行。点击“连接服务器”按钮。如果一切正常你应该能在LocalVideoRawImage上看到自己的摄像头画面并且用户下拉框会更新目前只有你自己。打开第二个客户端这是测试点对点通信的关键。你有几种选择方法A推荐在Unity编辑器中点击菜单栏File - Build Settings选择PC, Mac Linux Standalone平台勾选Development Build和Autoconnect Profiler可选然后点击Build And Run。将应用构建到一个新目录并运行。这样你就有了两个独立的可执行程序在通信。方法B使用Unity的Game窗口复制功能。在Game窗口右上角点击下拉菜单选择“Add Tab”再新建一个Game窗口。然后在这个新窗口的“Display”下拉框中选择一个不同的显示编号如“Display 2”。但这种方法有时在输入处理上会有冲突更适合快速预览。方法C构建为WebGL并在两个不同的浏览器标签页中打开后面会讲。在两个客户端都连接服务器后它们应该能在用户列表中看到对方。在一个客户端中选择另一个用户点击“呼叫”。稍等片刻ICE协商需要时间你应该能在RemoteVideoRawImage中看到对方的视频画面点击“挂断”可以结束通话。5.2 常见问题与排查技巧实录在开发过程中你几乎一定会遇到一些问题。以下是我踩过的一些坑和解决方案问题1连接信令服务器失败错误提示“无法连接到远程服务器”排查首先检查信令服务器server.js是否在运行终端是否有输出。检查Unity中signalingServerUrl配置是否正确默认是ws://localhost:8080。如果Unity项目运行在Editor中localhost指向本机没问题。如果构建成独立应用且两个客户端在同一台机器也使用localhost。但如果第二个客户端在另一台机器需要将localhost替换为运行信令服务器的机器的局域网IP地址例如ws://192.168.1.100:8080并确保防火墙允许8080端口通信。问题2能看到本地视频但呼叫后远程视频黑屏/不显示排查这是最常见的问题。请按以下步骤检查检查信令流程打开Unity编辑器的Console窗口查看日志。确认offer,answer,ice-candidate消息是否被正确发送和接收。如果看不到ICE候选地址交换很可能无法建立连接。检查STUN服务器我们使用了公共STUN服务器。在某些严格的企业防火墙或对称型NAT后STUN可能失效。可以尝试添加多个STUN服务器或者对于测试暂时将RTCConfiguration中的iceServers列表清空这会让WebRTC只使用本地候选地址仅限同一台电脑上的两个客户端测试时使用。检查视频轨道回调确保peerConnection.OnTrack回调被触发并且remoteVideoTrack.OnVideoReceived事件被订阅。可以在这些地方添加Debug.Log。检查纹理赋值确保remoteVideoImage.texture tex;这行代码被执行并且remoteVideoImage的GameObject是激活的。检查分辨率与编码有些摄像头初始分辨率可能不被对端支持。可以尝试在StartLocalCamera中降低WebCamTexture的分辨率如改为640x480。问题3在Unity Editor中运行正常但打包后尤其是WebGL失败排查WebGL平台有其特殊性。权限WebGL下访问摄像头/麦克风需要HTTPS环境并且用户必须手动触发如点击按钮才能弹出授权对话框。确保你的StartLocalCamera方法是由用户点击事件触发的。WebSocket确保信令服务器支持WebSocket并且地址正确。WebGL构建体运行在浏览器中localhost指向的是运行网页的机器即用户电脑而不是打包时你的开发机。你需要将信令服务器部署到公网或局域网内其他机器并修改地址。Unity WebRTC WebGL后端截至当前版本com.unity.webrtc的WebGL后端可能不如原生平台稳定。关注Unity官方论坛和包更新日志。构建设置在Build Settings中选择WebGL平台后点击Player Settings在Player - WebGL - Publishing Settings中将Compression Format改为Disabled可以避免一些加载问题。同时确保Enable Exceptions设置为Full Without Stacktrace或Full以便捕获错误。问题4延迟很高或视频卡顿优化降低视频参数在创建WebCamTexture和VideoStreamTrack时使用更低的分辨率和帧率。例如new WebCamTexture(deviceName, 640, 480, 15)。检查编码WebRTC会自动协商编解码器。可以尝试在创建RTCPeerConnection后通过peerConnection.GetTransceivers()获取收发器并尝试设置优先编解码器如VP8但这个过程较复杂。网络路径点对点连接建立后数据不经过服务器。延迟高可能是两端网络本身的问题。使用同一路由器下的两台设备测试可以排除公网因素。Unity渲染开销确保你的场景没有运行昂贵的渲染脚本占用了大量CPU/GPU影响视频编码。问题5如何添加音频实现获取音频与视频类似。使用Microphone类或AudioListener获取音频源然后创建AudioStreamTrack并添加到localStream中。Unity.WebRTC包提供了AudioStreamTrack的构造函数可以接受AudioSource作为输入。关键是要确保在创建RTCPeerConnection和Offer/Answer之前音频轨道已被添加到流中。5.3 项目打包与部署进阶当你完成本地测试后可能希望将应用分享给别人或部署到服务器。打包为桌面应用在Build Settings中选择目标平台Windows, macOS, Linux点击Build即可。记得将SignalingServer文件夹也一并提供给用户并指导他们如何运行服务器需要安装Node.js。或者你可以将信令服务器代码集成到Unity应用中例如使用像LiteNetLib这样的纯C#网络库来重构信令服务器实现一个真正的P2P应用但这会复杂很多。打包为WebGL并部署在Build Settings中选择WebGL平台点击Build输出到一个文件夹例如WebGLBuild。你需要一个Web服务器来托管这些文件。简单测试可以用Python在WebGLBuild目录下运行python -m http.server 8000Python 3。然后在浏览器访问http://localhost:8000。关键步骤信令服务器必须与网页同源或支持CORS。我们的简单Node.js服务器不支持CORS。你需要修改server.js在创建WebSocket服务器时也创建一个HTTP服务器来托管Unity生成的index.html和资源文件或者为WebSocket握手响应添加CORS头。更简单的方法是使用像express和ws结合的方案。HTTPS如果想让外网访问或者使用麦克风/摄像头必须使用HTTPS。你可以使用ngrok等工具为本地服务创建HTTPS隧道或者部署到支持SSL的云服务器。6. 扩展思路与优化方向一个基础的点对点视频通话已经实现了。但这就是终点吗远不止于此。基于这个框架你可以进行大量有趣的扩展多人视频会议目前的信令服务器和客户端逻辑只支持一对一。你可以修改服务器引入“房间”Room的概念。多个客户端加入同一个房间新加入者向房间内所有现有成员发送Offer并处理来自多方的Answer和ICE。客户端需要管理多个RTCPeerConnection实例。UI上则需要动态创建多个RawImage来显示多方视频。屏幕共享Unity中可以通过ScreenCapture类或读取RenderTexture来捕获屏幕内容。创建一个VideoStreamTrack传入屏幕纹理就可以将屏幕作为视频源添加到媒体流中。你可以在UI上添加一个“共享屏幕”按钮点击后动态切换视频轨道。文字聊天与文件传输利用RTCDataChannel你可以在两个对等端之间建立低延迟的数据通道。这非常适合传输聊天消息、游戏状态同步或小文件。RTCPeerConnection的CreateDataChannel方法可以创建通道并通过OnMessage事件接收数据。结合3D场景这才是Unity的强项你可以将接收到的远程视频纹理贴到一个3D物体比如一个电视模型、一个虚拟会议室的屏幕上。或者将本地摄像头画面经过绿幕抠像处理后将人物合成到3D场景中实现虚拟直播或AR会议的效果。性能与画质优化自适应码率根据网络状况动态调整视频编码参数。WebRTC底层有部分自适应能力但你可以在应用层通过检测网络RTT、丢包率然后动态调整WebCamTexture的分辨率或帧率来实现更粗粒度的控制。硬件编码确保在Player Settings中开启了相应的图形API如DX11, Metal, Vulkan和硬件编码支持如果平台支持。Unity.WebRTC包在支持的情况下会尝试使用硬件编码器。音频处理添加音频降噪Noise Suppression、回声消除AEC模块可以极大提升通话体验。可以考虑集成像WebRTC Audio Processing这样的原生插件或第三方DSP库。这个项目就像一个乐高底座WebRTC提供了稳定可靠的实时通信管道而Unity则赋予了你无限的内容创作和交互呈现能力。从简单的视频通话到复杂的虚拟社交空间、远程协作白板、甚至实时云游戏想象空间完全由你定义。