Unity WebGL实时视频流方案:FFmpeg+WebSocket桥接RTSP摄像头
2026/7/21 22:28:28 网站建设 项目流程

1. 项目概述:当Unity WebGL遇上实时视频流

如果你正在用Unity开发一个需要在浏览器里运行的3D可视化项目,比如一个数字孪生工厂的监控后台,或者一个在线的安全教育模拟器,然后产品经理跑过来跟你说:“咱们得把现场那几个摄像头的实时画面接进来,让用户在网页上就能直接看到。” 这时候,你大概率会心头一紧。Unity做WebGL应用本身就有不少坑,而RTSP(Real Time Streaming Protocol)这种常见的安防摄像头、网络摄像头的流媒体协议,在浏览器环境里几乎就是“天敌”。浏览器原生不支持RTSP,WebGL构建出来的应用运行在一个受限制的沙盒环境中,直接访问网络流更是难上加难。传统的方案要么是让后端服务器做流转发(比如转成HLS或WebRTC),增加复杂度和延迟;要么是找一些昂贵的商业插件。最近,我在一个工业监控项目里遇到了完全一样的需求,经过一番折腾,发现了一个强大且免费的开源解决方案,它巧妙地绕开了这些障碍,实现了在Unity WebGL中相对稳定地播放RTSP流。这篇文章,我就来详细拆解这个方案的思路、核心实现以及我踩过的那些坑,希望能给同样被这个问题困扰的开发者一条清晰的路径。

2. 核心思路与架构选型:为什么是“桥接”而非“直连”

在深入代码之前,我们必须先理解为什么这个问题如此棘手,以及开源方案是如何破局的。这决定了我们整个技术栈的选型。

2.1 问题根源:WebGL的安全沙箱与协议壁垒

Unity WebGL构建的应用,最终是以JavaScript和WebAssembly的形式在浏览器中运行的。浏览器出于安全考虑,施加了严格的限制:

  1. 网络访问限制:普通的TCP/UDP Socket访问被禁止。RTSP协议通常基于TCP(偶尔UDP),这意味着Unity中的UnityWebRequest或传统的 .NET Socket 无法直接连接到rtsp://地址。
  2. 协议不支持:浏览器本身不具备解码或播放RTSP流的能力。<video>标签只支持有限的格式,如MP4、WebM,以及流媒体协议如HLS、DASH,但不支持RTSP。
  3. 线程与性能:WebGL中多线程支持有限,而实时视频的解码、渲染又是计算密集型任务,处理不好极易导致页面卡顿或崩溃。

因此,“直连”RTSP摄像头这条路在WebGL上基本被堵死。我们必须采用“桥接”方案。

2.2 开源方案的核心架构:FFmpeg + WebSocket + Canvas

我采用的这个开源方案,其核心思想可以概括为:“将解码工作从浏览器端剥离,由一个轻量级服务端完成,浏览器只负责接收和显示图像帧。”

具体架构分层如下:

  1. 流获取与解码层(服务端):使用FFmpeg这个强大的多媒体框架。它负责连接RTSP源,将视频流实时解码成一张张独立的图像(通常是RGB或JPEG格式)。FFmpeg几乎支持所有已知的摄像头RTSP格式,稳定性极高。
  2. 数据传输层(双向通信):在服务端和客户端(Unity WebGL应用)之间建立WebSocket连接。WebSocket是全双工通信协议,非常适合高频、低延迟的数据推送。服务端将解码后的图像帧数据通过WebSocket发送给客户端。
  3. 图像渲染层(客户端/Unity):Unity 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的逻辑,几乎开箱即用,非常适合快速验证和部署。

环境准备:

  1. 安装Node.js:从官网下载并安装LTS版本。
  2. 安装FFmpeg:这是关键依赖。去FFmpeg官网下载编译好的可执行文件,并将其所在目录添加到系统的环境变量PATH中。在命令行输入ffmpeg -version能显示信息即表示成功。
  3. 创建项目:新建一个文件夹,初始化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搭建

  1. 在Unity中创建一个新场景。
  2. 在Canvas下创建一个RawImage组件,用于显示视频。将其锚点拉伸至全屏或你需要的尺寸。
  3. 创建一个空物体,命名为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:password@192.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/?EIO=4&transport=websocket"; 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.FromJson<SocketIOEventArray>(jsonStr); if (jsonArray != null && jsonArray[0] == "video-frame") { string eventDataStr = jsonArray[1].ToString(); var eventData = JsonUtility.FromJson<FrameData>(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.FromJson<SocketIOEventArray>(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 关键实现细节与优化

  1. WebSocket库选择:Unity WebGL不支持System.Net.WebSockets。我使用了NativeWebSocket这个第三方库(可以通过Unity的Package Manager的Git URL添加),它对WebGL有很好的支持。
  2. 消息队列缓冲:这是保证流畅度的关键。WebSocket消息回调可能在任意线程触发,如果直接在回调中执行Texture2D.LoadImage(这是一个同步的、CPU密集的操作),会严重阻塞主线程,导致画面卡顿甚至浏览器无响应。引入一个QueueUpdate中每帧只处理一帧,起到了缓冲和节流的作用。
  3. 纹理更新Texture2D.LoadImage方法可以自动识别JPEG/PNG数据并填充纹理,非常方便。但要注意,频繁创建和销毁纹理会产生GC(垃圾回收)压力。我们的方案是复用同一个Texture2D对象。
  4. Socket.io协议处理Socket.io并非原始的WebSocket,它在WebSocket之上封装了自己的事件协议(消息以42[...]这样的数字开头)。客户端需要按照这个格式进行拼接和解析。上面的代码做了简化处理,对于生产环境,建议使用官方的socket.io-client的C#实现,或者使用更健壮的解析逻辑。

4.4 构建与部署

  1. 在Unity编辑器中,将RTSPStreamClient脚本拖到RTSPStreamManager物体上,并配置好serverUrlrtspUrldisplayImage
  2. 打开File -> Build Settings,选择WebGL平台,点击Build
  3. 将构建生成的文件夹(包含index.html,.js,.data等文件)整个复制到我们Node.js服务所在的目录下(或者任何Web服务器目录下)。
  4. 确保Node.js服务正在运行。
  5. 用浏览器打开你的WebGL应用(例如http://localhost:3000如果按照示例将构建文件放到了服务根目录)。如果一切正常,连接建立后,你将看到RTSP视频流在Unity的UI上播放出来。

5. 性能调优、问题排查与进阶思考

实现基本功能只是第一步,要让它在实际项目中稳定运行,还需要解决一系列问题。

5.1 性能瓶颈与调优策略

瓶颈点表现优化策略
网络带宽画面卡顿、延迟高、服务端CPU占用高1. 降低分辨率/帧率:在服务端FFmpeg参数中,-r降低帧率(如15fps),scale缩小画面(如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增加重连逻辑,监听errorend事件,重新创建流。
    • 使用Socket.io的心跳机制和自动重连功能,确保网络层连接稳定。
    • 在Unity客户端,监听OnCloseOnError事件,实现自动重连机制。

问题4:多路视频流内存占用过高。

  • 解决
    • 在Unity中,确保不用的流及时调用StopStream通知服务端关闭,并销毁对应的Texture2D
    • 服务端同样需要做好资源管理,客户端断开后及时停止对应的FFmpeg进程。

5.3 安全与生产环境考量

  1. 认证与授权:示例中服务端是开放连接的。在生产环境,必须在服务端(Node.js)和RTSP源两个层面添加认证。Node.js服务可以使用JWT等机制验证Unity客户端。RTSP密码应存储在服务端环境变量中,而非客户端。
  2. CORS:如果Unity WebGL应用和服务端不在同一个域名下,需要正确配置CORS。示例中Socket.iocors设置为了"*",生产环境应指定确切的来源。
  3. HTTPS/WSS:线上部署务必使用HTTPS和WSS(安全的WebSocket),否则浏览器可能会阻止混合内容或非安全连接。
  4. 服务监控:需要监控Node.js服务的CPU、内存使用情况,以及FFmpeg进程的状态,确保服务稳定。

5.4 方案对比与选型思考

这个开源方案并非银弹,它是在WebGL限制下的一个优雅折中。我们来对比一下其他常见方案:

方案优点缺点适用场景
本文方案 (FFmpeg+WS)免费、开源、灵活可控,延迟相对较低(1-3秒),支持几乎所有RTSP源。需要额外部署服务端,增加了架构复杂度;延迟高于WebRTC。对延迟要求不极端(非实时交互),需要快速集成多种品牌摄像头,预算有限或追求技术可控的项目。
商业Unity插件客户端集成简单,可能有更好的性能优化和官方支持。需要付费,可能很昂贵;插件可能对特定摄像头型号支持不佳;黑盒化,问题难排查。预算充足,追求快速交付且对底层技术不关心,项目规模不大的团队。
后端转码(HLS/DASH)利用成熟的流媒体技术(如nginx-rtmp-module),兼容性极好(可直接用HTML5 video标签)。延迟非常高(通常10秒以上),架构更重(需要转码和切片服务器)。对实时性要求极低,主要做录像回放或直播观看的场景。
WebRTC网关延迟极低(<500ms),是真正的“实时”方案。架构复杂,需要STUN/TURN服务器穿越NAT;RTSP转WebRTC的网关(如Janus, Mediasoup)配置和维护门槛高。视频会议、远程操控等对延迟有毫秒级要求的交互式应用。

我个人在实际项目中的体会是:对于大多数工业监控、智慧园区、在线展示这类项目,1-3秒的延迟是完全可接受的。这个开源方案在成本、开发难度和效果之间取得了最好的平衡。它最大的价值在于“透明”,你完全掌控从流获取到显示的每一个环节,任何问题都可以深入定位和优化,这是商业插件无法比拟的。当然,如果你的应用是无人车远程驾驶或者实时AR协作,那么就必须硬着头皮去攻克WebRTC的方案了。

最后一个小技巧:在调试时,可以先将FFmpeg的输出保存为本地文件,确认命令能正确拉流并转码,这能帮你快速区分问题是出在流源、FFmpeg命令,还是后续的WebSocket传输和Unity渲染环节。

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

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

立即咨询