Unity WebGL实现RTSP零延迟监控:服务端转码与WebSocket实战方案

📅 2026/8/3 6:45:16
Unity WebGL实现RTSP零延迟监控:服务端转码与WebSocket实战方案
1. 项目概述为什么要在Unity WebGL里啃RTSP这块硬骨头最近在做一个工业可视化项目客户要求在浏览器里零延迟地查看几十路摄像头的实时画面。需求一提出来团队里就有人嘀咕这不就是做个视频监控嘛用现成的安防平台或者WebRTC不香吗干嘛非得用Unity WebGL这话问到点子上了。选择Unity WebGL来播放RTSP流本质上是在特定场景下的“最优解”博弈。如果你做的只是一个简单的门禁监控页面那确实没必要这么折腾。但当你面对的是需要将实时视频无缝嵌入到复杂3D场景中的需求时——比如在数字孪生工厂里点击一个虚拟的机床模型就能弹出对应位置的摄像头实时画面或者在智慧园区的大屏上视频流需要作为UI元素与各种图表、告警信息动态组合——Unity的渲染管线、UI系统UGUI/UI Toolkit和强大的脚本控制能力就成为了不可替代的优势。然而RTSPReal Time Streaming Protocol协议本身是为局域网或专网设计的流媒体协议它基于TCP或UDP/RTP并不是为浏览器原生设计的。浏览器无法像播放一个MP4文件那样直接处理rtsp://开头的地址。这就构成了核心矛盾我们拥有一个强大的、跨平台的渲染引擎Unity WebGL但它运行在一个“沙箱”环境浏览器中而这个环境对我们要用的流媒体协议“不感冒”。传统的解决方案比如通过VLC插件或者ActiveX控件在当今追求安全与跨平台的Web环境下早已被淘汰。因此这个项目的核心挑战就是要在浏览器的限制下为Unity WebGL打通一条接收、解码并流畅播放RTSP视频流的通道。我走过的弯路不少试过在Unity里用C#直接解析RTP包性能灾难也试过让服务端把每一帧都转成Base64图片发过来带宽和延迟双爆炸。最终稳定下来的方案其核心思路可以概括为“桥接与转码”在浏览器前端与RTSP源后端之间建立一个轻量级的“转码服务器”作为桥梁。这个服务器负责拉取原始的RTSP流并将其转换为一种WebGL能够高效处理、浏览器能够原生支持的格式通常是基于HTTP的MJPEG、WebM或HLS然后Unity再通过标准的网络请求去获取并渲染这些转换后的数据。听起来像是绕了个弯但这是目前实现功能、性能与兼容性平衡的最务实路径。接下来我就把这套经过实战检验的“三步走”方案拆开揉碎了讲给你听。2. 核心方案选型为什么是“服务端转码 WebSocket/HTTP”面对RTSP播放的难题社区里方案众多但坑也多。在深入三步实现之前我们必须先厘清为什么选择“服务端转码”这条主流路径以及配套技术栈的考量。2.1 主流技术路线优劣分析首先我们得明白浏览器和Unity WebGL各自的能力边界。纯前端解码理想很丰满梦想着在WebAssembly里用C/C写一个FFmpeg直接在浏览器里拉流、解码、渲染。理论上可行但现实是骨感的。一个完整的FFmpeg编译后体积巨大几十MB首次加载时间无法接受。更重要的是RTSP拉流涉及复杂的网络协议栈和音视频解码在单线程且性能受限的WebAssembly环境里很难保证多路视频的稳定和低延迟。这条路对大多数应用场景来说性价比太低。浏览器插件或Native Client已被时代抛弃NPAPI、ActiveX、甚至Unity自家的Web Player都因为严重的安全隐患和极差的跨平台性被现代浏览器彻底抛弃。PNaCl也已被WebAssembly取代。此路不通。服务端转码务实的选择这是目前最成熟、最稳定的方案。核心思想是“让专业的人做专业的事”。在服务器端可以是一台独立的转码服务器也可以是业务后端兼做此事使用成熟的媒体处理工具如FFmpeg、GStreamer来拉取RTSP流并进行实时的转码与封装输出为浏览器友好的格式。Unity WebGL端则只需要像加载普通网络资源一样去获取这些处理后的流。这个方案将最耗计算资源的解码工作从性能羸弱的前端转移到了强大的服务端保证了播放的流畅性与稳定性。2.2 转码输出格式的选择MJPEG、HLS还是WebSocket确定了服务端转码的路线下一个关键决策是转成什么格式给UnityMJPEG over HTTP原理服务器将视频流的每一帧都压缩成一张独立的JPEG图片通过一个长期的HTTP连接持续发送。Unity端不断请求这个HTTP地址收到一张就渲染一张。优点实现极其简单。服务器用FFmpeg一行命令就能搭建Unity端用UnityWebRequest或WWW循环抓取即可。兼容性无敌。缺点延迟高带宽占用大。因为每帧都是完整的JPEG压缩图像无法利用帧间压缩数据量巨大。通常延迟在1秒以上不适合真正的“实时”监控。多路播放时对服务器带宽压力很大。适用场景对实时性要求不高2秒、路数少1-4路、且追求最简单快速上手的演示或测试项目。HLS (HTTP Live Streaming)原理服务器将流切成一系列小的、长度固定的.ts视频文件如2秒一个并生成一个不断更新的.m3u8索引文件。客户端浏览器通过HTTP下载并播放这些.ts片段。优点兼容性非常好是苹果推动的行业标准所有现代浏览器和移动设备都原生支持。自带自适应码率对抗网络波动能力强。缺点固有延迟高。由于需要等待切片、生成索引、客户端缓冲延迟通常在10-30秒完全无法满足实时监控的需求。虽然可以通过降低切片时长如1秒和减少缓冲来优化但很难做到1秒内的稳定低延迟。适用场景视频点播、直播对延迟不敏感如赛事直播、录像回放。实时监控请直接排除HLS。WebSocket 自定义封装本文推荐方案原理服务器拉取RTSP流解码后将原始的YUV或RGB帧数据或者经过轻量编码如VP8/VP9关键帧的数据通过WebSocket连接直接、持续地推送给浏览器端的Unity。Unity收到数据后在内存中快速组装成纹理并渲染。优点延迟极低可优化至100-300毫秒真正的“准实时”。数据通过长连接推送避免了HTTP的请求-响应开销。传输的是原始或轻量编码数据带宽利用率高于MJPEG。缺点实现复杂度较高。需要自行设计前后端的数据通信协议如定义帧头、区分视频/音频帧、处理丢包等。需要一定的WebSocket和图像处理编程能力。适用场景对延迟有严格要求的实时监控、交互式视频应用。这是实现“零延迟”目标的关键。注意这里的“零延迟”是一个工程上的目标指尽可能接近摄像头的采集延迟通常100-500ms。受限于网络传输、编解码、渲染管线绝对零延迟是不可能的。但通过WebSocket方案我们可以将整体延迟控制在人眼难以察觉的范围内。综合来看要实现标题所说的“零延迟实时监控”WebSocket方案是唯一的选择。下面的三步实现也将围绕构建一个高效的WebSocket视频流转发服务来展开。3. 第一步搭建RTSP转码中继服务器Node.js FFmpeg我们的中转服务器是整个系统的枢纽。我选择Node.js child_process调用FFmpeg的方案因为它轻量、跨平台、易于与WebSocket服务集成。3.1 环境准备与核心依赖首先确保你的开发或部署机器上已经安装了FFmpeg。这是整个方案的核心工具。在Ubuntu上可以sudo apt install ffmpeg在Windows上可以去官网下载编译好的二进制文件并添加到系统PATH。然后初始化一个Node.js项目安装必要的依赖npm init -y npm install express ws fluent-ffmpegexpress: 用于提供简单的HTTP服务例如状态检查、获取服务器列表。ws: 一个简单高效的WebSocket服务器库。fluent-ffmpeg: 一个优秀的Node.js FFmpeg封装库让我们能够以编程方式、更优雅地调用FFmpeg命令。3.2 服务器核心代码实现我们创建一个server.js文件。这个服务器的核心工作是为每一个客户端请求的RTSP流启动一个FFmpeg进程来拉流并转码然后将转码后的数据通过一个独立的WebSocket连接推送给对应的客户端。const WebSocket require(ws); const express require(express); const ffmpeg require(fluent-ffmpeg); const app express(); const PORT 8080; // 存储活跃的FFmpeg进程和对应的WebSocket连接 const activeStreams new Map(); // key: streamId, value: { ws, ffmpegProcess } // 创建WebSocket服务器 const wss new WebSocket.Server({ port: 8081 }); wss.on(connection, (ws, request) { console.log(新的WebSocket客户端连接); const urlParams new URL(request.url, ws://${request.headers.host}).searchParams; const rtspUrl urlParams.get(rtsp); const streamId urlParams.get(id) || stream_${Date.now()}; if (!rtspUrl) { ws.send(JSON.stringify({ error: Missing rtsp parameter })); ws.close(); return; } // 检查是否已存在相同ID的流避免重复创建 if (activeStreams.has(streamId)) { console.log(流ID ${streamId} 已存在发送关闭指令并接管); const oldStream activeStreams.get(streamId); oldStream.ws.close(); if (oldStream.ffmpegProcess) { oldStream.ffmpegProcess.kill(SIGKILL); } } // 启动FFmpeg进程 console.log(启动FFmpeg拉流: ${rtspUrl}); const ffmpegProcess ffmpeg(rtspUrl) .inputOptions([ -rtsp_transport, tcp, // 强制使用TCP避免UDP丢包导致花屏 -stimeout, 5000000, // 设置超时微秒 ]) .outputOptions([ -f, image2pipe, // 输出到管道 -update, 1, // 每次输出覆盖上一帧对MJPEG流有用但这里我们传raw -pix_fmt, rgb24, // 输出RGB24格式Unity的Texture2D可以直接使用 -r, 25, // 输出帧率根据摄像头能力调整 -vsync, 0, // 不进行帧率同步尽可能快地输出 -vf, scale640:360, // 可选缩放图像降低带宽和Unity渲染压力 ]) .format(rawvideo) // 输出原始视频数据 .on(start, (commandLine) { console.log(FFmpeg进程启动: ${streamId}); activeStreams.set(streamId, { ws, ffmpegProcess }); }) .on(error, (err, stdout, stderr) { console.error(FFmpeg错误 [${streamId}]:, err.message); if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ error: FFmpeg error: ${err.message} })); ws.close(); } cleanupStream(streamId); }) .on(end, () { console.log(FFmpeg进程结束 [${streamId}]); cleanupStream(streamId); }); // 将FFmpeg的stdout原始RGB数据通过WebSocket发送 ffmpegProcess.pipe(); const ffmpegStdout ffmpegProcess._ffmpegProc.stdout; if (ffmpegStdout) { ffmpegStdout.on(data, (chunk) { if (ws.readyState WebSocket.OPEN) { // 这里直接发送二进制数据。也可以选择Base64编码但会增加开销。 ws.send(chunk, { binary: true }); } }); } ws.on(close, () { console.log(客户端断开连接 [${streamId}]); if (activeStreams.get(streamId)?.ws ws) { ffmpegProcess.kill(SIGKILL); cleanupStream(streamId); } }); ws.on(error, (error) { console.error(WebSocket错误 [${streamId}]:, error); cleanupStream(streamId); }); }); function cleanupStream(streamId) { const stream activeStreams.get(streamId); if (stream) { if (stream.ws.readyState WebSocket.OPEN) { stream.ws.close(); } if (stream.ffmpegProcess) { stream.ffmpegProcess.kill(SIGKILL); } activeStreams.delete(streamId); console.log(已清理流: ${streamId}); } } // 提供一个简单的HTTP接口来查看活跃流 app.get(/streams, (req, res) { res.json({ activeStreams: Array.from(activeStreams.keys()) }); }); app.listen(PORT, () { console.log(RTSP转码中继服务器HTTP服务运行在 http://localhost:${PORT}); console.log(WebSocket服务运行在 ws://localhost:8081); });关键点解析与避坑指南-rtsp_transport tcp这是极其重要的选项。RTSP默认可能使用UDP传输RTP包在复杂的网络环境下尤其是经过互联网极易丢包导致视频花屏、卡顿。强制使用TCP虽然会增加少量延迟但能保证数据的可靠传输画面稳定性是实时监控的第一要务。-pix_fmt rgb24我们输出RGB24格式的原始数据。每个像素由3个字节R, G, B表示。这是为了与Unity中Texture2D的TextureFormat.RGB24格式直接对应避免在Unity端进行复杂的颜色空间转换提升性能。-vf scale640:360根据你的实际需求调整分辨率。在WebGL环境下渲染高分辨率视频如1080p对性能消耗很大尤其是多路播放时。通常将流缩放至720p或更低的分辨率能在视觉质量和性能之间取得良好平衡。流管理与资源释放代码中使用了Map来管理活跃流并在客户端断开或出错时严格关闭对应的WebSocket和FFmpeg进程。务必做好资源清理否则服务器上的FFmpeg进程会越来越多最终耗尽资源。错误处理FFmpeg拉流可能因网络波动、摄像头重启、密码错误等众多原因失败。必须监听error事件并将错误信息反馈给客户端同时进行清理。稳定的错误处理是服务端健壮性的关键。实操心得在服务器部署时建议将Node.js进程用pm2等工具管理实现后台运行和崩溃自动重启。对于高并发场景数十上百路单进程Node.js可能成为瓶颈需要考虑使用集群Cluster模式或者将FFmpeg进程分散到多个转码服务器上通过负载均衡来分配。4. 第二步Unity WebGL客户端接收与渲染服务端准备好了数据像水流一样通过WebSocket涌过来。Unity端的任务就是接住这些“水”RGB数据并把它变成屏幕上流动的图像。4.1 建立WebSocket连接与数据接收首先我们需要在Unity中建立WebSocket连接。由于Unity WebGL不支持标准的System.Net.WebSockets我们必须使用浏览器的WebSocket API。这通常通过Unity的JavaScript互操作JS Interop或使用现成的第三方插件如NativeWebSocket来实现。这里为了清晰展示原理我们使用JS Interop的方式。创建一个WebSocketVideoStream.cs脚本using UnityEngine; using System.Runtime.InteropServices; using System; public class WebSocketVideoStream : MonoBehaviour { // 定义与JavaScript交互的函数 [DllImport(__Internal)] private static extern void ConnectToStream(string wsUrl, string streamId); [DllImport(__Internal)] private static extern void DisconnectFromStream(string streamId); // 这个函数将由JavaScript回调传入接收到的图像数据 private void OnVideoDataReceived(byte[] data, int width, int height) { // 在这里处理接收到的图像数据更新纹理 UpdateTextureFromData(data, width, height); } public string rtspUrl rtsp://admin:password192.168.1.100:554/h264/ch1/main/av_stream; public string serverWsUrl ws://localhost:8081; public string streamId camera1; public int targetWidth 640; public int targetHeight 360; private Texture2D _videoTexture; private Renderer _renderer; // 假设视频渲染在一个Plane上 void Start() { // 初始化纹理 _videoTexture new Texture2D(targetWidth, targetHeight, TextureFormat.RGB24, false); _videoTexture.filterMode FilterMode.Point; // 对于像素风格或需要锐利边缘时使用 _renderer GetComponentRenderer(); if (_renderer ! null) { _renderer.material.mainTexture _videoTexture; } // 构建完整的WebSocket URL包含RTSP地址和流ID作为参数 string fullWsUrl ${serverWsUrl}?rtsp{Uri.EscapeDataString(rtspUrl)}id{streamId}; // 调用JavaScript函数建立连接 #if UNITY_WEBGL !UNITY_EDITOR ConnectToStream(fullWsUrl, streamId); #else Debug.LogWarning(WebSocket连接仅在WebGL平台生效。在编辑器中使用模拟数据或测试服务器。); // 可以在编辑器模式下模拟数据接收用于测试渲染逻辑 // SimulateDataReceiving(); #endif } void OnDestroy() { #if UNITY_WEBGL !UNITY_EDITOR DisconnectFromStream(streamId); #endif if (_videoTexture ! null) { Destroy(_videoTexture); } } // 由JavaScript调用的回调函数必须声明为public且遵循特定命名约定或者通过SendMessage // 更优雅的方式是使用Delegate或Event这里为简化使用字符串方法名。 public void JSCallback_OnVideoFrame(string base64Data) { // JavaScript传递Base64字符串需要解码为byte[] byte[] imageData Convert.FromBase64String(base64Data); // 假设数据是完整的RGB24帧 if (imageData.Length targetWidth * targetHeight * 3) { UpdateTextureFromData(imageData, targetWidth, targetHeight); } else { Debug.LogError($接收到的数据长度{imageData.Length}与预期{targetWidth*targetHeight*3}不符); } } private void UpdateTextureFromData(byte[] data, int width, int height) { if (_videoTexture null || _videoTexture.width ! width || _videoTexture.height ! height) { _videoTexture new Texture2D(width, height, TextureFormat.RGB24, false); if (_renderer ! null) _renderer.material.mainTexture _videoTexture; } // 直接加载原始数据到纹理这是最高效的方式 _videoTexture.LoadRawTextureData(data); _videoTexture.Apply(false); // 非强制应用性能更好 } }接下来我们需要创建一个对应的JavaScript文件例如StreamingPlugin.jslib并将其放在项目的Assets/Plugins/WebGL目录下。这个文件负责与浏览器的WebSocket API直接交互。// Assets/Plugins/WebGL/StreamingPlugin.jslib mergeInto(LibraryManager.library, { ConnectToStream: function (wsUrlPtr, streamIdPtr) { var wsUrl Pointer_stringify(wsUrlPtr); var streamId Pointer_stringify(streamIdPtr); if (window.unityStreamSockets undefined) { window.unityStreamSockets {}; } if (window.unityStreamSockets[streamId]) { console.warn(WebSocket for ${streamId} already exists. Closing previous.); window.unityStreamSockets[streamId].close(); } try { var socket new WebSocket(wsUrl); window.unityStreamSockets[streamId] socket; socket.binaryType arraybuffer; // 重要接收二进制数据 socket.onopen function(event) { console.log(WebSocket connected for stream: ${streamId}); }; socket.onmessage function(event) { if (event.data instanceof ArrayBuffer) { // 将ArrayBuffer转换为Uint8Array var uint8Array new Uint8Array(event.data); // 将Uint8Array转换为Base64字符串以便通过Unity的SendMessage传递 // 注意对于大尺寸视频帧频繁的Base64转换和字符串传递有性能开销。 // 更优方案是使用Unity的IL2CPP和Emscripten的堆操作直接传递指针但更复杂。 var base64String btoa(String.fromCharCode.apply(null, uint8Array)); // 调用Unity中的方法 // 假设你的GameObject名为“VideoPlayer”挂载了WebSocketVideoStream脚本 gameInstance.SendMessage(VideoPlayer, JSCallback_OnVideoFrame, base64String); } else if (typeof event.data string) { // 可能是错误信息JSON try { var msg JSON.parse(event.data); if (msg.error) { console.error(Stream ${streamId} error:, msg.error); gameInstance.SendMessage(VideoPlayer, JSCallback_OnStreamError, msg.error); } } catch(e) { console.log(Stream ${streamId} message:, event.data); } } }; socket.onerror function(error) { console.error(WebSocket error for ${streamId}:, error); gameInstance.SendMessage(VideoPlayer, JSCallback_OnStreamError, WebSocket error); }; socket.onclose function(event) { console.log(WebSocket closed for ${streamId}); delete window.unityStreamSockets[streamId]; }; } catch (error) { console.error(Failed to create WebSocket for ${streamId}:, error); } }, DisconnectFromStream: function (streamIdPtr) { var streamId Pointer_stringify(streamIdPtr); if (window.unityStreamSockets window.unityStreamSockets[streamId]) { window.unityStreamSockets[streamId].close(); delete window.unityStreamSockets[streamId]; } } });关键点解析与性能优化二进制数据传输在JavaScript中设置socket.binaryType arraybuffer至关重要这确保了我们从WebSocket接收到的是原始的二进制ArrayBuffer而不是被转换成字符串的Blob。Base64编码的性能瓶颈上述示例为了简化将二进制数据通过btoa转换成Base64字符串再通过SendMessage传递给Unity。这是当前实现中最大的性能瓶颈。对于640x36025fps的视频每帧数据约690KB每秒需要进行25次Base64编码和字符串传递开销巨大。高级优化方案要追求极致性能必须避免Base64转换。可以利用Emscripten提供的HEAPU8Unity WebGL编译后的内存堆进行直接内存操作。大致思路是在C#端分配一个足够大的byte[]并将其pin住获取其指针。将这个指针传递给JavaScript函数。JavaScript在收到ArrayBuffer后直接将数据拷贝到HEAPU8中指针所指向的位置。然后通知C#端数据已就绪C#端直接使用之前分配的byte[]来更新纹理。这种方式完全避免了中间格式转换和额外的内存分配是生产环境多路视频播放的必备优化。实现起来更复杂需要深入理解Unity WebGL的互操作机制。纹理更新优化Texture2D.LoadRawTextureData和Texture2D.Apply(false)是更新纹理的标准且高效的方式。确保纹理格式RGB24与传入的数据格式完全匹配。4.2 多路视频播放与UI集成单一视频播放只是开始。工业场景往往是多路视频同时展示。管理多个流可以创建一个StreamManager单例负责管理所有WebSocketVideoStream实例的创建、连接和销毁。为每个摄像头预设一个唯一的streamId和RTSP URL。动态创建渲染表面根据摄像头数量在运行时动态实例化Quad或Plane作为渲染表面并为其挂载WebSocketVideoStream脚本设置对应的流参数。可以将这些表面排列在画布上或者附着在3D场景中的不同物体上。UI控制结合UGUI或UI Toolkit创建视频窗口的拖拽、缩放、关闭、画质切换通过修改服务端FFmpeg的scale参数并重连等功能。例如点击一个UI按钮动态创建一个新的视频流窗口并连接到指定的摄像头。5. 第三步性能调优与生产环境部署三步中的最后一步也是最考验工程能力的一步是将一个能跑的Demo变成一个稳定、高效、可维护的生产级系统。5.1 客户端Unity WebGL性能优化降低分辨率与帧率这是最有效的优化手段。不是所有场景都需要1080p全高清。根据视频窗口在屏幕上的实际大小动态请求不同分辨率的流这需要服务端支持多种输出配置。将帧率从25fps降低到15fps或10fps能显著减少数据量和渲染压力。使用GPU加速的Shader显示对于RGB24格式我们使用的是CPU上传纹理。对于YUV格式更省带宽可以考虑在Shader中进行YUV到RGB的转换但这会增加Shader复杂度。一个更通用的优化是确保渲染视频的材质使用的是Unlit/Texture这类轻量级Shader避免不必要的光照和顶点计算。对象池与纹理复用频繁创建和销毁Texture2D和GameObject会引发GC垃圾回收导致卡顿。对于动态打开/关闭的视频窗口使用对象池来管理渲染表面和纹理对象。按需渲染如果视频窗口被其他UI完全遮挡或不在摄像机视野内可以暂停该视频流的网络请求和纹理更新节省CPU和带宽。5.2 服务端Node.js中转性能与稳定性FFmpeg参数精细调优-preset ultrafast在输出格式为H.264等时使用牺牲一些压缩率换取更快的编码速度降低转码延迟。-tune zerolatency同样为降低延迟而设计。-threads根据服务器CPU核心数设置FFmpeg使用的线程数充分利用多核性能。缓冲区控制使用-avioflags direct和调整-probesize、-analyzeduration来减少缓冲进一步降低延迟。进程隔离与资源限制为每个FFmpeg进程设置资源限制如CPU时间、内存防止某个摄像头流异常导致拖垮整个服务器。在Docker容器中部署可以很方便地实现这一点。引入流媒体服务器当路数非常多时比如超过50路用Node.js直接管理大量FFmpeg子进程会变得笨重。可以考虑引入专业的流媒体服务器如MediaMTX原rtsp-simple-server、ZLMediaKit或SRS。这些服务器专门为流媒体转发、转码而设计支持集群管理效率更高。我们的Node.js服务则退化为一个“控制层”负责向这些媒体服务器发起拉流请求并告知客户端连接媒体服务器转换后的WebSocket或HTTP-FLV地址。健康检查与自动重启实现一个监控机制定期检查每个FFmpeg进程是否存活、输出是否正常。对于僵死或无输出的进程自动重启。同时记录日志便于排查摄像头断线、网络波动等问题。5.3 网络与安全使用WSSWebSocket Secure在生产环境务必使用wss://替代ws://保证视频流数据在传输过程中的安全。身份验证与授权不应在客户端代码或URL中硬编码RTSP密码。应该在客户端连接你的中转服务器时传递一个“流标识符”如camera_id。服务器端根据这个标识符从安全的配置数据库或环境中获取对应的RTSP URL和认证信息。这样即使客户端代码被查看也不会泄露摄像头密码。CDN与边缘计算如果用户分布广泛可以考虑将转码服务器部署在离用户更近的边缘节点或者使用支持RTSP拉流转WebRTC/HLS的云服务以降低网络延迟。6. 常见问题排查与实战心得在实际部署和开发中你会遇到各种各样的问题。这里记录了一些典型的“坑”和解决方法。问题1视频播放几秒后卡住不动可能原因WebSocket连接断开或FFmpeg进程卡死。排查打开浏览器开发者工具的“网络(Network)”选项卡查看WebSocket连接状态。如果断开查看关闭代码和原因。在服务器查看Node.js日志和FFmpeg进程状态。FFmpeg可能因为网络超时、编码错误而退出。解决增加FFmpeg的-stimeout、-timeout参数值。在客户端实现WebSocket断线重连机制例如在onclose事件后等待2秒重连。在服务端加强FFmpeg进程的异常监控和自动重启。问题2画面颜色异常发紫、发绿可能原因颜色格式不匹配。最常见的是YUV和RGB混淆。排查确认FFmpeg输出格式-pix_fmt与Unity中Texture2D的格式完全一致。如果FFmpeg输出yuv420p而Unity纹理是RGB24颜色必然错误。解决统一为rgb24。如果摄像头源只能是YUV要么在服务端用FFmpeg转成RGB-pix_fmt rgb24要么在Unity Shader中编写YUV转RGB的代码。问题3延迟感觉很高1秒可能原因链路中缓冲过多。排查与解决服务端FFmpeg已提及的-rtsp_transport tcp、-fflags nobuffer、-avioflags direct、-flags low_delay等参数组合使用。传输环节确保使用WebSocket而非HTTP轮询。检查网络本身是否有高延迟或丢包。客户端Unity确保纹理更新Apply在每帧中尽快完成避免在Update中做耗时操作阻塞渲染。考虑使用Coroutine或JobSystem进行异步纹理更新。问题4多路播放时页面卡顿甚至崩溃可能原因内存或CPU占用过高。解决降低规格这是最直接有效的方法。降低分辨率如640x360、降低帧率如15fps。按需加载非当前标签页或最小化的视频窗口暂停其数据接收和渲染。升级方案如前所述将Base64传输优化为直接内存拷贝HEAPU8。硬件加速确保浏览器启用了硬件加速通常默认开启。在Unity Player Settings中尝试启用Graphics Jobs实验性可能对多纹理更新有帮助。个人实战心得从简单开始先用MJPEG方案快速验证整个管道服务器拉流 - Unity显示是通的。然后再切换到更复杂但延迟更低的WebSocket原始数据方案。日志是你的朋友在服务器端详细记录FFmpeg的启动参数、标准输出和错误输出。在Unity端用Debug.Log输出关键事件连接成功、收到数据长度、纹理更新耗时。当出现问题时这些日志是唯一的线索。测试测试再测试用不同的网络环境局域网、高延迟公网测试。用不同的RTSP源海康、大华、宇视等不同品牌摄像头以及VLC模拟的RTSP服务器测试。兼容性工作往往比核心开发更耗时。备选方案永远要有Plan B。对于某些实在无法直接播放的RTSP流可以考虑在摄像头同局域网内部署一个轻量级服务如上面的Node.js服务将其转换为HTTP-FLV或WebRTC再由你的中心服务器或客户端连接。WebRTC的延迟可以做到比WebSocket方案更低但实现复杂度也更高。通过以上三步——搭建转码中继、Unity客户端渲染、深度性能调优——你就能在Unity WebGL中构建出一个稳定、低延迟的多路RTSP实时监控系统。这个过程充满了挑战但当你看到来自真实世界的视频流流畅地呈现在自己构建的3D数字孪生场景中时那种成就感是完全值得的。