简介面向 Unity WebGL 视频播放场景的一套海康监控画面接入示例工程适合具备 Unity/C# 基础、需要在网页端播放海康设备实时画面的开发者参考。工程重点重写了 HTTP 请求模块并补充 AVProVideo无水印版本发布 WebGL 所需的设置指导与配置文件使用前只需修改 HTTP 请求配表中的字段值即可完成发布并在 WebGL 环境运行对本地 Nginx 部署也有对应实践线索。压缩包共 701 个文件、约 80.17MB包含 99 个 C# 脚本、30 个 asset 资源、26 个 dll、25 个 shader、14 个 mat 及多份 txt 说明、png/mp4 演示素材等覆盖工程源码、播放器依赖、渲染材质与配置文档。已有 1215 人学习适合用来快速搭建监控视频接入原型理解 WebGL 下视频播放链路、请求参数配置与发布设置减少从零排查适配问题的时间。1. 海康摄像头接进 Unity WebGL先认清 AVProVideo 只负责最后一百米值班室的监控要搬进浏览器前端同事的第一反应是 Unity WebGL 加 AVProVideo把海康摄像头的画面往 Canvas 上一贴UI、场区地图、报警弹窗全用 Unity 那套现成的。真动手才发现AVProVideo 在 WebGL 平台上根本不做解码——它只创建一个浏览器原生的video元素把 URL 丢给浏览器再把帧拷贝成 WebGL 纹理。能不能播取决于 Chrome 认不认这个流。而海康 IPC 吐出的是 RTSP浏览器一个都不认。这份 demo 源码解决的就是中间这一段RTSP 怎么变成浏览器认识的流、AVProVideo 在 WebGL 下哪些参数必须改、多路网格怎么不卡不花屏、断流了怎么自动恢复。用 Unity/C# 写业务逻辑的人可以直接照着改不必再去踩一遍浏览器解码栈的坑。2. 链路拆解为什么 WebGL 里 AVProVideo 只能播“浏览器认得的流”2.1 浏览器解码栈决定了 AVProVideo 的能力上限在 Windows、Android 平台上AVProVideo 自带解码后端DirectShow、Media Foundation、ExoPlayer 都能直接把 RTSP、MKV、MPEG-TS 解出来所以你写OpenMedia(rtsp://...)在编辑器里是能跑的。很多人就是在这里被误导的编辑器模式下一切正常打成 WebGL 包一上线全黑。WebGL 平台的实现路径完全不一样。插件目录里能找到 WebGL 那份 .jslib 运行时它的职责只有三件事创建或复用一个video元素、把 URL 赋给video.src、每帧用texImage2D把 video 的当前帧上传到 Unity 的纹理。解码、解封装、协议栈统统交给浏览器。这条边界一旦清楚后面的所有限制都是必然结果维度桌面/移动原生平台WebGL 平台协议RTSP / RTMP / HLS / 文件HTTP(S) 渐进下载、MSE 可承载的流编码插件后端支持即可浏览器支持才播H.265 基本无解解码线程插件内部线程浏览器进程Unity 无法干预显存/内存可控跟随标签页超了直接崩首帧速度200~600ms800ms~3s取决于缓冲策略所以选型的第一步不是配 AVProVideo而是问一句这个流Chrome 自己放得出来吗把rtsp://直接粘到浏览器地址栏是没反应的这就是最直白的验证。2.2 四条转封装路线的取舍HLS、HTTP-FLV、fMP4、WebRTC从摄像机出来到浏览器能播中间必须有个转封装环节。常见做法是在内网放一台转流服务ffmpeg 或流媒体服务把 RTSP 拉过来只换封装不重编码H.264 原样透传输出浏览器认的容器。四条路线的实际差异我按现场跑过的结果列一下路线首帧延迟ChromeSafariAVProVideo 原生支持落地成本HLSfMP4 分片1~3s需 hls.js原生支持需 jslib 挂库中HTTP-FLV0.5~1.5s需 flv.js不支持需 jslib 挂库中fMP4 渐进下载1~2s原生原生直接填 Remote URL低WebRTC500ms原生原生不支持需自己贴纹理高消息类、值班类场景对延迟不敏感HLS 是最省事的切片文件落盘还能当录像回放用服务端挂掉重连也简单。要做云台跟随、要跟现场对讲延迟压不到 1 秒以内体验就废了那就只能上 WebRTC代价是 AVProVideo 这条链路要绕开改用 jslib 直接把video元素的帧送进 Unity 纹理。中间还有一个常被忽略的选项fMP4 渐进下载。它不需要任何 JS 库AVProVideo 填个绝对 URL 就能播缺点是流不能随时切、seek 能力弱。demo 里的默认通道就是走这条用来先验证 AVProVideo 这条链路本身是否通畅再去接转封装服务。2.3 从 IPC 取流主码流/子码流地址与 ffmpeg 转封装命令海康系 IPC 的 RTSP 地址有两套格式新固件基本都用通道式# 通道式新固件101 1 号通道主码流102 1 号通道子码流 # 201 / 202 就是 2 号通道NVR 上按通道号顺序排 rtsp://user:pwdip:554/Streaming/Channels/101 # 旧格式部分老设备还在用 rtsp://user:pwdip:554/h264/ch1/main/av_stream主码流一般是 1080p 或 4K子码流是 D1 或 720p。多宫格场景一律用子码流四路 1080p 同时硬解办公机的集显扛不住就算不崩帧率也会掉到个位数。这是省事又立竿见影的一步。拿到 RTSP 之后转封装命令ffmpeg -hide_banner -loglevel warning \ -rtsp_transport tcp \ -stimeout 5000000 \ -i rtsp://user:pwdip:554/Streaming/Channels/102 \ -c:v copy -an \ -f hls \ -hls_time 1 \ -hls_list_size 6 \ -hls_flags delete_segmentsomit_endlist \ -hls_segment_type fmp4 \ -hls_fmp4_init_filename init.mp4 \ /var/www/live/ch01/index.m3u8参数逐个说清楚因为这几个值直接对应后面的“坑”-rtsp_transport tcp强制走 TCP。UDP 在内网也会丢包丢了就是画面下半截绿块且恢复要等下一个 I 帧值班的人看到会以为摄像头坏了。-stimeout 5000000拉流超时单位微秒这里 5 秒。新版 ffmpeg 里这个参数改名叫-timeout写错了会直接报 Unknown option 而不是不生效。-c:v copy -an视频流原样透传不重编码CPU 占用几乎为零-an丢掉音频监控场景基本用不上留着反而触发浏览器的自动播放限制。-hls_time 1期望切片长度 1 秒。注意它是“期望值”实际切片长度由关键帧决定。IPC 默认 GOP 是帧率的 2~4 倍25fps 就是 2~4 秒一个 I 帧切出来全是 4 秒的片。必须去摄像机编码设置里把 I 帧间隔改成 25即 1 秒一个 I 帧这个参数才有意义。-hls_list_size 6播放列表里保留 6 个分片。太小容易在两次请求之间把正在播的分片删掉Chrome 直接 404。-hls_flags delete_segmentsomit_endlist自动删旧分片防止磁盘堆满omit_endlist让播放器知道这是直播流不会播完就停。转封装服务本身要考虑的东西比命令多一条命令对应一路画面几十路就要用进程管理或者成熟的流媒体服务去管还要做健康检查进程挂了自动拉起。demo 里是单路脚本工程化的时候这块得重写。3. Demo 落地Unity 工程配置到 AVProVideo 的 WebGL 参数逐项过一遍3.1 工程目录与三个核心脚本的职责源码包的目录结构大致是这样各人习惯不同但职责划分建议照这个来路径职责Assets/AVProVideo/插件本体含 WebGL 的 jslib 运行时Assets/Demo/Scripts/PreviewGrid.cs宫格布局、通道分配、当前选中通道切换Assets/Demo/Scripts/ChannelPlayer.cs单通道播放、首帧超时、断流重连Assets/Demo/Scripts/StreamConfig.csScriptableObject存通道名、URL、码流类型Assets/Demo/Plugins/WebGL/自己写的 jslib用于挂 hls.js 之类Assets/StreamingAssets/本地测试用的 mp4正式环境走远程 URL拆分思路很直白ChannelPlayer只关心“这一路怎么播、播挂了怎么办”不碰 UIPreviewGrid只关心“哪几路该在哪个格子”不关心播放器内部状态。两者的接口就三个方法Open(url)、Close()、IsPlaying。这么拆的好处是后面要接 WebRTC 的时候只换ChannelPlayer的内部实现宫格逻辑一行不用动。3.2 AVProVideo WebGL 面板参数能改的和不能改的MediaPlayer面板上的选项不少WebGL 真正相关的就那么几项。各版本文案略有差异按语义找对应项即可参数建议取值原因Platform Options Overrides勾选并单独配 WebGL不勾就走 DefaultWebGL 分支会拿到错误路径Video Location / PathRemote URL绝对地址WebGL 下 StreamingAssets 是相对页面的 URL必须和Application.absoluteURL拼Auto Open关交给脚本控制时机避免和自动播放策略打架Auto Start关同上先静音打开拿到用户手势后再播Apply ToMaterial 或 UI RawImage不建议用DisplayIMGUIWebGL 下事件穿透处理很别扭Audio Output首屏关带声音的 video 在 Chrome 里必须有用户交互才允许播还有一个容易在打包后才暴露的问题IL2CPP 的代码裁剪。AVProVideo 的运行时是通过反射和 jslib 回调串起来的Managed Stripping Level 调高之后某些方法会被裁掉表现为编辑器正常、WebGL 包打开视频没反应控制台只报一句找不到函数的错。解决办法是在工程根目录放一个link.xmllinker !-- WebGL IL2CPP 裁剪时保住 AVProVideo 的运行时 -- assembly fullnameAVProVideo.Runtime preserveall/ assembly fullnameAVProVideo.Editor preserveall/ /linker文件放在Assets/下即可Unity 打包时会自动读取。这个文件平时没人注意但它是这类“打包后才坏”的问题里最容易查的一类。3.3 ChannelPlayer一个 MediaPlayer 管多路画面的写法单通道播放的核心是控制打开时机和首帧超时。默认行为是OpenMedia之后插件自己开始播异常情况下你根本不知道它卡在哪一步。我一般会关掉自动打开自己写状态机using System.Collections; using UnityEngine; using RenderHeads.Media.AVProVideo; public class ChannelPlayer : MonoBehaviour { [SerializeField] private MediaPlayer mediaPlayer; [SerializeField] private float firstFrameTimeout 8f; [SerializeField] private int maxRetryBeforeSlowdown 5; private string _url; private int _retry; private Coroutine _routine; public bool IsPlaying mediaPlayer ! null mediaPlayer.Control ! null mediaPlayer.Control.IsPlaying(); public void Open(string url) { _url url; _retry 0; if (_routine ! null) StopCoroutine(_routine); _routine StartCoroutine(PlayRoutine()); } private IEnumerator PlayRoutine() { // 关掉自动流程打开/播放的时机全部由这里控制 mediaPlayer.m_AutoOpen false; mediaPlayer.m_AutoStart false; mediaPlayer.CloseMedia(); // 等一帧确保上一次的 video 元素已经被回收 yield return null; mediaPlayer.OpenMedia( new MediaPath(_url, MediaPathType.AbsolutePathOrURL), autoPlay: false); // 首帧超时判定HasVideo 为 true 才算真的解出了画面 float elapsed 0f; while (elapsed firstFrameTimeout !mediaPlayer.Info.HasVideo()) { elapsed Time.unscaledDeltaTime; yield return null; } if (!mediaPlayer.Info.HasVideo()) { ScheduleRetry(); yield break; } mediaPlayer.Control.SetVolume(0f); // 首屏静音绕开自动播放限制 mediaPlayer.Play(); _retry 0; } private void ScheduleRetry() { _retry; // 指数退避封顶 30 秒连续失败说明源站或网络有问题不要死循环重连 float delay _retry maxRetryBeforeSlowdown ? Mathf.Pow(2f, _retry) : 30f; StartCoroutine(RetryAfter(delay)); } private IEnumerator RetryAfter(float seconds) { yield return new WaitForSecondsRealtime(seconds); if (!string.IsNullOrEmpty(_url)) Open(_url); } public void Close() { if (_routine ! null) { StopCoroutine(_routine); _routine null; } if (mediaPlayer ! null) mediaPlayer.CloseMedia(); _url null; } }几个点值得单独说m_AutoOpen和m_AutoStart是MediaPlayer上的公开字段不是属性直接赋值即可。关掉它们的意义在于OpenMedia之后你可以先判断首帧有没有出来再决定播不播。默认流程里这两步是粘在一起的出了问题只能看控制台日志猜。mediaPlayer.Info.HasVideo()比IsPlaying()更适合做首帧判定。IsPlaying在浏览器已经把video标成 playing、但首帧还没解出来的阶段也可能返回 true这时候贴图是黑的。Time.unscaledDeltaTime而不是Time.deltaTime。WebGL 标签页切到后台时 Unity 主循环会被浏览器降频甚至暂停用缩放时间会让超时判定完全失真切回来瞬间触发一堆重连。退避到 30 秒封顶而不是无限指数增长是因为监控场景里断流大多是瞬时的摄像机重启、交换机抖动几秒内就恢复了重连间隔太长反而显得“卡死了”。3.4 打包与部署压缩格式、MIME、CORS 三件套WebGL 包本身跑不起来的情况八成是服务端配置不到位。IIS 上至少要补这些 MIME 和响应头?xml version1.0 encodingutf-8? configuration system.webServer staticContent !-- Unity WebGL 的产物 -- remove fileExtension.unityweb / mimeMap fileExtension.unityweb mimeTypeapplication/octet-stream / mimeMap fileExtension.wasm mimeTypeapplication/wasm / mimeMap fileExtension.data mimeTypeapplication/octet-stream / !-- 视频分片 -- mimeMap fileExtension.m3u8 mimeTypeapplication/vnd.apple.mpegurl / mimeMap fileExtension.ts mimeTypevideo/mp2t / mimeMap fileExtension.m4s mimeTypevideo/iso.segment / mimeMap fileExtension.mp4 mimeTypevideo/mp4 / /staticContent httpProtocol customHeaders !-- 视频站点和页面不同源时必须带上线前记得收窄到具体域名 -- add nameAccess-Control-Allow-Origin value* / add nameAccess-Control-Allow-Headers valueRange / add nameAccess-Control-Expose-Headers valueContent-Length,Content-Range,Accept-Ranges / /customHeaders /httpProtocol /system.webServer /configuration三条要注意的地方。Access-Control-Expose-Headers里必须显式暴露Content-Range和Accept-Ranges否则浏览器读不到这两个头会认为服务端不支持 Range 请求Safari 上直接拒播。.m3u8的 MIME 写错成text/plain时 Chrome 靠 hls.js 还能播Safari 原生播放器就会当普通文本处理表现为“点了没反应”。Unity 的压缩格式Player Settings → Publishing Settings建议内网环境用 Disabled 或 GzipBrotli 虽然体积小但对服务端的Content-Encoding配置要求更严配置错了会得到Unable to parse build/xxx.framework.js这种看起来完全无关的报错。另外Decompression Fallback打开能兜底代价是加载慢一点。4. 多路与 UI4 宫格、9 宫格怎么不卡不花屏4.1 别给每一路 new 一个 MediaPlayer这是我在现场见过最多的写法复制粘贴四个MediaPlayer每个挂一个 URL一开四路直接卡成 PPT。原因不在 Unity在浏览器——每一个MediaPlayer在 WebGL 下都对应一个独立的video元素也就对应一路独立的解码器和一块视频内存。Chrome 对同时活跃的 video 元素数量有软限制超了之后新的元素会停在HAVE_METADATA状态不再推进表现为后开的那几路永远黑屏控制台一句错都没有。正确做法是把播放器数量固定下来也就是“池化”。四宫格就建四个MediaPlayer常驻不销毁切换通道时只调用Open(url)换地址不Destroy也不AddComponent。九个格子的场景下同时活跃的播放器压到 4 个以内其余格子显示最后一张截帧或者 “点击查看” 占位用户点哪个就把哪个塞进空闲的播放器。这个取舍在值班场景里完全能接受因为人眼同时也就盯一两个画面。// 固定大小的播放器池数组在编辑器里手工填好运行时不动态创建 [SerializeField] private ChannelPlayer[] pool new ChannelPlayer[4]; private ChannelPlayer Acquire() { foreach (var p in pool) if (!p.IsPlaying) return p; return pool[0]; // 全忙就抢占最早的那一路 }Acquire()的抢占策略按业务定。安防场景一般是“最后点击的优先”也就是抢占索引 0 或者按最近使用时间排序别用随机或者按数组顺序覆盖否则用户点了 A 格子画面跑到 B 格子上去会被当成 bug 提。4.2 纹理输出与 RawImage 的尺寸对齐纹理这一层有两个高频问题画面比例不对和画面上下颠倒。比例问题的根源是不同通道的分辨率不一样——主码流 1920×1080子码流 704×576还有一堆 4:3 的老设备在跑。UI 上的RawImage如果按格子尺寸硬拉伸4:3 的流会被压扁。稳妥的写法是每帧检查实际分辨率再算尺寸private void LateUpdate() { if (mediaPlayer null || mediaPlayer.Info null) return; int w mediaPlayer.Info.GetVideoWidth(); int h mediaPlayer.Info.GetVideoHeight(); if (w 0 || h 0) return; float gridW rawImage.rectTransform.rect.width; float gridH rawImage.rectTransform.rect.height; float videoAspect (float)w / h; float gridAspect gridW / gridH; // 按较小的那个方向对齐保证画面完整落在格子里letterbox rawImage.rectTransform.SetSizeWithCurrentAnchors( RectTransform.Axis.Horizontal, videoAspect gridAspect ? gridW : gridH * videoAspect); rawImage.rectTransform.SetSizeWithCurrentAnchors( RectTransform.Axis.Vertical, videoAspect gridAspect ? gridW / videoAspect : gridH); }上下颠倒的问题先去查mediaPlayer.TextureProducer.RequiresVerticalFlip()。WebGL 分支下贴图方向和原生平台不一致的情况是存在的不同插件版本行为也可能变。判断出来之后再决定是翻转 UV 还是改 shader千万不要用localScale.y -1去翻——UI 的点击区域会跟着一起翻用户点上方触发下方的事件这个 bug 排查起来极其费时间。还有一个隐形成本LateUpdate里每帧调GetVideoWidth()是有开销的它要跨 jslib 边界读浏览器状态。四路同时跑就是每帧四次跨界调用能省则省——分辨率不会变缓存起来在FirstFrameReady事件里刷新一次就够了。4.3 断流重连状态机与超时阈值监控流断的方式有很多种摄像机重启、网线被拔、交换机重启、转流进程挂掉、HLS 分片 404。对上层来说现象都一样——画面定格。如果只靠插件的错误事件有一半情况是收不到的因为浏览器压根不认为video出错了它只是不再往后缓冲。所以需要一层自己写的看门狗。判定逻辑用“播放位置在 N 秒内没有推进”private IEnumerator Watchdog() { double lastTime -1; int stallCount 0; while (true) { yield return new WaitForSecondsRealtime(3f); if (mediaPlayer null || mediaPlayer.Control null) continue; double current mediaPlayer.Control.GetCurrentTime(); // 直播流位置不推进说明已经卡住了 if (Mathf.Abs((float)(current - lastTime)) 0.05f) stallCount; else stallCount 0; lastTime current; if (stallCount 2) // 连续 2 次、约 6 秒没推进 { stallCount 0; Open(_url); } } }阈值怎么定是有讲究的。3 秒采样、2 次判定等于容忍 6 秒左右的卡顿。定得太短网络抖一下就来一次重连重连本身要 800ms 以上反而更卡定得太长用户会来投诉“画面死了半分钟”。HLS 场景下还要额外加一条分片请求本身可能超时看门狗和播放器的重试会撞车所以Open()里必须先CloseMedia()让旧的那一路彻底断掉再开新的。一个容易忽视的细节是mediaPlayer.Control在CloseMedia()之后会变成 null看门狗里访问之前一定要判空否则重连过程中必抛空引用而这个异常在 WebGL 上的堆栈信息会被裁剪得几乎没法看。5. 避坑清单五类现场翻车记录与排查顺序5.1 黑屏但控制台干净自动播放策略现象编辑器里播得好好的打成 WebGL 部署上去四路全黑控制台除了The AudioContext was not allowed to start之外没有任何报错Network 面板里视频分片在正常下载。原因Chrome 的自动播放策略。带音轨的video必须有用户手势才能play()脚本在页面加载后直接调用播放会被拒Promise 里抛的NotAllowedError如果没接 catch就静默失败。AVProVideo 走的就是video.play()一样受限。转封装时用-an去掉音轨能规避大部分情况。解决分两步走。首屏所有播放器先SetVolume(0)静音打开此时浏览器允许自动播放页面上放一个“进入监控”的按钮用户点了之后再把音量恢复监控场景其实不需要恢复。转流侧统一加-an从源头消除这个变量。5.2 画面能播但贴图全黑CORS 与 WebGL 纹理污染现象视频站和页面不在同一个域名下video标签单独打开能播Network 显示 200但 Unity 里纹理是纯黑控制台可能出现SecurityError: Failed to execute texImage2D on WebGLRenderingContext: The image element contains cross-origin data。原因这是 WebGL 的跨域纹理保护。video元素播放跨域资源本身是允许的但一旦用texImage2D去读它的像素浏览器就要求这个资源必须是 CORS-clean 的——服务端得回Access-Control-Allow-Origin并且 video 元素得带crossOrigin属性。任一条不满足纹理上传就被拒。解决服务端统一加Access-Control-Allow-Origin并且要把这个头加到.m3u8、分片文件、init.mp4上只加在播放列表上是不够的。同时检查Access-Control-Expose-Headers里有没有Content-Range缺了它 Safari 会拒绝加载跨域分片。最后确认视频站点和页面用的是同一套协议别一边 https 一边 http混合内容会被直接拦掉。5.3 Chrome 能播 Safari 不能播HLS 归属问题现象同一套地址Chrome 上四路正常Safari 上黑屏或者反过来Safari 正常Chrome 上video的readyState卡在 0 不动。原因两家对 HLS 的原生支持完全相反。Safari 原生支持m3u8把 URL 赋给src就能播Chrome 不支持必须靠 hls.js 把分片通过 MSE 喂进去。AVProVideo 在 WebGL 下只是把 URL 赋给src它不会帮你挂 hls.js。解决写一个小 jslib在 AVProVideo 创建好 video 元素之后找到它并挂上 hls.js// Assets/Demo/Plugins/WebGL/AVProHLS.jslib mergeInto(LibraryManager.library, { AVPro_HookHLS: function (urlPtr) { var url UTF8ToString(urlPtr); if (window.__avproHlsHooked) return; window.__avpro p a hrefhttps://download.csdn.net/download/Bruinxyz/88800085 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p