简介:面向 Unity WebGL 接入海康监控实况画面的开发场景,这套 Demo 源码围绕 AVProVideo 播放链路搭建,重写了 Http 请求模块,并附带无水印版 AVProVideo 发布 WebGL 的设置指导与配置文件;按项目环境修改请求配表字段后即可构建运行,适合需要将安防摄像头实时视频嵌入网页端的 Unity/C# 开发者参考。压缩包共 701 个文件,约 80.17MB,包含 C# 脚本、Shader、Asset、DLL、AAR、配置文件及演示视频等,覆盖播放控制、请求交互与平台适配所需的基础素材。目前已有 1215 人学习下载。源码保留较完整的工程结构与配置示例,可帮助读者理解 AVProVideo 在 WebGL 下的发布设置、请求参数替换及常见联调思路,也可作为监控画面网页化展示的原型参考。
1. 从 RTSP 到浏览器:这个 Demo 把监控画面搬进 WebGL 的三段链路
前阵子接了个活,需求听着特别朴素:把厂区几路网络摄像机的实时画面,塞进一个浏览器里能打开的 Unity WebGL 大屏。我第一反应是拿个播放器组件、填上 RTSP 地址就完事,结果画面一片漆黑,控制台连个像样的报错都没有。问题不在于播放器选得好不好,而在于 WebGL 跑在 WebAssembly 和浏览器里,根本没有 RTSP 这条通路。这个 Demo 就是把这个死结解开的最小可跑样例:Unity/C# 工程加 AVProVideo 做播放层,服务端把摄像头的 RTSP 转成浏览器能吃的 HLS,最后把画面贴到 UGUI 上做监控墙。它适合正在做安防可视化大屏、Unity 转 Web、或者要在一个页面里同时看多路摄像头实时画面的开发者,尤其适合那些在 Editor 里跑得好好的、一构建到 WebGL 就翻车的人。
2. 技术选型与整体链路:为什么播放器解决不了协议问题
2.1 WebGL 播不了 RTSP 的真实原因
先把最容易误判的一点说清楚:这不是 AVProVideo 的锅,换什么播放器都一样。在 Windows/Android 上,AVProVideo 走的是平台原生的解码后端,可以直接拉 RTSP、RTMP,甚至能吃到设备私有协议。但 WebGL 平台的运行时是浏览器沙箱,Unity 编译出的 WebAssembly 模块没法自己开 TCP Socket 去连 554 端口,浏览器本身也不带 RTSP 解码器。所以 AVProVideo 在 WebGL 下的实际实现,是去操作浏览器里的一个<video>元素,能力上限就是 HTML5 video 的上限:HTTP 上的 HLS(m3u8)、渐进式 MP4,仅此而已。
这个限制往下推,会连带出几个必须提前知道的结论。第一,编码必须是浏览器能解的,H.264 + AAC 最稳,H.265/HEVC 在多数浏览器里要么不支持要么靠硬件,别赌。第二,<video>元素不认识rtsp://开头的地址,你把摄像机的原始地址填进去,它连请求都不会发。第三,音频会被浏览器的自动播放策略卡住,没有用户交互之前只能静音播放。想明白这三条,后面的架构其实是被逼出来的,不是设计出来的。
我在项目里一般把这条边界写在代码注释最上面,防止后来接手的人又去搜“WebGL 怎么播 RTSP”,绕一圈回来。
2.2 AVProVideo 在这个链路里承担的角色
既然底层就是个<video>元素,那为什么不直接用 Unity 自带的 VideoPlayer?这是个很实际的问题。自带 VideoPlayer 在 WebGL 上的能力其实也依赖浏览器,但它的错误反馈非常少,很多时候是静默失败,你只能靠猜。AVProVideo 的价值主要在三块。
一是统一的事件模型。MediaPlayer.Events会把 Opening、Started、FirstFrameReady、FinishedPlaying、Error 这些状态逐个抛出来,并且带 ErrorCode。做监控最怕的就是“某一路悄悄断了没人知道”,有了这些事件,重连逻辑才有挂载点。
二是纹理输出的多种方式。ApplyToMaterial、ApplyToMesh、DisplayUGUI几种方式在 WebGL 下行为不太一样,多路分屏的时候选择哪种直接影响性能,这个后面章节会细讲。
三是平台参数隔离。每个平台有独立的 Platform Options,WebGL 这一栏里可以单独设置,不会污染 Editor 和 Windows 的配置。
但要清楚,AVProVideo 只解决了“播放和呈现”,协议转换这一层它管不了。指望它在 WebGL 里凭空解开 RTSP,那就是把责任放错了地方。
2.3 三种转码架构的取舍
服务端把 RTSP 转成什么,是这个项目最关键的决策。下面这几种我都实际跑过,差别很大:
| 方案 | 端到端延迟 | AVProVideo WebGL 是否直接支持 | 浏览器兼容性 | 适用场景 |
|---|---|---|---|---|
| RTSP → HLS(m3u8 + ts) | 3~8 秒 | 支持,直接填 URL | 好,Safari 原生支持,Chrome 靠 hls 逻辑 | 大屏值守、非交互巡检 |
| RTSP → HTTP-FLV | 1~2 秒 | 不支持,需自己写 jslib | 需 flv 解析库 | 需要低延迟的操控类场景 |
| RTSP → WebRTC | 200~500 毫秒 | 不支持,需信令交换 | 好 | 云台控制、对讲 |
| RTSP → 定时 JPEG 快照 | 1~3 秒 | 支持(当图片轮播) | 极好 | 只看个大概、极低带宽 |
这个 Demo 选的是 HLS 路线,原因很直接:它是唯一一个“把 URL 丢给 AVProVideo 就能出画面”的方案,不需要写 JavaScript 插件,不需要做信令,工程复杂度最低。代价就是延迟,HLS 的切片机制天生带几秒缓冲,hls_time设成 1 秒也只是把延迟压到 3 秒左右,再往下压就会频繁卡顿。
所以选型时要先问清楚业务:如果是保安室墙上挂着看,5 秒延迟完全可以接受;如果是操作员看着画面按按钮,那 HLS 一定不合格,得放弃 AVProVideo 走 WebRTC + 自定义渲染。这个判断要在写第一行代码之前做完,不然后面全是返工。
2.4 完整链路拆解
整条链路是这样走的:
摄像机输出 RTSP 流,地址形如rtsp://user:pass@ip:554/Streaming/Channels/101,主码流和子码流是不同的通道号。服务端跑一个 FFmpeg 进程拉流,转成 H.264 后按秒切片,持续往磁盘目录里写index.m3u8和一堆.ts文件。Nginx 把这个目录当静态资源暴露出去,同时补上 CORS 头和正确的 MIME 类型。浏览器加载页面,AVProVideo 把 m3u8 地址交给底层的<video>,浏览器自己去做切片下载和解码,解出来的帧再通过 WebGL 纹理回传给 Unity。Unity 侧拿到纹理,贴到 RawImage 或者材质球上,最后就是你在 Canvas 里看到的那一格画面。
链路里有两个地方特别容易断:一是 FFmpeg 进程挂了没人拉起,表现是画面定格在最后一帧,看起来像网络卡;二是 Nginx 的 MIME 类型没配对,m3u8 被当成 octet-stream 下载,浏览器直接拒绝播放。这两个都会在排查章节展开。
3. 工程结构与核心脚本:一路画面从打开到上屏的过程
3.1 目录结构与模块划分
工程遵循 Unity 常规的 Assets 分层,但把“流地址配置”单独抽了出来,方便不走打包流程就能改地址:
Assets/ ├── AVProVideo/ # 插件本体,不改动 │ ├── Scripts/ │ └── Resources/ ├── Plugins/ │ └── WebGL/ │ └── VideoBridge.jslib # 自定义 JS 桥,二次开发用 ├── Scenes/ │ └── MonitorWall.unity # 主场景,一个 Canvas + 四宫格 ├── Scripts/ │ ├── Player/ │ │ ├── CameraStreamPlayer.cs # 单路播放器,负责打开/重连 │ │ └── VideoWallController.cs # 多路调度与轮询 │ ├── Config/ │ │ └── StreamConfig.cs # ScriptableObject,存地址列表 │ └── UI/ │ └── ChannelCell.cs # 每格画面的状态角标 ├── StreamingAssets/ │ └── streams.json # 运行时可读的流地址表 └── Settings/ └── StreamConfig.asset把地址放在StreamingAssets/streams.json是有意为之。WebGL 打包之后,改 C# 就得重新构建,但改一个 json 只要重新上传文件。现场调试的时候,摄像机的 IP 地址和密码经常变,这个设计能省掉大量重新打包的时间。
{ "channels": [ { "id": "cam01", "name": "东门", "url": "https://media.example.internal/hls/cam01/index.m3u8" }, { "id": "cam02", "name": "西库", "url": "https://media.example.internal/hls/cam02/index.m3u8" }, { "id": "cam03", "name": "车间", "url": "https://media.example.internal/hls/cam03/index.m3u8" } ], "switchInterval": 10 }switchInterval是轮询切换的秒数,四宫格里有一格做主画面轮播时用得到。读取这个文件要用 UnityWebRequest,因为 WebGL 下没有直接的 File IO,这一点后面避坑章节会讲。
3.2 场景与组件绑定关系
主场景里,Canvas 下挂四个 RawImage,每格是一个ChannelCell,内部引用一个DisplayUGUI组件(AVProVideo 自带)和一个MediaPlayer组件。DisplayUGUI的Player字段指向同格的MediaPlayer,Texture属性不用手动设,插件运行时自己填。
这里有个容易搞混的点:DisplayUGUI和ApplyToMaterial两种呈现方式在 WebGL 下的开销不一样。DisplayUGUI走的是 UGUI 的合批,四路画面理论上能进同一个 drawcall 批次(实际上因为纹理不同会拆开);ApplyToMaterial是直接把纹理塞进材质,适合做 3D 物体上的屏幕。做监控墙用DisplayUGUI更省事,也不容易出现 UV 翻转的问题。
MediaPlayer上要显式勾选Auto Open关掉、Auto Play关掉,因为开流时机要由脚本控制,否则场景一加载就四路同时拉流,弱网环境下必挂。
3.3 核心脚本:单路播放器与重连策略
这是整个 Demo 里最值得逐行看的一段,负责一路流从打开、异常、重连的完整生命周期:
using System.Collections; using UnityEngine; using RenderHeads.Media.AVProVideo; public class CameraStreamPlayer : MonoBehaviour { [SerializeField] private MediaPlayer mediaPlayer; [SerializeField] private string streamUrl; [SerializeField] private float retryInterval = 3f; // 重连间隔,秒 [SerializeField] private int maxRetry = 0; // 0 表示无限重试 private int _retryCount; private bool _opened; private void OnEnable() { // 事件必须先注册再打开,否则会错过 FirstFrameReady mediaPlayer.Events.AddListener(OnMediaEvent); OpenStream(); } private void OnDisable() { mediaPlayer.Events.RemoveListener(OnMediaEvent); StopAllCoroutines(); mediaPlayer.CloseMedia(); _opened = false; } public void SetUrl(string url) { if (streamUrl == url) return; streamUrl = url; mediaPlayer.CloseMedia(); _opened = false; OpenStream(); } private void OpenStream() { if (_opened || string.IsNullOrEmpty(streamUrl)) return; _opened = true; // 第二个参数传绝对 URL,WebGL 下必须带 http(s) 协议头 mediaPlayer.OpenMedia(MediaPathType.AbsolutePathOrURL, streamUrl, true); } private void OnMediaEvent(MediaPlayer mp, MediaPlayerEvent.EventType type, ErrorCode code) { switch (type) { case MediaPlayerEvent.EventType.Started: _retryCount = 0; // 成功开流才清零,避免死循环刷日志 break; case MediaPlayerEvent.EventType.Error: Debug.LogWarning($"[Stream] {streamUrl} error: {code}"); _opened = false; StartCoroutine(RetryRoutine()); break; case MediaPlayerEvent.EventType.FinishedPlaying: // 直播流正常不会结束,走到这里说明服务端切片断了 _opened = false; StartCoroutine(RetryRoutine()); break; } } private IEnumerator RetryRoutine() { if (maxRetry > 0 && _retryCount >= maxRetry) { Debug.LogError($"[Stream] {streamUrl} 超过重试上限,停止重连"); yield break; } _retryCount++; yield return new WaitForSeconds(retryInterval); OpenStream(); } }逻辑上分三段看。第一段是生命周期绑定,OnEnable里先注册事件再开流,这个顺序不能反,因为 WebGL 下<video>加载很快,本地缓存命中时几乎瞬间就触发FirstFrameReady,晚注册就永远收不到这个回调,画面虽然出来了但你的 UI 状态角标一直是“加载中”。OnDisable里必须CloseMedia(),否则切场景后旧的<video>元素会残留在 DOM 里继续吃带宽,多切几次标签页就卡死了,这个坑后面还会提。
第二段是OpenMedia的三个参数。第一个MediaPathType.AbsolutePathOrURL,表示路径按绝对地址处理,WebGL 下必须用这个;如果你用RelativeToStreamingAssets,插件会拼出一个StreamingAssets/前缀,浏览器直接 404。第二个是 URL 本身,必须带https://,WebGL 构建在 HTTPS 页面上时不允许加载 HTTP 资源,混合内容会被浏览器拦截。第三个参数autoPlay传 true,HLS 是直播流,打开就该播。
第三段是重连。这里的关键设计是_retryCount只在Started事件里清零,而不是在发起重连时清零。如果写在发起处,那么“打开失败 → 重连 → 又失败”这个循环里计数永远归零,maxRetry形同虚设,日志会被刷爆,浏览器控制台几秒钟就滚出上千行。这个细节我调了半天才发现。
参数调整上,retryInterval默认 3 秒是经验值。设太小(0.5 秒)会造成服务端还没恢复就反复请求,Nginx 日志一片 404;设太大(30 秒)现场恢复后画面要等半分钟才回来,值班的人会以为系统坏了。
3.4 多路调度:轮询换源而不是销毁重建
四宫格里有一格做主画面轮播,每 10 秒换一路。最直觉的写法是销毁旧的播放器、实例化新的,但这在 WebGL 下是性能灾难——每次销毁重建都会在 DOM 里留下一个<video>,GC 又回收不掉,跑十分钟标签页就崩。
正确的做法是复用同一个MediaPlayer,只换 URL:
public class VideoWallController : MonoBehaviour { [SerializeField] private CameraStreamPlayer mainPlayer; [SerializeField] private float switchInterval = 10f; private List<StreamEntry> _channels = new List<StreamEntry>(); private int _cursor; private IEnumerator Start() { yield return LoadStreamsFromJson(); // 协程读取 StreamingAssets if (_channels.Count == 0) yield break; while (true) { var entry = _channels[_cursor % _channels.Count]; mainPlayer.SetUrl(entry.url); // 复用播放器,只换地址 _cursor++; yield return new WaitForSeconds(switchInterval); } } private IEnumerator LoadStreamsFromJson() { string path = Path.Combine(Application.streamingAssetsPath, "streams.json"); using var req = UnityWebRequest.Get(path); yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { Debug.LogError($"[Wall] 读取配置失败: {req.error}"); yield break; } var cfg = JsonUtility.FromJson<StreamConfigRoot>(req.downloadHandler.text); _channels.AddRange(cfg.channels); } }SetUrl里做的事是CloseMedia()再OpenMedia(),中间没有对象销毁。CloseMedia会让插件把底层的<video>元素src清空,浏览器就停止下载切片了,这是控制内存的关键动作。如果不调CloseMedia直接换 URL,某些浏览器版本上旧的视频元素还在后台缓冲,四路轮播转一圈就是四个僵尸<video>。
读配置这段用的是UnityWebRequest而不是File.ReadAllText。在 Editor 和 Windows 上File.ReadAllText能跑,但 WebGL 下Application.streamingAssetsPath是个 URL,直接 File IO 必然抛异常。这个差异是 Unity 转 Web 最常见的翻车点之一,写代码时就得养成习惯。
4. 跑起来:从拉流转码到 WebGL 构建的完整步骤
4.1 服务端:FFmpeg 把 RTSP 切成 HLS
先确认摄像机本身能出流,用 VLC 或者 ffplay 拉一下原始地址,能出画面再往下走。然后准备转码脚本,每路摄像机一个进程:
#!/usr/bin/env bash set -euo pipefail CAM_ID="cam01" RTSP_URL="rtsp://operator:pass@192.168.1.64:554/Streaming/Channels/101" OUT_DIR="/var/www/hls/${CAM_ID}" mkdir -p "${OUT_DIR}" exec ffmpeg -hide_banner -loglevel warning \ -rtsp_transport tcp \ -i "${RTSP_URL}" \ -an \ -c:v libx264 -preset veryfast -tune zerolatency \ -profile:v main -level 4.0 -pix_fmt yuv420p \ -r 25 -g 25 -keyint_min 25 -sc_threshold 0 \ -b:v 1200k -maxrate 1200k -bufsize 2400k \ -f hls \ -hls_time 1 \ -hls_list_size 3 \ -hls_flags delete_segments+omit_endlist \ -hls_segment_filename "${OUT_DIR}/seg_%05d.ts" \ "${OUT_DIR}/index.m3u8"几个参数必须解释清楚,调错了直接影响能不能播。
-rtsp_transport tcp强制走 TCP 拉流。默认是 UDP,局域网里看着没问题,一旦跨网段丢包,H.264 的 P 帧参考链一断,画面就是大片马赛克加花屏,而 FFmpeg 日志里什么都不报,属于典型的玄学故障。改成 TCP 后延迟会略高一点点,但画面稳定得多。
-an丢掉音频。监控场景基本不需要声音,而且音频转码会额外占 CPU,切片的 m3u8 里带音轨还会让浏览器的自动播放策略更严格。
-g 25 -keyint_min 25 -sc_threshold 0这三个是绑在一起用的。-g是 GOP 长度,必须等于帧率乘以hls_time,这里 25fps × 1s = 25。-sc_threshold 0关掉场景切换自动插关键帧,否则切片边界和关键帧对不上,播放器每次跨切片都要等下一个 I 帧,表现出来就是画面每隔几秒顿一下。这是 HLS 调优里最容易漏的一步。
-hls_list_size 3让 m3u8 里只保留 3 个切片,配合delete_segments自动删旧文件,磁盘不会涨。3 个切片对应 3 秒窗口,够播放器缓冲,也不会让延迟堆太高。
-hls_flags omit_endlist告诉播放器这是直播流,别等播放列表结束。少了这个标志,某些浏览器会认为流已经播完,直接触发FinishedPlaying,你的重连逻辑就会被误触发。
要把进程守住,用 systemd 或者 supervisor 都行,关键是配Restart=always。FFmpeg 遇到摄像机重启、网络抖动会自己退出,没有守护的话表现就是画面停住不动。
提示:新版 FFmpeg 里 RTSP 的连接超时参数从
-stimeout改成了-timeout,启动报Unrecognized option就换一下参数名,这个变更坑过不少人。
4.2 Nginx 暴露切片目录并补上响应头
HLS 本质就是静态文件,Nginx 直接托管就行,但两个头必须补:
server { listen 443 ssl; server_name _; ssl_certificate /etc/nginx/cert/fullchain.pem; ssl_certificate_key /etc/nginx/cert/privkey.pem; location /hls/ { root /var/www; add_header Access-Control-Allow-Origin "*" always; add_header Cache-Control "no-cache" always; types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; } default_type application/octet-stream; } }Access-Control-Allow-Origin是必须的。WebGL 构建产物一般部署在和媒体服务不同的域名或端口上,浏览器会把 m3u8 请求当成跨域请求,没有这个头,控制台会直接报 CORS 错误,AVProVideo 收到的是 Error 事件但拿不到具体原因。
types块里那两个 MIME 映射也不能省。Nginx 默认的 mime.types 里通常没有.m3u8,会退化成application/octet-stream,浏览器拿到这个类型不会当成播放列表处理,表现就是“请求成功了但没画面”。这个坑的迷惑之处在于 Network 面板里状态码是 200,看起来一切正常。
Cache-Control: no-cache是给 m3u8 用的,切片本身可以缓存,但播放列表每次都要重新拉,否则播完 3 个切片就卡住不动了。
4.3 Unity 侧配置清单
服务端跑通后,Unity 这边按下面的表逐项确认:
| 配置项 | 位置 | 值 | 说明 |
|---|---|---|---|
| Auto Open | MediaPlayer 组件 | 取消勾选 | 由脚本控制开流时机 |
| Auto Play | MediaPlayer 组件 | 取消勾选 | 同上 |
| Platform Options → WebGL | MediaPlayer 组件 | 启用 | 不启用会沿用默认平台设置 |
| WebGL 下 Allow Unsafe | Player Settings | 视需求 | 涉及 jslib 时按需开 |
| Compression Format | Player Settings | Brotli | 体积小,但要服务端配 Content-Encoding |
| Color Space | Player Settings | Gamma | WebGL 下 Linear 会额外增加纹理开销 |
| Strip Engine Code | Player Settings | 谨慎开启 | 反射调用的类型被裁掉会静默失败 |
Platform Options这一栏特别值得注意。插件会按平台分别存配置,你在 Editor 里调好的参数,切到 WebGL 平台后是另一套,很多人构建出来发现“参数怎么不对”,其实是根本没勾选启用 WebGL 覆盖。
Compression Format选 Brotli 能得到最小的包体,但要求 Nginx 对.unityweb文件返回Content-Encoding: br。这一步没配好,加载页面会卡在 90% 然后报解压错误,而错误信息往往只有一行,不太好定位。
4.4 构建产物与本地预览
构建完成后不要双击index.html打开,file://协议下 WebGL 的加载器完全跑不起来。起一个本地静态服务:
# 在 Build 目录的上一级执行,注意 .unityweb 的 MIME 需要单独处理 python3 -m http.server 8080 --directory ./Build这个命令只适合快速验证画面通不通。http.server不会给.unityweb.br加Content-Encoding头,如果你开了 Brotli 压缩,这里大概率加载失败。真正的验证还是丢到 Nginx 上,或者用带压缩支持的静态服务器。我在本地一般会直接改 Nginx 配置指向构建目录,虽然麻烦点,但和线上环境一致,少一轮“本地好的线上不行”。
浏览器打开后按 F12,重点看三件事:Network 里 m3u8 请求的状态和响应类型是不是application/vnd.apple.mpegurl;Console 里有没有 CORS 或混合内容告警;Elements 里能不能找到一个<video>元素并且它的src指向你的流地址。这三条能覆盖绝大多数“黑屏”问题。
5. 常见问题排查:黑屏、高延迟、内存涨、跨域这四类我全踩过
5.1 现象:页面出来了,四宫格全是黑的,控制台没有任何报错
这是最常见也最费时间的一类。原因通常有三个层次。
第一层,m3u8 地址压根没请求出去。表现是 Network 面板里干干净净。原因多半是OpenMedia的路径类型传错了,用了相对路径,或者 URL 少了协议头。解决办法是在OpenStream里把完整 URL 打出来,复制到浏览器地址栏直接访问,能下下来一个文本文件才算通。
第二层,m3u8 请求了但被拦截。Network 里状态是 200,但 Response 是空的或者被标红。这时候看 Console,八成是 CORS 或者 HTTPS 页面加载 HTTP 资源被拦。解决办法是服务端补Access-Control-Allow-Origin,以及媒体服务也上 HTTPS。
第三层,请求正常、切片也在下,就是不出画面。这种最隐蔽,原因通常是<video>元素被创建了但尺寸是 0,或者它的display被设成了none。AVProVideo 在 WebGL 下依赖这个元素做解码,如果它被隐藏,某些浏览器会暂停解码。解决办法是检查有没有自己写的 CSS 或者 jslib 动过 video 元素。
注意:
Error事件的 ErrorCode 在 WebGL 下比较粗糙,很多时候就是一个笼统的 LoadFailed,不要指望它告诉你具体原因,还是得靠浏览器开发者工具。
5.2 现象:Editor 里播得好好的,构建成 WebGL 就黑屏
这个几乎是 Unity 转 Web 的必经之路。最常见的原因是资源路径处理方式不同。Editor 下File.ReadAllText能读到 StreamingAssets 里的任何文件,WebGL 下这条路完全不通,必须换成UnityWebRequest。如果你的配置加载写在Awake里用同步 IO,构建后必然抛异常,而这个异常在某些版本里不会打印到浏览器控制台。
第二个原因是插件版本和平台后端不匹配。AVProVideo 的平台支持是按模块编译的,如果打包时 WebGL 模块没被包含进来,运行时会静默降级成一个空实现,表现就是“没有报错但也没有画面”。检查方法是看构建日志里有没有相关模块的编译记录。
第三个原因是线程。WebGL 不支持多线程,任何System.Threading.Thread、Task.Run、async/await里的ConfigureAwait(false)都会出问题。项目里如果有拉流的心跳检测写了后台线程,构建后直接卡死。解决办法是全部改成协程,用WaitForSeconds做定时。
5.3 现象:画面能播,但延迟有七八秒,值班的人说“人都走过去了画面才动”
HLS 的延迟是结构性的,但七八秒说明配置还有优化空间。先看hls_time,设成 1 秒是下限了,设成 2 秒以上延迟必然 6 秒起。再看hls_list_size,设成 5 意味着播放器至少要缓冲 5 个切片才开始播,直接多出 5 秒。改成 3 是比较平衡的值。
其次看播放器端的缓冲策略。AVProVideo 在 WebGL 下对<video>的控制有限,但可以在 Platform Options 里调整起播缓冲相关参数。另外确认摄像机的码流选的是子码流而不是主码流,主码流通常是 4K 或 1080p 高码率,浏览器解码加上网络传输,缓冲会自己往上涨。
还有一个隐蔽原因:GOP 和切片长度没对齐。前面说过-g必须等于帧率乘hls_time,如果摄像机是 30fps 而你写死了-g 25,每个切片边界都落在非关键帧上,播放器每次跨切片都要等下一个 I 帧,累积起来就是好几秒的额外等待。
5.4 现象:跑十几分钟,浏览器标签页内存飙到 1G 以上最后崩掉
WebGL 的内存上限比原生平台紧得多,32 位地址空间加上浏览器的额外开销,实际可用量比想象中小。三个地方最容易漏。
一是切换流的时候没调CloseMedia()。前面SetUrl那段代码里CloseMedia是必须的,它会让浏览器释放掉<video>的缓冲。只改src不关闭,旧的缓冲在某些浏览器上不会立即释放。
二是纹理没释放。四宫格如果用了ApplyToMaterial,每次换流生成的新纹理要在不用的材质上Destroy,否则 WebGL 纹理对象会一直累积。用DisplayUGUI相对安全一些,插件内部做了复用。
三是日志。Debug.Log在 WebGL 下会往浏览器控制台写,如果重连逻辑里每次都打一行,跑一晚上控制台能堆几十万行,浏览器自己的内存先撑不住。生产构建里应该把日志级别调高或者直接关掉。
5.5 现象:请求返回 200,但播放器立刻报错,Network 里 m3u8 显示为下载文件
典型的 MIME 类型问题。Nginx 没配.m3u8的映射,返回application/octet-stream,浏览器把它当成待下载的二进制文件,不会解析成播放列表。解决办法就是前面 Nginx 配置里的types块。
同类的还有.ts切片,如果被当成octet-stream,某些浏览器仍然能播(因为它靠内容嗅探),但 Safari 会比较严格。所以两个映射一起配上最稳。
还有一种变体是 CDN 或者中间层改写了响应头,本地测试好的配置上线后出问题,这时候要在浏览器 Network 里看实际的 Response Headers,而不是相信自己写的 Nginx 配置。
6. 二次开发:把播放器从“能看”改到“能控”
Demo 跑通只是起点,真正落地的时候需求会很快超出“播个画面”的范围。最典型的是三件事:想静音、想截图、想全屏。这三件事在 WebGL 下都绕不开直接操作 DOM,因为 AVProVideo 暴露的 C# API 到不了那一层。
做法是写一个 jslib 桥。放到Assets/Plugins/WebGL/下:
mergeInto(LibraryManager.library, { // 遍历页面上所有 video 元素,统一设置静音 WebGL_SetVideoMuted: function (muted) { var list = document.querySelectorAll('video'); for (var i = 0; i < list.length; i++) { list[i].muted = !!muted; } }, // 请求某一格画面全屏,按索引取,避免四宫格全屏错乱 WebGL_RequestFullscreen: function (index) { var list = document.querySelectorAll('video'); var el = list[index | 0]; if (el && el.requestFullscreen) { el.requestFullscreen(); } } });C# 侧的封装要注意平台条件编译,Editor 下DllImport("__Internal")会直接崩:
using System.Runtime.InteropServices; public static class VideoBridge { #if UNITY_WEBGL && !UNITY_EDITOR [DllImport("__Internal")] private static extern void WebGL_SetVideoMuted(bool muted); [DllImport("__Internal")] private static extern void WebGL_RequestFullscreen(int index); #endif public static void SetMuted(bool muted) { #if UNITY_WEBGL && !UNITY_EDITOR WebGL_SetVideoMuted(muted); #endif } public static void RequestFullscreen(int index) { #if UNITY_WEBGL && !UNITY_EDITOR WebGL_RequestFullscreen(index); #endif } }静音这件事不只是“用户体验”,它直接关系到能不能自动播放。浏览器的自动播放策略要求带声音的视频必须有用户交互才能播,如果你的流带了音轨又没静音,页面加载后会自动暂停,表现就是黑屏。用这个桥在启动时统一静音,能绕过一大半这类问题。
全屏按索引取是有原因的:四宫格里如果直接document.querySelector('video'),拿到的永远是第一个,点哪格都全屏第一格。用querySelectorAll加索引才对得上。
如果想进一步压延迟,那就是另一个量级的改造了:放弃 HLS,服务端换成 WebRTC 或者 HTTP-FLV,前端不再依赖 AVProVideo 的<video>通路,而是把一个 canvas 的纹理通过Texture2D.CreateExternalTexture传回 Unity。这条路线能把延迟压到 1 秒以内,但工作量大概是把整个播放层重写一遍,而且四路同时跑的时候 canvas 绘制的 CPU 占用会明显上升。判断标准很简单:如果业务方能接受 3 秒延迟,就别碰这块;如果接受不了,一开始就不该选 HLS。
验证改造是否生效的方法我习惯用两条:一是改完之后在浏览器 Console 里执行document.querySelectorAll('video').length,看看元素数量和预期是否一致,多出来就是有泄漏;二是挂机跑半小时,用浏览器的性能面板看内存曲线是不是平的,缓慢上升就说明有资源没释放。
从那以后我每次做 WebGL 播放相关的改动,都会强制走一遍“四路同时开、跑三十分钟、切十次流”的流程,哪怕只改了一个静音开关。因为这个平台上出的问题,九成不是当场能看出来的,都是跑久了才暴露。希望帮到你。
本文还有配套的精品资源,点击获取