Unity与WebRTC融合:从游戏引擎到实时视频通信应用开发实战
2026/7/21 5:20:48 网站建设 项目流程

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项目中对应什么。

  1. 信令(Signaling):WebRTC本身不负责发现和连接对等端。两个客户端(Peer)需要通过网络交换一些元数据信息才能建立连接,这个过程就叫信令。交换的信息包括:会话描述协议(SDP)和交互式连接建立(ICE)候选地址。在Unity中,我们需要自己实现一个信令服务器。为了方便,本项目将使用一个非常简单的基于WebSocket的Node.js服务器作为示例。在实际产品中,你可能需要更健壮的信令服务,可能基于Socket.IO、SignalR等。

  2. 对等连接(RTCPeerConnection):这是WebRTC的核心对象,代表一个端到端的连接。它负责处理音视频流的传输、编码解码、网络地址穿越(NAT穿透)等。在Unity中,我们将通过一个C#的WebRTC库(例如com.unity.webrtc)来创建和操作这个对象。

  3. 媒体流(MediaStream)与轨道(Track):媒体流包含一个或多个轨道,如音频轨道(AudioTrack)和视频轨道(VideoTrack)。在Unity中,我们的视频源可能来自WebCamTexture(摄像头)或RenderTexture(屏幕、3D摄像机视图)。我们需要将这些Unity的纹理数据“转换”成WebRTC能识别的视频轨道。

  4. 数据通道(RTCDataChannel):除了音视频,WebRTC还允许你建立双向的、低延迟的数据通道,用来传输任意二进制数据。这在Unity中潜力巨大,可以用来同步游戏状态、传输控制指令(比如远程操控机器人)、发送文本聊天消息等。

  5. ICE(Interactive 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');

这个服务器逻辑非常直白:

  1. 每个新连接的客户端会被分配一个唯一ID(如client_1)。
  2. 服务器维护一个所有连接客户的映射表。
  3. 当客户端A想要呼叫客户端B时,A会通过WebSocket发送一个offer消息给服务器,服务器将其转发给B。
  4. B收到offer后,创建answer并发送回服务器,服务器再转发给A。
  5. ICE候选地址(ice-candidate)的交换过程类似。
  6. 客户端可以请求当前在线用户列表(list-users)。

要运行服务器,在SignalingServer目录下执行:

node server.js

看到“信令服务器运行在 ws://localhost:8080”的输出,说明服务器已就绪。请保持这个终端窗口打开

实操心得:在实际项目中,这个服务器需要增加房间(Room)管理、身份验证、状态持久化等功能。这里为了聚焦Unity客户端实现,做了最大程度的简化。你可以使用更成熟的方案如Socket.IO(带房间概念)或PeerJS(提供了更上层的抽象)来替代。

4. Unity客户端核心实现详解

4.1 场景与UI搭建

回到Unity编辑器,我们先搭建一个简单的UI。

  1. 在场景中创建一个Canvas。将其Render Mode设置为Screen Space - OverlayUI Scale Mode设置为Scale With Screen Size,参考分辨率设为1920x1080。
  2. Canvas下创建两个RawImage组件,分别命名为LocalVideoRawImageRemoteVideoRawImage。调整它们的大小和位置,一个用于显示本地摄像头画面,一个用于显示远程视频。你可以为它们添加一个边框Image作为装饰。
  3. 创建几个Button和对应的Text
    • ConnectButton: 连接信令服务器。
    • CallButton: 呼叫另一个用户(初始状态为不可交互)。
    • HangUpButton: 挂断通话(初始状态为不可交互)。
    • 可以再创建一个DropdownScrollView用来显示在线用户列表。
  4. 创建一个空物体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 List<string> 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 Dictionary<string, RTCPeerConnection> peerConnections = new Dictionary<string, 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的异步操作,我推荐使用Taskasync/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 ArraySegment<byte>(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.DeserializeObject<SignalingMessage>(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 ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None); } }

这里有几个关键点:

  1. WebRTC.Initialize()必须在任何其他WebRTC API调用前执行,通常放在Start()Awake()中。
  2. 我们使用ClientWebSocketSystem.Net.WebSockets)进行通信。所有网络操作都在异步方法中完成。
  3. ProcessSignalingMessage中使用了UnityMainThreadDispatcher。因为WebSocket回调可能在非主线程触发,而修改UI、操作Unity对象必须在主线程。你需要一个简单的调度器,网上有很多现成代码,或者可以自己用Queue<Action>Update()方法实现一个。
  4. 收到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作为“发起方”(Offer),B作为“接收方”(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主线程触发,我们必须将涉及GameObjectUITexture的操作派发回主线程。这里提供一个简单的实现:

using UnityEngine; using System; using System.Collections.Generic; using System.Threading; public class UnityMainThreadDispatcher : MonoBehaviour { private static UnityMainThreadDispatcher _instance; private static readonly Queue<Action> _executionQueue = new Queue<Action>(); private static readonly object _lockObject = new object(); public static UnityMainThreadDispatcher Instance { get { if (_instance == null) { var go = new GameObject("UnityMainThreadDispatcher"); _instance = go.AddComponent<UnityMainThreadDispatcher>(); 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 本地测试运行

  1. 启动信令服务器:在终端中,进入SignalingServer目录,运行node server.js
  2. 运行Unity项目:在Unity编辑器中,将WebRTCManager脚本挂载到之前创建的空物体上,并将UI元素(RawImages, Buttons, Dropdown)拖拽到脚本的对应公开字段中。
  3. 点击Play按钮运行。点击“连接服务器”按钮。如果一切正常,你应该能在LocalVideoRawImage上看到自己的摄像头画面,并且用户下拉框会更新(目前只有你自己)。
  4. 打开第二个客户端:这是测试点对点通信的关键。你有几种选择:
    • 方法A(推荐):在Unity编辑器中,点击菜单栏File -> Build Settings,选择PC, Mac & Linux Standalone平台,勾选Development BuildAutoconnect Profiler(可选),然后点击Build And Run。将应用构建到一个新目录并运行。这样你就有了两个独立的可执行程序在通信。
    • 方法B:使用Unity的Game窗口复制功能。在Game窗口右上角,点击下拉菜单,选择“Add Tab”,再新建一个Game窗口。然后在这个新窗口的“Display”下拉框中选择一个不同的显示编号(如“Display 2”)。但这种方法有时在输入处理上会有冲突,更适合快速预览。
    • 方法C:构建为WebGL并在两个不同的浏览器标签页中打开(后面会讲)。
  5. 在两个客户端都连接服务器后,它们应该能在用户列表中看到对方。在一个客户端中选择另一个用户,点击“呼叫”。稍等片刻(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:能看到本地视频,但呼叫后远程视频黑屏/不显示

  • 排查:这是最常见的问题。请按以下步骤检查:
    1. 检查信令流程:打开Unity编辑器的Console窗口,查看日志。确认offer,answer,ice-candidate消息是否被正确发送和接收。如果看不到ICE候选地址交换,很可能无法建立连接。
    2. 检查STUN服务器:我们使用了公共STUN服务器。在某些严格的企业防火墙或对称型NAT后,STUN可能失效。可以尝试添加多个STUN服务器,或者(对于测试)暂时将RTCConfiguration中的iceServers列表清空,这会让WebRTC只使用本地候选地址,仅限同一台电脑上的两个客户端测试时使用
    3. 检查视频轨道回调:确保peerConnection.OnTrack回调被触发,并且remoteVideoTrack.OnVideoReceived事件被订阅。可以在这些地方添加Debug.Log。
    4. 检查纹理赋值:确保remoteVideoImage.texture = tex;这行代码被执行,并且remoteVideoImage的GameObject是激活的。
    5. 检查分辨率与编码:有些摄像头初始分辨率可能不被对端支持。可以尝试在StartLocalCamera中降低WebCamTexture的分辨率,如改为640x480。

问题3:在Unity Editor中运行正常,但打包后(尤其是WebGL)失败

  • 排查:WebGL平台有其特殊性。
    1. 权限:WebGL下访问摄像头/麦克风需要HTTPS环境,并且用户必须手动触发(如点击按钮)才能弹出授权对话框。确保你的StartLocalCamera方法是由用户点击事件触发的。
    2. WebSocket:确保信令服务器支持WebSocket,并且地址正确。WebGL构建体运行在浏览器中,localhost指向的是运行网页的机器(即用户电脑),而不是打包时你的开发机。你需要将信令服务器部署到公网或局域网内其他机器,并修改地址。
    3. Unity WebRTC WebGL后端:截至当前版本,com.unity.webrtc的WebGL后端可能不如原生平台稳定。关注Unity官方论坛和包更新日志。
    4. 构建设置:在Build Settings中选择WebGL平台后,点击Player Settings,在Player -> WebGL -> Publishing Settings中,将Compression Format改为Disabled,可以避免一些加载问题。同时确保Enable Exceptions设置为Full Without StacktraceFull以便捕获错误。

问题4:延迟很高或视频卡顿

  • 优化
    1. 降低视频参数:在创建WebCamTextureVideoStreamTrack时,使用更低的分辨率和帧率。例如new WebCamTexture(deviceName, 640, 480, 15)
    2. 检查编码:WebRTC会自动协商编解码器。可以尝试在创建RTCPeerConnection后,通过peerConnection.GetTransceivers()获取收发器,并尝试设置优先编解码器(如VP8),但这个过程较复杂。
    3. 网络路径:点对点连接建立后,数据不经过服务器。延迟高可能是两端网络本身的问题。使用同一路由器下的两台设备测试,可以排除公网因素。
    4. 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并部署

  1. Build Settings中选择WebGL平台,点击Build,输出到一个文件夹(例如WebGLBuild)。
  2. 你需要一个Web服务器来托管这些文件。简单测试可以用Python:在WebGLBuild目录下运行python -m http.server 8000(Python 3)。然后在浏览器访问http://localhost:8000
  3. 关键步骤:信令服务器必须与网页同源或支持CORS。我们的简单Node.js服务器不支持CORS。你需要修改server.js,在创建WebSocket服务器时,也创建一个HTTP服务器来托管Unity生成的index.html和资源文件,或者为WebSocket握手响应添加CORS头。更简单的方法是使用像expressws结合的方案。
  4. HTTPS:如果想让外网访问,或者使用麦克风/摄像头,必须使用HTTPS。你可以使用ngrok等工具为本地服务创建HTTPS隧道,或者部署到支持SSL的云服务器。

6. 扩展思路与优化方向

一个基础的点对点视频通话已经实现了。但这就是终点吗?远不止于此。基于这个框架,你可以进行大量有趣的扩展:

  1. 多人视频会议:目前的信令服务器和客户端逻辑只支持一对一。你可以修改服务器,引入“房间”(Room)的概念。多个客户端加入同一个房间,新加入者向房间内所有现有成员发送Offer,并处理来自多方的Answer和ICE。客户端需要管理多个RTCPeerConnection实例。UI上则需要动态创建多个RawImage来显示多方视频。

  2. 屏幕共享:Unity中可以通过ScreenCapture类或读取RenderTexture来捕获屏幕内容。创建一个VideoStreamTrack传入屏幕纹理,就可以将屏幕作为视频源添加到媒体流中。你可以在UI上添加一个“共享屏幕”按钮,点击后动态切换视频轨道。

  3. 文字聊天与文件传输:利用RTCDataChannel,你可以在两个对等端之间建立低延迟的数据通道。这非常适合传输聊天消息、游戏状态同步或小文件。RTCPeerConnectionCreateDataChannel方法可以创建通道,并通过OnMessage事件接收数据。

  4. 结合3D场景:这才是Unity的强项!你可以将接收到的远程视频纹理,贴到一个3D物体(比如一个电视模型、一个虚拟会议室的屏幕)上。或者,将本地摄像头画面经过绿幕抠像处理后,将人物合成到3D场景中,实现虚拟直播或AR会议的效果。

  5. 性能与画质优化

    • 自适应码率:根据网络状况动态调整视频编码参数。WebRTC底层有部分自适应能力,但你可以在应用层通过检测网络RTT、丢包率,然后动态调整WebCamTexture的分辨率或帧率来实现更粗粒度的控制。
    • 硬件编码:确保在Player Settings中开启了相应的图形API(如DX11, Metal, Vulkan)和硬件编码支持(如果平台支持)。Unity.WebRTC包在支持的情况下会尝试使用硬件编码器。
    • 音频处理:添加音频降噪(Noise Suppression)、回声消除(AEC)模块可以极大提升通话体验。可以考虑集成像WebRTC Audio Processing这样的原生插件或第三方DSP库。

这个项目就像一个乐高底座,WebRTC提供了稳定可靠的实时通信管道,而Unity则赋予了你无限的内容创作和交互呈现能力。从简单的视频通话,到复杂的虚拟社交空间、远程协作白板、甚至实时云游戏,想象空间完全由你定义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询