Unity WebGL实时视频流方案:FFmpeg+WebSocket桥接RTSP摄像头

📅 2026/7/20 17:15:57
Unity WebGL实时视频流方案:FFmpeg+WebSocket桥接RTSP摄像头
1. 项目概述当Unity WebGL遇上实时视频流如果你正在用Unity开发一个需要在浏览器里运行的3D可视化项目比如一个数字孪生工厂的监控后台或者一个在线的安全教育模拟器然后产品经理跑过来跟你说“咱们得把现场那几个摄像头的实时画面接进来让用户在网页上就能直接看到。” 这时候你大概率会心头一紧。Unity做WebGL应用本身就有不少坑而RTSPReal Time Streaming Protocol这种常见的安防摄像头、网络摄像头的流媒体协议在浏览器环境里几乎就是“天敌”。浏览器原生不支持RTSPWebGL构建出来的应用运行在一个受限制的沙盒环境中直接访问网络流更是难上加难。传统的方案要么是让后端服务器做流转发比如转成HLS或WebRTC增加复杂度和延迟要么是找一些昂贵的商业插件。最近我在一个工业监控项目里遇到了完全一样的需求经过一番折腾发现了一个强大且免费的开源解决方案它巧妙地绕开了这些障碍实现了在Unity WebGL中相对稳定地播放RTSP流。这篇文章我就来详细拆解这个方案的思路、核心实现以及我踩过的那些坑希望能给同样被这个问题困扰的开发者一条清晰的路径。2. 核心思路与架构选型为什么是“桥接”而非“直连”在深入代码之前我们必须先理解为什么这个问题如此棘手以及开源方案是如何破局的。这决定了我们整个技术栈的选型。2.1 问题根源WebGL的安全沙箱与协议壁垒Unity WebGL构建的应用最终是以JavaScript和WebAssembly的形式在浏览器中运行的。浏览器出于安全考虑施加了严格的限制网络访问限制普通的TCP/UDP Socket访问被禁止。RTSP协议通常基于TCP偶尔UDP这意味着Unity中的UnityWebRequest或传统的 .NET Socket 无法直接连接到rtsp://地址。协议不支持浏览器本身不具备解码或播放RTSP流的能力。video标签只支持有限的格式如MP4、WebM以及流媒体协议如HLS、DASH但不支持RTSP。线程与性能WebGL中多线程支持有限而实时视频的解码、渲染又是计算密集型任务处理不好极易导致页面卡顿或崩溃。因此“直连”RTSP摄像头这条路在WebGL上基本被堵死。我们必须采用“桥接”方案。2.2 开源方案的核心架构FFmpeg WebSocket Canvas我采用的这个开源方案其核心思想可以概括为“将解码工作从浏览器端剥离由一个轻量级服务端完成浏览器只负责接收和显示图像帧。”具体架构分层如下流获取与解码层服务端使用FFmpeg这个强大的多媒体框架。它负责连接RTSP源将视频流实时解码成一张张独立的图像通常是RGB或JPEG格式。FFmpeg几乎支持所有已知的摄像头RTSP格式稳定性极高。数据传输层双向通信在服务端和客户端Unity WebGL应用之间建立WebSocket连接。WebSocket是全双工通信协议非常适合高频、低延迟的数据推送。服务端将解码后的图像帧数据通过WebSocket发送给客户端。图像渲染层客户端/UnityUnity WebGL客户端接收到图像帧数据二进制或Base64字符串后将其转换为Texture2D然后通过RawImage组件或直接绘制到材质球上在UI或3D场景中进行显示。这个架构的优势非常明显规避协议限制RTSP的连接、解码全部在服务端完成浏览器端只处理通用的WebSocket和图像数据完美兼容WebGL环境。灵活性高服务端可以用任何语言实现Node.js, Python, Go等只要它能调用FFmpeg。我们可以根据项目情况选择最熟悉的技术栈。客户端负担轻Unity端只需要处理接收数据和更新纹理计算压力小性能更有保障。注意这个方案会引入一个额外的服务进程。对于纯前端的项目你需要一个可以运行Node.js或Python的后端环境。如果是本地化部署项目这个服务可能需要随客户端一起分发。3. 实战部署搭建流媒体转发服务理论清晰后我们开始动手。首先搭建服务端这是整个系统的发动机。3.1 服务端技术选型Node.js node-rtsp-stream在众多实现中我选择了Node.js环境下的node-rtsp-stream库。它封装了FFmpeg和WebSocket的逻辑几乎开箱即用非常适合快速验证和部署。环境准备安装Node.js从官网下载并安装LTS版本。安装FFmpeg这是关键依赖。去FFmpeg官网下载编译好的可执行文件并将其所在目录添加到系统的环境变量PATH中。在命令行输入ffmpeg -version能显示信息即表示成功。创建项目新建一个文件夹初始化Node.js项目。mkdir unity-rtsp-relay cd unity-rtsp-relay npm init -y核心服务代码创建一个server.js文件内容如下const Stream require(node-rtsp-stream); const express require(express); const app express(); const http require(http).createServer(app); const io require(socket.io)(http, { cors: { origin: *, // 在生产环境中应替换为你的Unity WebGL部署域名 methods: [GET, POST] } }); // 提供一个简单的测试页面 app.get(/, (req, res) { res.sendFile(__dirname /index.html); }); // 存储活动的流 let streams {}; io.on(connection, (socket) { console.log(Unity客户端已连接: , socket.id); // 客户端请求开启一个流 socket.on(start-stream, (config) { const { streamId, rtspUrl, width, height } config; console.log(收到开启流请求: ${streamId}, URL: ${rtspUrl}); // 如果该streamId已存在先停止旧的 if (streams[streamId]) { streams[streamId].stop(); } // 创建新的RTSP流并转发 try { const stream new Stream({ name: streamId, streamUrl: rtspUrl, wsPort: 0, // 我们不直接使用它的WS服务而是通过Socket.io转发 ffmpegOptions: { -stats: , -r: 25, // 帧率 -vf: scale${width || 640}:${height || 480}, // 缩放降低带宽 -q:v: 5, // 图像质量 (2-31, 越低越好) -f: image2pipe, // 输出为图像流 -c:v: mjpeg, // 使用MJPEG编码它是连续的JPEG图像比H.264更易处理 -pix_fmt: yuvj420p, } }); // 监听FFmpeg的输出JPEG图像数据 stream.on(data, (data) { // 将Buffer数据转换为Base64字符串便于通过Socket.io传输 const frameBase64 data.toString(base64); // 通过Socket.io发送给特定的客户端或房间 socket.emit(video-frame, { streamId, frame: frameBase64 }); }); stream.on(error, (err) { console.error(流 ${streamId} 错误:, err.message); socket.emit(stream-error, { streamId, error: err.message }); }); streams[streamId] stream; socket.emit(stream-started, { streamId }); } catch (error) { console.error(创建流 ${streamId} 失败:, error); socket.emit(stream-error, { streamId, error: error.message }); } }); // 客户端请求停止流 socket.on(stop-stream, ({ streamId }) { if (streams[streamId]) { streams[streamId].stop(); delete streams[streamId]; console.log(流 ${streamId} 已停止); } }); socket.on(disconnect, () { console.log(Unity客户端断开连接: , socket.id); // 清理该客户端创建的所有流这里简化处理实际可能需要更精细的管理 for (const streamId in streams) { streams[streamId].stop(); } streams {}; }); }); const PORT process.env.PORT || 3000; http.listen(PORT, () { console.log(流媒体转发服务运行在 http://localhost:${PORT}); });安装依赖npm install node-rtsp-stream express socket.io关键配置解析ffmpegOptions这是调优的核心。我选择了-c:v mjpeg编码。虽然MJPEG的压缩效率不如H.264但它每一帧都是完整的JPEG图片对于WebSocket逐帧传输和Unity端解码来说处理逻辑简单延迟更可控。-q:v 5控制了JPEG质量数值越低画质越好但数据量越大需要根据网络情况和画面复杂度权衡。scale务必对视频流进行缩放。一个1080P的原始RTSP流数据量巨大经过WebSocket传输到浏览器会严重卡顿。将其缩放到640x480或800x600能极大减轻网络和客户端的压力。Socket.io我们用它替代了node-rtsp-stream自带的简单WebSocket服务器。Socket.io提供了更稳定的连接、自动重连、房间管理等高级功能更适合生产环境。运行服务node server.js服务启动后你可以在浏览器访问http://localhost:3000看到一个测试页但我们的重点是Unity客户端。4. Unity WebGL客户端实现服务端跑起来了现在我们来构建Unity客户端让它能接收并显示视频帧。4.1 场景与UI搭建在Unity中创建一个新场景。在Canvas下创建一个RawImage组件用于显示视频。将其锚点拉伸至全屏或你需要的尺寸。创建一个空物体命名为RTSPStreamManager并将我们即将编写的脚本挂载上去。4.2 核心C#脚本连接、接收与渲染创建一个名为RTSPStreamClient.cs的脚本。using UnityEngine; using UnityEngine.UI; using System; using System.Collections; using NativeWebSocket; // 需要导入WebSocket库 public class RTSPStreamClient : MonoBehaviour { [Header(服务器配置)] public string serverUrl ws://localhost:3000; // Socket.io服务器地址 [Header(RTSP源配置)] public string rtspUrl rtsp://username:password192.168.1.100:554/stream1; public string streamId camera1; // 流的唯一标识 public int targetWidth 640; public int targetHeight 480; [Header(UI绑定)] public RawImage displayImage; private WebSocket websocket; private Texture2D videoTexture; private bool isTextureInitialized false; private Queue frameQueue new Queue(); // 用于缓冲帧避免主线程卡顿 private const int MAX_QUEUE_SIZE 3; // 防止队列积压导致内存和延迟过高 async void Start() { // 初始化纹理 videoTexture new Texture2D(2, 2); // 临时尺寸收到第一帧后调整 if (displayImage ! null) { displayImage.texture videoTexture; } // 初始化WebSocket连接 await ConnectToServer(); } async void Update() { // 处理WebSocket消息 if (websocket ! null websocket.State WebSocketState.Open) { #if !UNITY_WEBGL || UNITY_EDITOR websocket.DispatchMessageQueue(); #endif } // 从队列中取出一帧进行渲染每帧只处理一帧保证平滑 if (frameQueue.Count 0) { string frameBase64 frameQueue.Dequeue(); UpdateTextureWithFrame(frameBase64); } } async Task ConnectToServer() { // 注意Socket.io连接需要特定的URL格式通常是在基础URL后加/socket.io/ string socketUrl serverUrl /socket.io/?EIO4transportwebsocket; websocket new WebSocket(socketUrl); websocket.OnOpen () { Debug.Log(WebSocket连接成功!); // 连接成功后发送启动流的请求 SendStartStreamCommand(); }; websocket.OnError (errorMsg) { Debug.LogError(WebSocket错误: errorMsg); }; websocket.OnClose (closeCode) { Debug.LogWarning(WebSocket连接关闭: closeCode); }; websocket.OnMessage (bytes) { // Socket.io消息是特定格式的这里需要简单解析 string message System.Text.Encoding.UTF8.GetString(bytes); ProcessSocketMessage(message); }; await websocket.Connect(); } void ProcessSocketMessage(string message) { // 简化处理只处理类型为42事件的消息并过滤出“video-frame”事件 if (message.StartsWith(42)) { try { // 去除前缀 42[ 和后缀 ] string jsonStr message.Substring(2); var jsonArray JsonUtility.FromJsonSocketIOEventArray(jsonStr); if (jsonArray ! null jsonArray[0] video-frame) { string eventDataStr jsonArray[1].ToString(); var eventData JsonUtility.FromJsonFrameData(eventDataStr); if (eventData ! null eventData.streamId this.streamId) { // 将帧数据加入队列 if (frameQueue.Count MAX_QUEUE_SIZE) { frameQueue.Enqueue(eventData.frame); } else { // 队列已满丢弃最旧的帧加入新的保证实时性 frameQueue.Dequeue(); frameQueue.Enqueue(eventData.frame); } } } } catch (Exception e) { Debug.LogWarning(解析Socket.io消息失败: e.Message); } } } void SendStartStreamCommand() { var config new StreamConfig { streamId this.streamId, rtspUrl this.rtspUrl, width this.targetWidth, height this.targetHeight }; string json JsonUtility.ToJson(config); // Socket.io事件发送格式42[event-name, {data}] string message 42[\start-stream\, json ]; websocket.SendText(message); } void UpdateTextureWithFrame(string base64Frame) { try { byte[] imageData Convert.FromBase64String(base64Frame); // 加载JPEG数据到纹理 if (!isTextureInitialized) { // 第一帧时根据图像数据初始化纹理尺寸 // 注意这里简化处理实际应解析JPEG头信息获取尺寸或由服务端告知 videoTexture.LoadImage(imageData); // LoadImage会自动调整纹理尺寸 isTextureInitialized true; } else { // 后续帧直接加载到现有纹理 videoTexture.LoadImage(imageData); } videoTexture.Apply(); // 应用纹理更改 } catch (Exception e) { Debug.LogError(更新纹理失败: e.Message); } } async void OnDestroy() { if (websocket ! null websocket.State WebSocketState.Open) { // 发送停止流命令 string stopMsg 42[\stop-stream\,{\streamId\:\ streamId \}]; await websocket.SendText(stopMsg); await websocket.Close(); } } // 用于解析Socket.io消息的辅助类 [Serializable] private class SocketIOEventArray { // 这是一个简化表示实际可能更复杂 public string[] data; public static SocketIOEventArray CreateFromJSON(string jsonString) { return JsonUtility.FromJsonSocketIOEventArray(jsonString); } // 索引器方便访问 public object this[int index] data[index]; } [Serializable] private class FrameData { public string streamId; public string frame; } [Serializable] private class StreamConfig { public string streamId; public string rtspUrl; public int width; public int height; } }4.3 关键实现细节与优化WebSocket库选择Unity WebGL不支持System.Net.WebSockets。我使用了NativeWebSocket这个第三方库可以通过Unity的Package Manager的Git URL添加它对WebGL有很好的支持。消息队列缓冲这是保证流畅度的关键。WebSocket消息回调可能在任意线程触发如果直接在回调中执行Texture2D.LoadImage这是一个同步的、CPU密集的操作会严重阻塞主线程导致画面卡顿甚至浏览器无响应。引入一个Queue在Update中每帧只处理一帧起到了缓冲和节流的作用。纹理更新Texture2D.LoadImage方法可以自动识别JPEG/PNG数据并填充纹理非常方便。但要注意频繁创建和销毁纹理会产生GC垃圾回收压力。我们的方案是复用同一个Texture2D对象。Socket.io协议处理Socket.io并非原始的WebSocket它在WebSocket之上封装了自己的事件协议消息以42[...]这样的数字开头。客户端需要按照这个格式进行拼接和解析。上面的代码做了简化处理对于生产环境建议使用官方的socket.io-client的C#实现或者使用更健壮的解析逻辑。4.4 构建与部署在Unity编辑器中将RTSPStreamClient脚本拖到RTSPStreamManager物体上并配置好serverUrl、rtspUrl和displayImage。打开File - Build Settings选择WebGL平台点击Build。将构建生成的文件夹包含index.html,.js,.data等文件整个复制到我们Node.js服务所在的目录下或者任何Web服务器目录下。确保Node.js服务正在运行。用浏览器打开你的WebGL应用例如http://localhost:3000如果按照示例将构建文件放到了服务根目录。如果一切正常连接建立后你将看到RTSP视频流在Unity的UI上播放出来。5. 性能调优、问题排查与进阶思考实现基本功能只是第一步要让它在实际项目中稳定运行还需要解决一系列问题。5.1 性能瓶颈与调优策略瓶颈点表现优化策略网络带宽画面卡顿、延迟高、服务端CPU占用高1. 降低分辨率/帧率在服务端FFmpeg参数中-r降低帧率如15fpsscale缩小画面如480p。2. 调整画质-q:v适当调高如10牺牲一些画质换取更小的数据量。3. 使用更高效的编码可以尝试将FFmpeg输出改为H.264编码的MPEG-TS流并在Unity端使用VideoPlayer配合Media Foundation仅限Windows或寻找WASM解码库但这会极大增加客户端复杂度。客户端解码Unity WebGL主线程卡顿浏览器FPS下降1. 帧队列缓冲如上文实现避免在回调中直接处理。2. 使用Web Worker将Base64解码和图像数据解析放到Web Worker中但这需要复杂的跨线程纹理传递Unity WebGL支持有限。3. 检查纹理格式确保服务端发送的JPEG颜色空间如yuvj420p能被Unity正确识别。服务端负载单服务无法支撑多路高清流1. 流复用如果多个客户端观看同一个摄像头服务端应只拉取一次RTSP流然后复制给多个WebSocket连接。2. 服务集群对于海量摄像头需要设计负载均衡将不同的RTSP流分配到不同的转发服务节点上。5.2 常见问题与排查实录问题1连接成功但黑屏/绿屏。排查打开浏览器开发者工具F12的Network标签过滤WS(WebSocket)查看消息传输是否正常。查看Console是否有错误。解决检查服务端FFmpeg命令是否成功执行。在服务端日志中查看是否有FFmpeg报错如“无法连接”、“401未授权”。检查RTSP URL是否正确包括IP、端口、路径、用户名和密码。可以使用VLC播放器先测试RTSP流本身是否可用。检查Unity客户端中纹理更新的代码。在UpdateTextureWithFrame方法中加入Debug.Log确认是否收到了数据以及LoadImage是否成功。问题2画面延迟非常大超过5秒。排查这通常是缓冲区堆积造成的。FFmpeg、WebSocket、Unity客户端队列都可能产生缓冲。解决在服务端FFmpeg参数中加入-fflags nobuffer和-flags low_delay以减少缓冲。调整-probesize和-analyzeduration为较小值加快流分析。检查并减小Unity客户端中的MAX_QUEUE_SIZE比如设为2。问题3频繁断线重连。排查网络不稳定或RTSP源本身不稳定。解决在服务端代码中为node-rtsp-stream增加重连逻辑监听error和end事件重新创建流。使用Socket.io的心跳机制和自动重连功能确保网络层连接稳定。在Unity客户端监听OnClose和OnError事件实现自动重连机制。问题4多路视频流内存占用过高。解决在Unity中确保不用的流及时调用StopStream通知服务端关闭并销毁对应的Texture2D。服务端同样需要做好资源管理客户端断开后及时停止对应的FFmpeg进程。5.3 安全与生产环境考量认证与授权示例中服务端是开放连接的。在生产环境必须在服务端Node.js和RTSP源两个层面添加认证。Node.js服务可以使用JWT等机制验证Unity客户端。RTSP密码应存储在服务端环境变量中而非客户端。CORS如果Unity WebGL应用和服务端不在同一个域名下需要正确配置CORS。示例中Socket.io的cors设置为了*生产环境应指定确切的来源。HTTPS/WSS线上部署务必使用HTTPS和WSS安全的WebSocket否则浏览器可能会阻止混合内容或非安全连接。服务监控需要监控Node.js服务的CPU、内存使用情况以及FFmpeg进程的状态确保服务稳定。5.4 方案对比与选型思考这个开源方案并非银弹它是在WebGL限制下的一个优雅折中。我们来对比一下其他常见方案方案优点缺点适用场景本文方案 (FFmpegWS)免费、开源、灵活可控延迟相对较低1-3秒支持几乎所有RTSP源。需要额外部署服务端增加了架构复杂度延迟高于WebRTC。对延迟要求不极端非实时交互需要快速集成多种品牌摄像头预算有限或追求技术可控的项目。商业Unity插件客户端集成简单可能有更好的性能优化和官方支持。需要付费可能很昂贵插件可能对特定摄像头型号支持不佳黑盒化问题难排查。预算充足追求快速交付且对底层技术不关心项目规模不大的团队。后端转码(HLS/DASH)利用成熟的流媒体技术如nginx-rtmp-module兼容性极好可直接用HTML5 video标签。延迟非常高通常10秒以上架构更重需要转码和切片服务器。对实时性要求极低主要做录像回放或直播观看的场景。WebRTC网关延迟极低500ms是真正的“实时”方案。架构复杂需要STUN/TURN服务器穿越NATRTSP转WebRTC的网关如Janus, Mediasoup配置和维护门槛高。视频会议、远程操控等对延迟有毫秒级要求的交互式应用。我个人在实际项目中的体会是对于大多数工业监控、智慧园区、在线展示这类项目1-3秒的延迟是完全可接受的。这个开源方案在成本、开发难度和效果之间取得了最好的平衡。它最大的价值在于“透明”你完全掌控从流获取到显示的每一个环节任何问题都可以深入定位和优化这是商业插件无法比拟的。当然如果你的应用是无人车远程驾驶或者实时AR协作那么就必须硬着头皮去攻克WebRTC的方案了。最后一个小技巧在调试时可以先将FFmpeg的输出保存为本地文件确认命令能正确拉流并转码这能帮你快速区分问题是出在流源、FFmpeg命令还是后续的WebSocket传输和Unity渲染环节。