简介:围绕Unity WebGL端播放海康设备监控实时画面这一需求,这份demo源码面向具备一定Unity/C#基础、需要把安防视频流接入网页端的开发者,重点处理WebGL平台下的视频播放与HTTP请求适配。工程重写了Http请求模块,并附带AVProVideo无水印版本的WebGL发布设置指导配置,使用时只需手动修改Http请求配表字段中的值,即可发布并在WebGL环境正常运行;本地服务采用nginx部署,便于对照联调。压缩包共701个文件、约80.17MB,含99个cs脚本、30个asset配置、28个so与26个dll插件库、25个shader、14个aar安卓依赖,以及mp4示例视频、png贴图和txt说明等,按脚本、插件、配置与示例资源分类型组织,覆盖运行所需的代码、插件与资源。目前已有1215人学习,可用于快速搭建监控画面网页播放原型;对需要把海康摄像头实时画面嵌入Web管理后台、数字孪生或监控大屏的项目,也可借鉴其中的请求改造与发布配置思路。
1. 浏览器里黑掉的那路监控画面,是这次要解决的全部问题
去年帮一个厂区做三维可视化看板,甲方要求在车间模型旁边贴一路实时监控。编辑器里怎么调都正常,AVProVideo 拉个地址就出画面;打包成 WebGL 丢到浏览器,画面直接黑掉,控制台安静得可怕。翻了一晚上才确定原因:AVProVideo 在 WebGL 平台底下用的是浏览器原生video元素,rtsp://开头的地址它压根不认。这份 demo 干的就是把这条断掉的链路补上——摄像机的码流先被转成浏览器认识的 HLS,AVProVideo 在 WebGL 下把它播出来,Unity 侧只保留 C# 控制播放、切流和状态回传。适合正在做 WebGL 可视化大屏、需要在三维场景里贴监控画面的开发者,也适合刚从编辑器打包到浏览器、被这堵墙正面撞过的人。
2. 播放链路与技术选型:RTSP 到 Canvas 中间到底缺了什么
2.1 四段式链路:从摄像机镜头到 Unity 纹理
很多人以为播放失败是插件没配好,实际上是链路上少了两段。完整走通的一条链路是这样的:网络摄像机(IPC)或录像机(NVR)输出的 RTSP 码流先被一台转流服务拉走,服务端把它切成 HLS 的.m3u8索引加一串.ts切片,放到一个静态文件目录里;浏览器加载 m3u8,由 HTML5video元素负责解码;AVProVideo 的 WebGL 实现把这个video元素的每一帧上传成 Unity 的Texture2D;最后RawImage或挂在模型上的MeshRenderer把它画出来。
这中间最容易被忽略的是「转流服务」这一段。它可以是单独一台机器上跑的 FFmpeg 进程,也可以是厂商自带的流媒体网关,或者在项目里塞一个轻量的流媒体服务。不管是哪种,职责都只有一个:把 Unity 和浏览器都不认识的 RTSP,翻译成浏览器能解的东西。
我把这段链路画在项目 README 的第一行,因为后面所有问题——黑屏、延迟、跨域、内存涨——都能在这四段里找到归属。哪一段断的,就去哪一段查,比在 Unity 控制台里瞎翻效率高得多。
一个具体的地址形态是这样的:摄像机侧是rtsp://user:pass@10.0.0.21:554/Streaming/Channels/101,转流后浏览器拿到的是http://10.0.0.30:8080/live/ch01.m3u8。两者端口、协议、路径全变了,Unity 里配置的必须是后者。我见过有人直接把摄像机的 RTSP 地址填进MediaPlayer,然后在论坛上问为什么 WebGL 平台报错,答案就在这。
2.2 AVProVideo 在 WebGL 上的真实能力边界
AVProVideo 这个插件的价值在于「一套 API,多平台实现」。MediaPlayer.OpenMedia()、Play()、Control.Seek()这些调用在 Windows、Android、iOS、WebGL 上写法一致,这是它被大量项目选用的原因。但底层实现是分平台的:Windows 和编辑器走系统解码器,Android 走MediaPlayer,iOS 走AVPlayer,而 WebGL 走的是浏览器<video>。
这就决定了 WebGL 平台的能力上限,不是 AVProVideo 给的,是浏览器给的:
| 能力 | Windows / 编辑器 | WebGL |
|---|---|---|
| RTSP 直拉 | 支持 | 不支持 |
| RTMP | 支持 | 不支持 |
| HLS(m3u8) | 支持 | 支持(Chrome、Edge、Safari) |
| MP4(H.264 + AAC) | 支持 | 支持 |
| H.265 / HEVC | 支持 | 基本不支持,兼容性极差 |
| 任意位置 Seek | 支持 | 仅限已缓冲区间 |
| 音频自动播放 | 无限制 | 受浏览器自动播放策略约束 |
看清这张表,很多「编辑器好好的、打包就不行」的玄学就有解释了:不是插件坏了,是同一行 C# 代码在两个平台上走了完全不同的实现路径。所以我在项目里做了一件小事——把所有跟平台相关的判断收在一个类里,而不是散落在各个脚本。
// MonitorPlatform.cs using UnityEngine; using RenderHeads.Media.AVProVideo; public static class MonitorPlatform { // WebGL 下浏览器只认 http(s) 的 m3u8 / mp4 public static bool IsBrowserCompatible(string url) { #if UNITY_WEBGL && !UNITY_EDITOR return url.StartsWith("http://") || url.StartsWith("https://"); #else // 非 WebGL 平台可以放宽,编辑器里方便直接用 RTSP 调试 return url.StartsWith("rtsp://") || url.StartsWith("http"); #endif } // 打开前统一做一次协议校验,避免把错误地址丢给底层再排查 public static bool SafeOpen(MediaPlayer player, string url) { if (!IsBrowserCompatible(url)) { Debug.LogError($"[Monitor] 当前平台不支持该协议: {url}"); return false; } return player.OpenMedia(MediaPathType.AbsolutePathOrURL, url, autoPlay: true); } }MediaPathType.AbsolutePathOrURL表示传入的是完整 URL,而不是StreamingAssets下的相对路径,这一点在填网络地址时不能选错。autoPlay参数在 WebGL 下只是「请求自动播放」,浏览器策略仍然可以拒绝,所以它不是万能的。加这层SafeOpen的意义在于:错误在打开前就暴露,而不是等到FirstFrameReady永远不来的时候,你还在怀疑是不是转流服务挂了。
2.3 转流方案怎么选:HLS、HTTP-FLV 与 WebRTC 的取舍
转流这一步,市面上常见的三条路,差别主要在延迟和接入成本上。这个 demo 选了 HLS,不代表它是最好的,只代表它是最稳的。
| 方案 | 典型延迟 | 浏览器兼容 | Unity WebGL 接入 | 适用场景 |
|---|---|---|---|---|
| HLS(m3u8) | 5~20 秒 | 最好,Chrome/Safari/移动端都认 | AVProVideo 直接播,零改造 | 演示、看板、非强实时 |
| HTTP-FLV | 1~3 秒 | 需要 flv.js 之类的库 | 需自写 jslib 接管播放 | 中等实时要求 |
| WebRTC | 300~800 毫秒 | 需要信令与 ICE | 需自写 jslib,且涉及 STUN/TURN | 云台操控、强实时 |
HLS 延迟高的根源在切片:-hls_time 2加-hls_list_size 6意味着播放器至少要缓冲两三个切片才开始放,加上解码排队,五秒起步是常态。所以选型要先问清楚业务:如果只是在大屏上「看着有人在动」,HLS 完全够;如果要做云台控制、点击后画面必须跟着转,那就得往 WebRTC 走,AVProVideo 在这一层就退化成一个渲染容器,播放逻辑得自己用 jslib 接管。
我一般给项目留两层方案:默认 HLS 保证「一定能播出来」,把播放器接口抽象成一个IVideoSource,需要低延迟时替换实现,上层 UI 不动。这个 demo 是单文件版本,没有做抽象,但脚本组织上预留了这个位置——后面第 6 章会讲怎么改。
3. 工程结构拆解:AVProVideo、场景与地址配置怎么组织
3.1 资源包目录与关键文件清单
把包解开,跟播放直接相关的其实就那么几个目录,其余的(Demo 场景、贴图、预制体)大部分可以删掉瘦身。
| 路径 | 作用 | 是否可删 |
|---|---|---|
Assets/AVProVideo/ | 插件主体,运行时脚本与材质 | 否 |
Assets/AVProVideo/Plugins/WebGL/ | WebGL 平台的.jslib桥接文件 | 否 |
Assets/AVProVideo/Resources/ | 默认着色器与播放器材质 | 否 |
Assets/AVProVideo/Demos/ | 官方示例场景 | 可删 |
Assets/Scripts/MonitorPlayer.cs | 单路播放控制 | 否 |
Assets/Scripts/VideoWall.cs | 多路拼接与切换 | 否 |
Assets/Config/monitor.json | 摄像头名称与流地址映射 | 否 |
Assets/Scenes/Main.unity | 启动场景 | 否 |
ProjectSettings/ | 打包参数(含 WebGL 模板) | 否 |
.jslib这个目录要特别留意。它是一个 JavaScript 文件,Unity 打包时会把它合并进生成的页面里,C# 通过[DllImport("__Internal")]就能调到里面的函数。AVProVideo 自带的这份文件负责创建和管理video元素、把帧数据取回来,正常情况下不用动;但一旦你要接 WebRTC 或者自己控制播放器的生命周期,改的就是它。
3.2 场景层级:一只 MediaPlayer 撑起播放
场景结构刻意做得扁平,因为 WebGL 下层级越深,Canvas 重绘和纹理上传的负担越明显:
Main (Scene) ├── Main Camera ├── Directional Light ├── Canvas (Screen Space - Overlay) │ └── VideoPanel │ ├── RawImage_Main // 主画面,贴 MediaPlayer 的纹理 │ └── RawImage_Thumb x4 // 四路缩略 ├── MediaPlayer_Main // 挂 MediaPlayer 组件的空物体 └── VideoWallController // 挂 VideoWall.cs,统一调度MediaPlayer组件挂在一个空物体上,本身不参与渲染,它只负责解码和提供纹理。RawImage拿到的纹理来自mp.TextureProducer.GetTexture(),这一步在 UI 层做,比挂到 3D 模型上省事——Screen Space - Overlay模式下不需要额外相机,也不会被场景光照影响。如果确实要贴在三维模型表面,把RawImage换成MeshRenderer+AVProVideo/Unlit材质,然后调mediaPlayer.ApplyToMeshRenderer()。
这里有个细节值得说:MediaPlayer一定要独立成物体,不要挂在Canvas下面。WebGL 下Canvas一旦被重建(比如分辨率变化、UI 缩放调整),挂在下面的组件有可能被重新初始化,播放就断了。我吃过这个亏,之后所有播放器对象都提到场景根节点。
3.3 流地址配置:别把 URL 写死在脚本里
demo 里每个摄像头都写在monitor.json里,加载后用名称去查,而不是在 Inspector 上一个一个拖。原因很现实:现场调试时地址经常变,写死在预制体里,改一次就要重新打包一次 WebGL,一次十几分钟。
{ "streams": [ { "id": "cam-01", "name": "一号门", "url": "http://10.0.0.30:8080/live/ch01.m3u8" }, { "id": "cam-02", "name": "二号门", "url": "http://10.0.0.30:8080/live/ch02.m3u8" }, { "id": "cam-03", "name": "车间东", "url": "http://10.0.0.30:8080/live/ch03.m3u8" } ] }加载逻辑放在StreamingAssets下:WebGL 平台不能用File.ReadAllText读流式目录,得走UnityWebRequest,这是另一个新手常踩的点。
using System.Collections; using UnityEngine; using UnityEngine.Networking; public class StreamConfig : MonoBehaviour { public StreamEntry[] Streams { get; private set; } public IEnumerator Load() { // WebGL 下 StreamingAssets 必须走网络请求,不能用 System.IO string path = System.IO.Path.Combine(Application.streamingAssetsPath, "monitor.json"); using (var req = UnityWebRequest.Get(path)) { yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { Debug.LogError($"[Config] 读取失败: {req.error}"); yield break; } var wrapper = JsonUtility.FromJson<StreamWrapper>(req.downloadHandler.text); Streams = wrapper.streams; } } [System.Serializable] public class StreamWrapper { public StreamEntry[] streams; } [System.Serializable] public class StreamEntry { public string id; public string name; public string url; } }Application.streamingAssetsPath在 Windows 下是本地目录、在 WebGL 下是网站上的一个 URL,UnityWebRequest是唯一能同时兼容两边的读法。JsonUtility要求字段名与 JSON 完全一致,且外层必须是对象而不是数组,所以这里包了一层StreamWrapper。这两点写错,返回的就是null或者空数组,而且不报错,属于典型的「黑匣子」式失败。
4. 核心代码逐段讲:从 C# 播放请求到 WebGL 的 .jslib 桥接
4.1 C# 侧:打开、首帧、错误三件事
播放控制的核心就三个点:什么时候打开、首帧到了做什么、出错了怎么报。其余的音量、暂停、重启都是在这个骨架上加。
using RenderHeads.Media.AVProVideo; using UnityEngine; using UnityEngine.UI; public class MonitorPlayer : MonoBehaviour { [SerializeField] private MediaPlayer mediaPlayer; // 场景里独立的空物体 [SerializeField] private RawImage screen; // 显示面 [SerializeField] private string streamUrl; // 从 StreamConfig 注入 private void Awake() { // 事件必须先注册,OpenMedia 是异步的,首帧可能在下一帧就到 mediaPlayer.Events.AddListener(OnMediaEvent); // WebGL 下强烈建议静音起播,否则被浏览器自动播放策略拦掉 mediaPlayer.Control.SetVolume(0f); } public void Play(string url) { streamUrl = url; if (!MonitorPlatform.SafeOpen(mediaPlayer, url)) return; } private void OnMediaEvent(MediaPlayer mp, MediaPlayerEvent.EventType type, ErrorCode code) { switch (type) { case MediaPlayerEvent.EventType.Started: Debug.Log($"[Monitor] 开始播放 {streamUrl}"); break; case MediaPlayerEvent.EventType.FirstFrameReady: // 首帧到达才有纹理,早取会拿到 null screen.texture = mp.TextureProducer.GetTexture(); break; case MediaPlayerEvent.EventType.Error: Debug.LogError($"[Monitor] 播放错误 code={code} url={streamUrl}"); break; } } private void OnDestroy() { // 不解除监听,切场景后回调会指向已销毁对象 mediaPlayer.Events.RemoveListener(OnMediaEvent); } }Events.AddListener必须在OpenMedia之前,因为打开是异步的,FirstFrameReady可能在打开后的下一帧就触发,注册晚了就永远收不到,画面一直空着。SetVolume(0f)是为了绕开浏览器的自动播放限制——Chrome 对带声音的自动播放一律拦截,静音则放行,播起来之后再根据用户操作恢复音量,这是行规。
TextureProducer.GetTexture()在首帧之前返回null,直接赋给RawImage.texture会让面板变成一块纯色,看起来像「播放器白了」,实际是取早了。至于ErrorCode,它给的是插件层面的错误码,WebGL 下常见的是网络错误和解码错误,日志里带上 URL 才能定位是哪一路挂了。
4.2 WebGL 桥接:.jslib里那几十行 JavaScript
AVProVideo 自带的 jslib 已经把<video>的创建、事件绑定、帧上传做完了,正常情况下不用碰。但如果要做低延迟方案,或者想在浏览器层面接管播放器(比如自己控制 buffer、自己处理重连),就得在这层加东西。
mergeInto(LibraryManager.library, { // C# 通过 [DllImport("__Internal")] 调用,参数是 UTF8 字符串指针 Monitor_Attach: function (urlPtr, muted) { var url = UTF8ToString(urlPtr); var v = document.getElementById('avpro-video'); if (!v) { console.warn('[Monitor] 未找到 video 元素,AVProVideo 可能尚未初始化'); return 0; } v.muted = !!muted; // 不静音大概率被自动播放策略拦下 v.autoplay = true; v.playsInline = true; // 移动端必需,否则会强制全屏 v.src = url; v.load(); return 1; }, // 浏览器层重连,比在 C# 里 close/open 更轻,不会销毁 video 元素 Monitor_Reconnect: function () { var v = document.getElementById('avpro-video'); if (!v) return 0; var src = v.src; v.src = ''; v.src = src; v.load(); return 1; } });UTF8ToString是 Unity WebGL 运行时提供的全局函数,负责把 C# 传过来的字符串指针转成 JS 字符串,不转直接当字符串用会得到一串数字。mergeInto(LibraryManager.library, {...})是 jslib 的标准写法,Unity 打包时会把这些函数合并进生成页面的全局作用域。
对应的 C# 声明:
public static class MonitorBridge { #if UNITY_WEBGL && !UNITY_EDITOR [System.Runtime.InteropServices.DllImport("__Internal")] private static extern int Monitor_Attach(string url, bool muted); [System.Runtime.InteropServices.DllImport("__Internal")] private static extern int Monitor_Reconnect(); #else private static int Monitor_Attach(string url, bool muted) => 0; // 非 WebGL 平台空实现 private static int Monitor_Reconnect() => 0; #endif public static bool Attach(string url) => Monitor_Attach(url, true) == 1; public static bool Reconnect() => Monitor_Reconnect() == 1; }DllImport("__Internal")这个写法只对 WebGL 有效,在编辑器里会直接抛DllNotFoundException。所以必须用#if UNITY_WEBGL && !UNITY_EDITOR包起来,并且给非 WebGL 平台留空实现,否则编辑器里根本跑不起来,调试体验全毁。!UNITY_EDITOR这个条件很容易漏——在编辑器里切成 WebGL 平台后,UNITY_WEBGL也是 true,不排除掉就会在编辑器里炸。
4.3 多路画面:切换时先关再开
监控墙的核心不是同时播很多路,而是「切得干净」。WebGL 下每个MediaPlayer背后都是一个真实的<video>元素,不关旧的直接开新的,旧的会一直占着解码资源,切个十来次浏览器就卡死了。
using RenderHeads.Media.AVProVideo; using UnityEngine; public class VideoWall : MonoBehaviour { [SerializeField] private MediaPlayer[] players; // 按格子顺序排列 private string[] currentUrls; private void Awake() => currentUrls = new string[players.Length]; public void Switch(int index, string url) { if (index < 0 || index >= players.Length) return; if (currentUrls[index] == url) return; // 同一路重复点击直接忽略 var p = players[index]; if (p.MediaOpened) p.CloseMedia(); // 先释放,WebGL 下这步不能省 currentUrls[index] = url; MonitorPlatform.SafeOpen(p, url); } // 页面隐藏时统一暂停,回来再恢复,省的是浏览器的解码配额 private void OnApplicationFocus(bool hasFocus) { foreach (var p in players) { if (!p.MediaOpened) continue; if (hasFocus) p.Play(); else p.Pause(); } } }CloseMedia()在 WebGL 下会把对应的video元素卸载并释放纹理,不调用的话,即使 C# 侧引用没了,浏览器的解码器也不会回收。MediaOpened属性用来判断当前是否已经打开了某路流,避免对空播放器调CloseMedia产生警告。
OnApplicationFocus那段是后来加的经验:一路 1080p 的 HLS 在 Chrome 里大约占 80~150 MB,四路同时在播,切到别的标签页时如果还继续解码,回来之后大概率看到「页面无响应」。浏览器对同时解码的视频数量是有硬上限的,一般是 4~8 个,超了就静默失败,表现是某几路莫名其妙不播——这种问题最难查,因为它不报错。
5. 联调与避坑排查:打包设置、跨域和 5 个典型翻车点
5.1 打包前必须改的几项 Player Settings
WebGL 的默认参数偏向「兼容」,对一个要长时间播放视频的项目来说不够用,下面几项是必改的:
Resolution and Presentation→ 关闭Default Canvas Width/Height的自适应缩放,改成固定比例,避免窗口 resize 时 Canvas 重建导致播放器重初始化。Other Settings→Color Space设为Linear,视频纹理在 Gamma 空间下会发灰。Publishing Settings→Compression Format选Disabled(本地部署)或Gzip(公网),Brotli在部分内网服务器的 MIME 配置上会翻车。Memory→ 初始内存给到 256 MB 以上,视频纹理是内存大户。Managed Stripping Level设为Low,MediaPlayer有不少反射调用,剥狠了就找不到类型。
打包产物是一个index.html加Build/目录,不能用file://直接打开,必须起一个 HTTP 服务。这一步绊倒过太多人。
# 在 Build 输出目录里起一个本地静态服务,端口随意 python3 -m http.server 8000 # 浏览器访问 http://localhost:8000/index.htmlpython3 -m http.server是最省事的验证方式,但它不带 CORS 头和 m3u8 的 MIME 类型,正式联调一定要换成 nginx 或者项目里的流媒体服务,否则你会以为是 Unity 的问题,实际是服务端配置的问题。
5.2 转流与静态服务的配置样板
demo 里给的转流命令只有一行,实际落地时-hls_time和-hls_list_size这两个参数要按业务调:
# RTSP 拉流转 HLS:切片 2 秒,保留最近 6 个切片(约 12 秒窗口) ffmpeg -rtsp_transport tcp \ -i "rtsp://user:pass@10.0.0.21:554/Streaming/Channels/102" \ -c:v copy -c:a aac -f hls \ -hls_time 2 -hls_list_size 6 -hls_flags delete_segments \ /var/www/live/ch01.m3u8-rtsp_transport tcp是必须加的,UDP 在跨网段时丢包严重,画面会花。-c:v copy表示视频不转码,直接复制码流,CPU 占用接近零,但前提是摄像机的编码必须是 H.264——如果是 H.265,copy出去的浏览器解不了,得改成-c:v libx264强制转码,代价是 CPU 直接拉满。102这个通道号一般对应子码流,分辨率和码率都低,用来做网页播放比主码流合适得多。
nginx 侧给 HLS 目录补上类型和跨域头:
location /live/ { add_header Access-Control-Allow-Origin *; add_header Cache-Control no-cache; types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } }m3u8的 MIME 类型不配对,Safari 会拒绝播放;ts不配对,Chrome 有时能播有时不能,表现为「同一路流,换个浏览器就好了」这种最让人抓狂的现象。Access-Control-Allow-Origin在 Unity 页面和流服务不同源时是硬性要求,浏览器会直接拦掉请求,控制台里只有一行 CORS 报错。
5.3 五条踩坑记录
第一条:编辑器里正常,打包 WebGL 后黑屏无报错。
现象是画面全黑,FirstFrameReady从不触发,控制台也没有异常。原因是配置里还留着rtsp://地址,WebGL 平台的<video>元素收到这个协议直接静默失败,不抛错。解决办法是把地址统一换成转流后的http://.../ch01.m3u8,并在SafeOpen里加协议白名单校验,打开前就拦住。
第二条:HLS 请求 404 或者被 CORS 拦截。
现象是控制台出现Access to fetch ... blocked by CORS policy,或者 m3u8 能拿到但 ts 全部失败。原因是静态服务没给.ts加跨域头,或者根路径写错了。解决办法是按上面的 nginx 配置补齐Access-Control-Allow-Origin和types映射,然后curl -I逐个确认 m3u8 和 ts 的响应头。
第三条:https 页面里拉 http 流,请求被浏览器拦下。
现象是整个播放链路在 Postman 里通,在页面里不通。原因是混合内容策略,https 页面不允许加载 http 资源。解决办法有两个:把流服务也升到 https,或者在 nginx 上做一层反向代理,让页面和流同源。后者改造成本低,我一般优先选它。
第四条:画面停在第一帧,或者按钮点了没反应。
现象是首帧出来了,之后不动,Play()也不生效。原因是浏览器自动播放策略拦截了带声音的播放,Play()的 Promise 被 reject,而 Unity 侧感知不到。解决办法是起播时静音(SetVolume(0f)),再通过一次用户点击(比如「开启声音」按钮)恢复音量。这条在桌面浏览器上时好时坏,在移动端是必现的。
第五条:挂十几分钟后页面卡死,或者某几路突然不播。
现象是内存曲线一路上涨,最后 Chrome 报「页面无响应」,或者四路里有两路莫名其妙黑掉。原因是切换时没调CloseMedia(),旧的video元素和解码器一直没释放,超过了浏览器的同时解码上限。解决办法是所有切换路径统一走「先CloseMedia再OpenMedia」,并且在OnApplicationFocus里暂停后台播放。
6. 二次开发:把单路 demo 改成能上监控墙的低延迟方案
这个 demo 的骨架够小,改起来不费劲,但有几个地方值得先动手再谈功能。第一步是把播放源抽象出来。现在MonitorPlayer直接依赖MediaPlayer,后面想换 WebRTC 就得改调用点;抽一个IVideoSource接口,暴露Play(string url)、Pause()、Texture Texture { get; }三个成员,HLS 实现包MediaPlayer,低延迟实现走自己写的 jslib,上层 UI 完全不用动。
public interface IVideoSource { Texture Texture { get; } void Play(string url); void Pause(); void Dispose(); }第二步是把转流服务纳入项目交付物。demo 里转流是一行 FFmpeg 命令,实际项目里这行命令活不过三天——进程挂了没人拉起来,摄像机重启后流断了不会自己接上。常见的做法是套一层进程守护,或者在流媒体服务里配好「拉流失败自动重试」,重试间隔给 3~5 秒,太短会把摄像机打挂。
第三步才是延迟优化。如果业务确实需要秒级以内,HLS 这条路再怎么调也下不来,得换 WebRTC。改造点集中在 jslib:用RTCPeerConnection替换<video>的src赋值,SDP 交换走一个简单的信令接口,然后把这个video元素交给 AVProVideo 的纹理上传流程——只要元素 ID 还是那个,Unity 侧一行不用改。这个思路我在另一个项目里验证过,可行,但调试成本比 HLS 高一个量级,不建议作为第一个版本。
还有一个容易被跳过的点:验证方法。改完之后不能只看「画面出来了」,要盯三个指标——首帧时间(从调用Play到FirstFrameReady的毫秒数)、内存曲线(连续切换 50 次后是否稳定)、断流恢复(手动停掉转流服务 10 秒再拉起,看画面能不能自己回来)。这三个指标我在每个监控类项目里都会写成一个简单的自测面板,挂在角落,上线前跑一遍。
我从那次通宵排查之后养成了个习惯:任何涉及 WebGL 播放的需求,先在浏览器里用一个裸 HTML 页面验证转流地址能不能播,确认链路通了再回 Unity 里接。这个动作五分钟,能省掉半夜三点对着黑屏猜是插件问题还是服务问题的所有时间。希望帮到你。
本文还有配套的精品资源,点击获取