简介一份面向 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*1001的规律往后排。这个地址只在服务端使用绝不能出现在前端代码里一个原因是浏览器不支持另一个原因是地址里带明文的账号密码直接暴露在前端等于把设备交出去。中间必须加一层转流网关把 RTSP 拉过来重新封装。市面上可选的形态有三条各自的代价差别很大链路形态端到端延迟经验值浏览器兼容性AVProVideo 在 WebGL 下能否直接播HLS标准切片620 秒全平台原生支持能最省事LL-HLS / 缩短切片25 秒依赖浏览器版本能需要网关配合调小切片和 GOPHTTP-FLV13 秒需要 MSE 额外解码库不能要绕开 AVProVideo 的播放通道WebRTC0.31 秒需要信令服务不能通常要自己写 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 拷一次1080p25fps 在集显机器上就会明显掉帧。我的经验是监控大屏这种场景能用覆盖层就用覆盖层别为了「看起来高级」去走纹理路径。音频能不能自动播。浏览器的自动播放策略要求有用户手势。也就是说页面加载完直接Play()视频画面可能出来但音频会被静默拦掉。监控场景本身多数是静音的所以这个坑不明显但如果你的 Demo 需要演示对讲就必须加一个「点击开始」的遮罩层。参数该动哪几个。在 MediaPlayer 组件上WebGL 真正影响结果的参数其实不多Video Location设为 URLPath填 HLS 地址Auto Open和Auto Play建议都关掉改成代码里显式调用方便控制时序和失败重试Loop关掉直播流循环没有意义Video Mapping选Fit避免不同分辨率的摄像头画面被拉伸变形Platform Options里针对 WebGL 单独覆盖路径这样同一套 Prefab 在编辑器和浏览器里能用不同地址。2.3 编码与分辨率的前置约束这一条最容易被忽略但它在联调阶段造成的排查时间最长前端播放器挑编码。摄像机和转流网关输出的视频必须是 H.264profile 用 Baseline 或 Main音频用 AAC。用 H.265 的摄像头在浏览器里就是黑屏而且不报错只是video.error里给一个含糊的码。我一般会在网关配置里加两条硬规则多画面场景4 分屏、9 分屏统一走子码流分辨率压到 720p、帧率 1520单画面放大时才切主码流 1080p。关键帧间隔设 12 秒太长会让首帧等待变久太短会让码率飙升。这几项在摄像机的 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 Actionstring OnFirstFrameReady; // 首帧到达参数为 playerId public event Actionstring, string OnPlaybackFailed; // 播放失败参数为 id 与错误码 private bool _firstFrameNotified; private void Awake() { if (mediaPlayer null) mediaPlayer GetComponentMediaPlayer(); 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}; } /// 前端侧 HLSAVProVideo 在 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-Originvideo 元素加载 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 秒调到 12 秒同时缩短关键帧间隔让每个切片都能从关键帧开始。解决延迟累积在控制器里加一个定时检查每 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 里的自检脚本和配置文件结构就是那次教训之后固化下来的。希望帮到你把这份骨架拿去改改能省掉你至少一轮返工。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?