简介:一份面向 Unity 开发者与流媒体监控集成场景的 WebGL 播放示例源码,围绕 AVProVideo(无水印版本)在浏览器端呈现海康设备实时画面展开。工程重写了 Http 请求模块,并将发布 WebGL 所需的设置整理为可参照的配置文件,使用者只需按实际环境修改请求配表中的字段值,即可完成发布运行;配套说明中也给出本地 nginx 部署服务的思路,适合需要将监控视频接入网页端的中级 Unity/C# 开发者参考。压缩包共 701 个文件,约 80.17MB,包含 99 个 C# 脚本、30 个 asset 与 28 个 so 配置、26 个 dll 插件、25 个 shader,以及 aar/jar 库、mp4/png 素材和 txt 说明等,覆盖工程结构、播放组件与 WebGL 适配内容。目前已有 1215 人学习下载,可作为监控画面网页化播放的完整示例与排错参照。
1. 需求先立住:为什么 WebGL 端播监控实时画面要先做可行性 Demo
接到「WebGL 平台里用 AVProVideo 播海康设备监控实时画面」这个需求,多数人的第一反应是把取流地址丢给播放器就完事,真正打包进浏览器才发现画面根本出不来——rtsp:// 这种地址浏览器压根不认,本地编辑器里跑得好好的场景,一进 Chrome 就是漆黑一片。这套 Demo 源码要填的正是这段空白:在 Unity/C# 侧做一层统一的播放器封装,在 WebGL 侧把浏览器自带的解码能力用起来,让网络摄像机的实时画面能被页面正常显示和控制。
它适合两类人:一类是毕设或课设要做「浏览器端监控大屏」的学生,需要一套能跑通、能答辩、结构说得清的骨架;另一类是要给现有管理后台补一路视频预览的开发者,不想从零踩一遍前端解码的坑。项目定位是「最小可行验证」——不追求做成完整安防平台,只把从设备取流到画面渲染这条主链路打通,剩下的业务逻辑由使用者自己往上长。
2. 技术选型与可行性边界:三条取流链路和 AVProVideo 的真实能力
动手写代码之前,有一件事必须先想明白:WebGL 打包出来的产物运行在浏览器里,它没有权限直接连摄像头的 RTSP。这不是 AVProVideo 的锅,也不是 Unity 的锅,是浏览器原生<video>元素只认 MP4、WebM 和 HLS 这几种格式。所以整个方案的第一个设计决策,不是「用什么播放器」,而是「流的形态怎么转换」。
2.1 三条取流链路的取舍:RTSP、HLS 与低延迟方案
先说源端。网络摄像机对外提供的实时流基本是 RTSP,常见路径形如/Streaming/Channels/101,前一位是通道号、后两位是码流号,101 表示 1 通道主码流、102 表示 1 通道子码流;接在 NVR 上时按n*100+1的规律往后排。这个地址只在服务端使用,绝不能出现在前端代码里,一个原因是浏览器不支持,另一个原因是地址里带明文的账号密码,直接暴露在前端等于把设备交出去。
中间必须加一层转流网关,把 RTSP 拉过来重新封装。市面上可选的形态有三条,各自的代价差别很大:
| 链路形态 | 端到端延迟(经验值) | 浏览器兼容性 | AVProVideo 在 WebGL 下能否直接播 |
|---|---|---|---|
| HLS(标准切片) | 6~20 秒 | 全平台原生支持 | 能,最省事 |
| LL-HLS / 缩短切片 | 2~5 秒 | 依赖浏览器版本 | 能,需要网关配合调小切片和 GOP |
| HTTP-FLV | 1~3 秒 | 需要 MSE + 额外解码库 | 不能,要绕开 AVProVideo 的播放通道 |
| WebRTC | 0.3~1 秒 | 需要信令服务 | 不能,通常要自己写 jslib 接 |
我一般会给学生和初级开发者推荐 HLS 这条线做第一版,理由很直接:AVProVideo 在 WebGL 平台下的播放通道本身就是浏览器原生 video 元素,HLS 是它最顺的一条路,改造成本最低,Demo 能在一天内出画面。如果项目对延迟有硬指标——比如要配合云台控制做实时跟手——那 HLS 这条线就废了,六秒的延迟足够让操作员把摄像头摇过头。这时候正确的做法不是硬调 AVProVideo 的缓冲参数,而是换链路:网关出 WebRTC,前端用一个自定义 jslib 把远端流渲染到 canvas,再交给 Unity 侧当纹理用。这条路工作量大一倍,但延迟能压到一秒以内。
提示:不要指望通过把 HLS 切片调到 0.2 秒来获得低延迟。切片过小会让播放器频繁发起请求,弱网下反而更容易卡死,而且很多播放器有最小缓冲策略,调下去也没效果。
2.2 AVProVideo 在 WebGL 下的能力边界
AVProVideo 是一套跨平台播放器插件,它的强项在 Windows、macOS、Android、iOS 这些原生平台上——可以直接拿到解码后的纹理,做 shader 特效、多画面拼接、透明通道都没问题。但在 WebGL 平台,它的实现方式变了:画面最终是由浏览器的 video 元素负责解码显示的,Unity 侧拿到的是一份「桥接」结果。
这个差异直接决定了几件事:
能不能贴到 3D 物体上。在原生平台,把视频当材质贴到曲面大屏、电视墙模型上是常规操作。在 WebGL 下,如果不走纹理上传路径,画面通常是 DOM 覆盖层,它浮在 canvas 上层,Unity 里任何后处理、UI 遮挡关系对它都不生效。较新的 AVProVideo 版本提供了 WebGL 纹理模式,可以把画面当纹理用,但代价是每帧要从 video 元素往 GPU 拷一次,1080p@25fps 在集显机器上就会明显掉帧。我的经验是:监控大屏这种场景,能用覆盖层就用覆盖层,别为了「看起来高级」去走纹理路径。
音频能不能自动播。浏览器的自动播放策略要求有用户手势。也就是说,页面加载完直接Play(),视频画面可能出来,但音频会被静默拦掉。监控场景本身多数是静音的,所以这个坑不明显,但如果你的 Demo 需要演示对讲,就必须加一个「点击开始」的遮罩层。
参数该动哪几个。在 MediaPlayer 组件上,WebGL 真正影响结果的参数其实不多:
Video Location设为 URL,Path填 HLS 地址;Auto Open和Auto Play建议都关掉,改成代码里显式调用,方便控制时序和失败重试;Loop关掉,直播流循环没有意义;Video Mapping选Fit,避免不同分辨率的摄像头画面被拉伸变形;Platform Options里针对 WebGL 单独覆盖路径,这样同一套 Prefab 在编辑器和浏览器里能用不同地址。
2.3 编码与分辨率的前置约束
这一条最容易被忽略,但它在联调阶段造成的排查时间最长:前端播放器挑编码。摄像机和转流网关输出的视频必须是 H.264,profile 用 Baseline 或 Main,音频用 AAC。用 H.265 的摄像头在浏览器里就是黑屏,而且不报错,只是video.error里给一个含糊的码。
我一般会在网关配置里加两条硬规则:多画面场景(4 分屏、9 分屏)统一走子码流,分辨率压到 720p、帧率 15~20;单画面放大时才切主码流 1080p。关键帧间隔设 1~2 秒,太长会让首帧等待变久,太短会让码率飙升。这几项在摄像机的 Web 配置页里都能改,改完记得重启取流通道,很多设备改完编码参数不会自动重建会话。
3. 总体架构设计:三层链路与四个模块的职责划分
把选型定下来之后,整体结构其实就浮出来了。这套 Demo 我按三层来切,每一层的职责边界划得很硬,目的是让「画面不出来」的时候能快速定位到底是哪一层的问题。
3.1 三层链路:设备接入、转流网关、播放前端
第一层是设备接入层,包含网络摄像机、NVR 和它们所在的局域网。这一层只负责出 RTSP,不参与任何业务逻辑。调试时用 VLC 之类的工具能拉到画面,就说明这一层没问题,可以直接往下走。
第二层是转流网关,跑在一个独立进程或独立机器上。它做三件事:按需拉取设备的 RTSP、重新封装成 HLS 切片、对外提供 HTTP 访问。这一层的价值不只是格式转换,它还是一个天然的隔离带——前端拿不到设备账号,设备也不用直接暴露在浏览器可达的网段。
第三层是播放前端,也就是 Unity WebGL 产物。这一层里只有两个东西:AVProVideo 的 MediaPlayer 组件,和一层包在它外面的控制器。控制器负责地址拼装、状态管理、失败重试和事件广播,UI 只跟控制器打交道,不直接碰 MediaPlayer。
这样的切法带来的好处是排查路径唯一。画面黑屏时,先看网关的切片目录有没有在增长,有就是前端问题,没有就是设备或网关问题,两边不会互相甩锅。
3.2 四个模块的职责与目录结构
在 Unity 工程里,这四个模块我用四个目录隔开,避免后期改一个功能翻遍整个工程:
Assets/ ├── Scripts/ │ ├── Core/ VideoPlayerController.cs 播放器封装与状态管理 │ ├── Net/ StreamUrlBuilder.cs 地址拼装与配置读取 │ ├── UI/ VideoWallPanel.cs 画面布局与交互 │ └── Diagnostics/ PlaybackLogger.cs 事件日志与错误上报 ├── Plugins/WebGL/ streambridge.jslib 浏览器侧兜底接口 └── StreamingAssets/ gateway-config.json 网关地址与通道映射配置外置是关键一步。把网关地址、通道号、主/子码流选择写进gateway-config.json而不是硬编码在场景里,换环境时只改一个文件,不用重新打包。这在答辩现场尤其有用——教室的网络和你实验室的网络肯定不是一个网段。
3.3 播放器封装的接口设计
控制器是整个方案的核心,它的职责是把 MediaPlayer 的一堆散装 API 收拢成三个动作:播、停、换。下面这段是骨架代码:
// VideoPlayerController.cs —— 对 AVProVideo MediaPlayer 的统一封装 using System; using System.Collections; using UnityEngine; using RenderHeads.Media.AVProVideo; public class VideoPlayerController : MonoBehaviour { [SerializeField] private MediaPlayer mediaPlayer; // 场景中预挂的播放器组件 [SerializeField] private string playerId = "ch01"; // 用于日志区分是哪一路 public event Action<string> OnFirstFrameReady; // 首帧到达,参数为 playerId public event Action<string, string> OnPlaybackFailed; // 播放失败,参数为 id 与错误码 private bool _firstFrameNotified; private void Awake() { if (mediaPlayer == null) mediaPlayer = GetComponent<MediaPlayer>(); mediaPlayer.Events.AddListener(HandleMediaEvent); } private void OnDestroy() { // 必须解绑,否则切场景后旧对象仍被事件持有,WebGL 下表现为内存只涨不降 mediaPlayer.Events.RemoveListener(HandleMediaEvent); } // 事件回调是唯一的画面状态来源,不要自己在 Update 里轮询 private void HandleMediaEvent(MediaPlayer mp, MediaPlayerEvent.EventType type, ErrorCode code) { switch (type) { case MediaPlayerEvent.EventType.FirstFrameReady: if (_firstFrameNotified) return; _firstFrameNotified = true; OnFirstFrameReady?.Invoke(playerId); break; case MediaPlayerEvent.EventType.Error: OnPlaybackFailed?.Invoke(playerId, code.ToString()); break; } } }代码里有三个设计点值得说清楚。第一,OnDestroy里必须解绑事件,这是 WebGL 端内存泄漏最常见的来源:事件委托持有控制器实例,场景切换后旧实例不会被 GC 回收,而每个实例底下都挂着一个 video 元素。第二,首帧判断用_firstFrameNotified做了一次幂等,因为有些版本的播放器在卡顿恢复后会重复抛FirstFrameReady,不去重会导致上层 UI 反复刷新。第三,失败回调里传的是ErrorCode枚举转成的字符串,不传异常对象,因为 WebGL 下异常堆栈信息很少,传对象意义不大,反而是错误码更便于在日志里做统计。
4. 关键实现:地址拼装、播放控制与 WebGL 桥接
架构定完,接下来是把三个最容易写错的地方落到代码:地址怎么拼、播放时序怎么控、浏览器侧出错怎么兜。
4.1 取流地址与页面地址的分层拼装
很多 Demo 把 URL 直接写在 Inspector 里,改成多路之后就变成一堆手填的字符串,通道一多必然填错。我的做法是用一个静态构造器把两类地址分开生成——设备侧 RTSP 只给网关看,前端侧 HLS 只给播放器看,两者不共享任何字段。
// StreamUrlBuilder.cs —— 两类地址分别生成,避免前端接触设备凭据 public static class StreamUrlBuilder { /// 设备侧 RTSP:只写进网关配置文件,绝不下发到浏览器 public static string BuildDeviceRtsp(DeviceInfo dev, int channel, bool subStream) { int streamNo = subStream ? 2 : 1; // 1=主码流 2=子码流 return $"rtsp://{dev.User}:{dev.Password}@{dev.Ip}:{dev.RtspPort}" + $"/Streaming/Channels/{channel}{streamNo:00}"; } /// 前端侧 HLS:AVProVideo 在 WebGL 下真正加载的地址 public static string BuildHlsUrl(string gatewayBase, int channel, bool subStream) { string suffix = subStream ? "sub" : "main"; return $"{gatewayBase.TrimEnd('/')}/live/ch{channel:D2}_{suffix}/index.m3u8"; } }channel{streamNo:00}这种格式化方式看起来啰嗦,但它把「通道 2 主码流 = 201」这个规则固化进了代码,而不是靠字符串手工拼。D2同理,保证通道 1 生成ch01而不是ch1——不少网关对路径大小写和位数敏感,位数不一致会直接 404,而 404 在浏览器里只表现为「一直转圈」,看不出原因。
4.2 播放时序与切流控制
WebGL 下最反直觉的一点是:换流之前必须先关。在原生平台,OpenMedia一个新的 URL 会自己处理旧会话;在浏览器里,每路播放器对应一个 video 元素,不显式关闭就换源,旧元素的解码线程会一直挂着,切三五次之后页面就开始卡。
public void PlayStream(string url) { // 换源前先关:WebGL 下每个 MediaPlayer 对应一个 <video>,残留会占用解码资源 if (mediaPlayer.MediaOpened) mediaPlayer.CloseMedia(); _firstFrameNotified = false; mediaPlayer.OpenMedia(new MediaPath(url, MediaPathType.AbsolutePathOrURL), autoPlay: false); StartCoroutine(PlayAfterOpen(0.3f)); } private IEnumerator PlayAfterOpen(float timeout) { float waited = 0f; while (!mediaPlayer.MediaOpened && waited < timeout) { waited += Time.unscaledDeltaTime; yield return null; } if (mediaPlayer.MediaOpened && mediaPlayer.Control != null) { mediaPlayer.Control.SetVolume(0f); // 监控默认静音,绕开浏览器自动播放限制 mediaPlayer.Control.Play(); } else { OnPlaybackFailed?.Invoke(playerId, "OPEN_TIMEOUT"); } }这里用autoPlay: false加显式Play(),而不是让播放器自己开,是为了把「打开成功」和「开始播放」拆成两个可观测的状态。超时用Time.unscaledDeltaTime累加而不是WaitForSeconds,因为 WebGL 下时间缩放可能被业务代码改过,用真实时间更可靠。超时阈值 0.3 秒是给OpenMedia的握手留的余量,实际首帧到达时间另算——首帧受网关切片生成速度影响,可能到两三秒,这个由OnFirstFrameReady事件来兜。
4.3 多画面布局与切换的状态机
四宫格是监控 Demo 的标配,但它比单画面复杂得多。核心矛盾在于:浏览器同时解码的路数是有限的。四路 1080p 主码流在普通办公机上就会开始掉帧,九路基本没戏。所以布局切换不能简单地「多开几个播放器」,而要跟码流选择绑在一起。
我用的规则是:布局从 1 分屏切到 4 分屏时,当前画面保留主码流,新增的三路强制走子码流;切到 9 分屏时全部降为子码流;从多分屏切回单画面时,被放大的那一路重新以主码流打开,其余播放器CloseMedia()但对象不销毁,留着下次复用。这样内存占用是一条阶梯曲线而不是锯齿曲线。
状态机只有四个状态:Idle(未开流)、Opening(已调 OpenMedia 未收到首帧)、Playing(收到首帧)、Failed(收到 Error 或超时)。UI 上的加载动画绑Opening,错误提示绑Failed,其余一律按Playing处理。加上这一层之后,用户点来点去也不会出现两个加载圈同时转的尴尬。
4.4 WebGL 端的 jslib 桥接与兜底
总有一些信息是 Unity 侧拿不到的,比如浏览器 video 元素的真实错误码。这时候需要一小段 jslib 做桥接:
// Assets/Plugins/WebGL/streambridge.jslib mergeInto(LibraryManager.library, { // 读取页面上第一个 video 元素的媒体错误码,0 表示无错误 StreamBridge_GetLastError: function () { var v = document.querySelector('video'); if (!v) return -1; // 没有 video 元素,说明播放器根本没创建成功 return v.error ? v.error.code : 0; } });C# 侧对应声明,并且一定要加平台条件编译:
#if UNITY_WEBGL && !UNITY_EDITOR [System.Runtime.InteropServices.DllImport("__Internal")] private static extern int StreamBridge_GetLastError(); // 错误码 4 通常是格式不支持,3 是解码失败,2 是网络中断 public int QueryBrowserError() => StreamBridge_GetLastError(); #endif#if UNITY_WEBGL && !UNITY_EDITOR这个条件不能省。少写!UNITY_EDITOR,编辑器里点运行就会因为找不到__Internal而抛 DllNotFoundException,而这个报错的位置和真正的问题八竿子打不着,很容易带偏排查方向。拿到错误码之后,在PlaybackLogger里做一次映射再写入日志,比直接打一个数字有用得多。
5. 联调避坑:五类最容易翻车的现场问题
前面四章讲的是「应该怎么搭」,这一章讲的是「搭完之后大概率会在哪里摔」。下面五条都是我在类似项目里真实遇到过的,按现象、原因、解决三段写,能对上号就直接抄结论。
5.1 黑屏与不出画
现象:编辑器里点运行画面正常,打包成 WebGL 用浏览器打开后一片黑,控制台也没有明显报错。
原因通常有三个。最常见的是混合内容拦截:页面通过 https 打开,而 HLS 地址是 http,浏览器会静默阻止这次请求,只在控制台留一条不显眼的警告。第二个是跨域,网关没有返回Access-Control-Allow-Origin,video 元素加载 m3u8 时被 CORS 挡下。第三个是编码问题,摄像机输出的是 H.265,浏览器解码不了,video.error.code给 4。
解决顺序是:先在浏览器开发者工具的 Network 面板里找那条 m3u8 请求,看它是被 block 还是 404 还是 200 但没后续 ts 请求。被 block 就统一协议或给网关配 CORS 头;200 但没有 ts 请求,基本就是编码问题,去摄像机配置页把视频编码改成 H.264、音频改成 AAC、关掉 B 帧,然后重启取流通道。
5.2 卡顿、内存与资源泄漏
现象:单路播放没问题,切到四宫格之后页面越来越卡,任务管理器里浏览器内存一路涨到一两个 G,最后标签页崩溃。
原因是播放器实例没有被正确释放。每次切流都new一个 MediaPlayer 或者每次都OpenMedia而不CloseMedia,底下的 video 元素就会不断堆积。浏览器的 video 元素不像普通 DOM 节点那么轻,每个都带独立的解码缓冲。
解法有三步:换源前强制CloseMedia();切布局时把不再使用的播放器gameObject.SetActive(false)而不是销毁重建,控制实例总数恒定在最大分屏数;在PlaybackLogger里每 30 秒打一次Profiler.GetTotalAllocatedMemoryLong(),跑两小时看曲线是否平稳。这套 Demo 里我把最大播放器数写成了常量 9,超过就复用而不是新建,这一条能挡掉后面八成的性能投诉。
5.3 首帧慢与延迟越跑越大
现象:点播放之后要等七八秒才出画面,而且挂着不动的时候,画面和真实场景的时间差会慢慢拉大,最后能差到十几秒。
前者是 HLS 切片机制的固有代价——播放器要等网关生成足够多的切片才开始播。后者更麻烦,通常是播放器为了抗抖动不断加大缓冲,网络一有波动就往后攒,攒了就不还。
解决首帧慢,把网关的切片时长从 6 秒调到 1~2 秒,同时缩短关键帧间隔,让每个切片都能从关键帧开始。解决延迟累积,在控制器里加一个定时检查:每 60 秒比对一次播放位置和流的实际时长,偏差超过阈值就CloseMedia()再重开一次,用一个短暂的黑屏换回低延迟。这个操作对监控场景是可接受的,毕竟看的是「现在」而不是「刚才」。
5.4 断流之后不自动恢复
现象:网络闪断一下,画面就停住了,之后再也不动,刷新页面才恢复。
原因是播放器进入了一个「僵死」状态:它认为自己还在播,但实际已经收不到数据,MediaOpened依然是 true,所以任何基于这个标志的判断都会失效。这在 WebGL 下尤其常见,因为浏览器不会主动通知 Unity 侧网络断了。
解法是加一条独立的心跳:控制器记录最后一次收到FirstFrameReady或播放位置发生变化的时间,超过 10 秒没有变化就判定为僵死,走「关—开」流程重连,重连间隔按 2、4、8 秒退避,连续五次失败才在 UI 上提示「设备离线」。这条逻辑跟播放器本身无关,纯靠时间戳判断,反而最可靠。
5.5 配置与环境错位
现象:在实验室跑得好好的工程,拷到答辩教室的电脑上就打不开,或者打开是别的摄像头的画面。
几乎都是地址硬编码和网络段不一致导致的。网关地址写死成127.0.0.1,换台机器就没有服务在监听;通道号写死成 1,换到另一套设备上就串台。
解法前面提过——配置外置到gateway-config.json,并且在启动时做一次自检:逐个检查配置里的网关地址能否 ping 通、m3u8 能否取到、通道数是否与实际一致,把结果打在屏幕上。答辩前十分钟跑一次自检,比现场手忙脚乱强得多。
6. 验收与答辩:怎么用一张表证明这套 Demo 真的跑通了
Demo 做完不等于交付完成,能拿出可复现的验收数据才算。我一般会准备一张固定表格,逐项实测填数,答辩时直接投屏。这张表的价值在于:它把「能播」这种主观判断,换成了别人可以要求你复现的客观指标。
| 验收项 | 测试方法 | 合格线(本 Demo 场景) |
|---|---|---|
| 单路首帧时间 | 点播放到画面出现,秒表计时 5 次取平均 | ≤ 3 秒(子码流) |
| 端到端延迟 | 摄像头对准秒表,截图比对画面与真实时间差 | HLS 方案 ≤ 8 秒 |
| 四宫格稳定性 | 四路同时播放,连续观察 30 分钟 | 无花屏、无单路黑屏 |
| 内存占用 | 浏览器任务管理器记录,四宫格运行 30 分钟前后对比 | 增长 ≤ 200MB 且曲线趋平 |
| 断流恢复 | 拔网线 10 秒后插回 | 30 秒内自动恢复画面 |
| 切换响应 | 1 分屏↔4 分屏来回切 10 次 | 每次 ≤ 2 秒出画,无残留画面 |
| 跨机器可用性 | 换一台电脑,只改配置文件 | 无需重新打包即可运行 |
演示流程我建议固定成三段,每段两分钟:第一段走正常路径,单画面放大、四分屏切换,证明主流程通;第二段故意制造故障,拔网线或者停掉网关进程,让画面断开再恢复,证明异常处理有做;第三段打开日志面板,把首帧时间和错误码当场展示出来,证明你不是只会点按钮。第三段最能拉开差距——老师或者面试官问的最多的就是「你怎么知道它为什么坏了」,有日志和一整套错误码映射表,这个问题就变成了你的主场。
还有一个容易被忽略的准备:离线备份。转流网关跑在一台机器上,万一现场网络不通或者设备被占用了,整个演示就崩了。我的习惯是提前用网关把四路流录成短切片放在本地,配置里留一套local环境的地址,演示前 5 分钟切过去验证一遍再切回来。这不是作弊,是把不可控因素提前关进笼子里。
坦白说,我第一次做这类演示的时候翻过车。当时所有参数都是在实验室调好的,到了现场才发现教室的路由器把组播和某些端口全挡了,折腾了四十分钟才勉强出画,讲的时候手都是抖的。从那以后我每次做跨环境演示,都强制走一遍「配置文件 + 启动自检 + 离线备份」这三步,宁可多花二十分钟准备,也不要在台上赌运气。这套 Demo 里的自检脚本和配置文件结构,就是那次教训之后固化下来的。
希望帮到你,把这份骨架拿去改改,能省掉你至少一轮返工。
本文还有配套的精品资源,点击获取