☰
Unity 接入萤石云视频流:WebGL 与 PC 端工程化实践
2026/10/26 9:39:32 网站建设 项目流程

简介:这份Unity3d工程源码面向需要在Unity中快速播放萤石云监控视频流的开发者,尤其适合受困于Universal Media Player打开慢、WebGL端无法播放等问题的中高级Unity程序员。项目以萤石云ezopen协议为例,通过3D WebView打开网页播放视频流的嫁接思路,实测打开速度比UMP快2至3倍,并解决了WebGL平台视频无法播放的痛点。压缩包共1079个文件,约115.38MB,包含138个cs脚本、576个meta、125个png、56个pak、55个js、17个dll及多个unity场景、prefab、shader等,覆盖工程配置、网页交互与视频流播放核心逻辑。目前已有1131人学习下载。读者可获得完整可运行的Unity 2020.3.28f1c1工程源码,直接参考网页播放视频流的实现方式,快速替换原有UMP方案,并借鉴其WebGL适配与插件选型思路,减少踩坑时间。

1. 萤石云视频流接进 Unity 的工程化路径:为什么我放弃了 UMP

去年接了个智慧园区的大屏项目,客户要求把园区里几十路萤石云摄像头画面实时投到 Unity 场景的 LED 大屏模型上,还得能打包成 WebGL 让领导在浏览器里直接点开看。一开始我按老思路上了 UMP(Unity Media Player),Windows 端跑得挺欢,一到 WebGL 构建直接黑屏,控制台报解码器初始化失败。折腾了三天才想明白:UMP 底层依赖系统级的多媒体框架,而 WebGL 跑在浏览器的沙箱里,根本没有那套东西可用。

这个工程源码解决的正是这个断层——它绕开了 UMP 这类依赖本地解码器的方案,用萤石云开放的 HLS/FLV 取流接口配合 C# 侧的轻量级协议解析,把视频帧数据通过 WebGL 兼容的方式送进 Unity 的 Texture2D。适合两类人:一是做安防监控可视化大屏的 Unity 开发,二是需要在 WebGL 平台播放实时视频流但被 UMP 坑过的团队。源码里把取流、解码、纹理更新、断线重连这几个环节都拆开了,改起来不费劲。

2. 取流协议选型与 C# 侧地址组装:HLS 和 FLV 到底选哪个

萤石云的视频流接入方式不止一种,选错了协议后面全是坑。工程里默认走的是 HLS 和 HTTP-FLV 两条路,我先把选型逻辑讲清楚,再落到 C# 代码上。

2.1 HLS 与 HTTP-FLV 的延迟和兼容性对比

萤石云开放平台提供的取流地址格式大致是这样的:

# HLS 取流地址(m3u8 索引文件) https://open.ys7.com/api/lapp/live/video/list?accessToken=at.xxxxx&deviceSerial=XXXXXX&channelNo=1&protocol=2 # HTTP-FLV 取流地址 https://open.ys7.com/api/lapp/live/video/list?accessToken=at.xxxxx&deviceSerial=XXXXXX&channelNo=1&protocol=3

protocol参数是关键:2走 HLS,3走 HTTP-FLV。HLS 的优势是浏览器原生支持好,WebGL 构建后<video>标签能直接播 m3u8;缺点是延迟高,切片机制决定了最少 6 到 10 秒打底。HTTP-FLV 延迟能压到 2 到 4 秒,但 WebGL 里没有原生 FLV 解码器,得靠 C# 侧自己解析 FLV Tag 再喂给纹理。

工程源码的做法是:Windows/Android 端默认走 FLV 拿低延迟,WebGL 端自动切到 HLS 保兼容。这个切换逻辑在StreamProtocolSelector.cs里,根据Application.platform判断。

对比项HLSHTTP-FLV
典型延迟6-10 秒2-4 秒
WebGL 原生支持支持(video 标签)不支持,需自行解析
首帧加载速度较慢(需等切片)较快
断线恢复自动追切片需手动重连
适用场景WebGL 大屏、对延迟不敏感PC 端实时监控、需要低延迟

2.2 C# 侧取流地址的组装与 Token 管理

萤石云的 accessToken 有效期默认 7 天,过期后所有取流地址全部失效。工程里封装了一个EzvizTokenManager,核心逻辑是提前 2 小时刷新 Token,避免直播中途断流。

using System; using System.Collections; using UnityEngine; using UnityEngine.Networking; public class EzvizTokenManager : MonoBehaviour { // 萤石云开放平台申请的应用信息 private const string AppKey = "你的AppKey"; private const string AppSecret = "你的AppSecret"; private string _accessToken; private DateTime _tokenExpireTime; // 提前刷新阈值:2小时 private readonly TimeSpan _refreshAhead = TimeSpan.FromHours(2); public IEnumerator InitToken() { // 第一步:用 appKey + appSecret 换取 accessToken var form = new WWWForm(); form.AddField("appKey", AppKey); form.AddField("appSecret", AppSecret); using (var req = UnityWebRequest.Post("https://open.ys7.com/api/lapp/token/get", form)) { yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { Debug.LogError($"Token 获取失败: {req.error}"); yield break; } // 解析返回 JSON,取出 accessToken 和 expireTime var json = JsonUtility.FromJson<TokenResponse>(req.downloadHandler.text); if (json.code != "200") { Debug.LogError($"Token 接口返回异常: {json.msg}"); yield break; } _accessToken = json.data.accessToken; // expireTime 是毫秒时间戳,转成 DateTime _tokenExpireTime = DateTimeOffset .FromUnixTimeMilliseconds(json.data.expireTime) .LocalDateTime; Debug.Log($"Token 获取成功,过期时间: {_tokenExpireTime}"); } } // 每帧检查是否需要刷新,由外部 Update 调用 public bool NeedRefresh() { return DateTime.Now >= _tokenExpireTime - _refreshAhead; } public string GetToken() => _accessToken; [Serializable] private class TokenResponse { public string code; public string msg; public TokenData data; } [Serializable] private class TokenData { public string accessToken; public long expireTime; } }

这段代码的逻辑分三步:先拿 appKey 和 appSecret 去换 accessToken,再把返回的毫秒时间戳转成本地时间存起来,最后暴露一个NeedRefresh()给外部轮询。参数上要注意_refreshAhead设成 2 小时是个经验值——萤石云偶尔会提前使 Token 失效,留足余量比卡着点刷新稳。expireTime字段返回的是毫秒级 Unix 时间戳,别当成秒处理,否则算出来的过期时间会离谱。

2.3 取流地址的拼接与设备序列号校验

拿到 Token 之后,取流地址的拼接就简单了,但设备序列号和通道号这两个参数容易填错。萤石云的设备序列号是 9 位字母数字组合,通道号从 1 开始,硬盘录像机(NVR)下的子通道要按实际接入口算。

public string BuildStreamUrl(string deviceSerial, int channelNo, int protocol) { // protocol: 2=HLS, 3=HTTP-FLV // 注意:设备序列号必须是大写,小写会导致接口返回 404 deviceSerial = deviceSerial.ToUpperInvariant(); // 通道号校验:萤石云 NVR 通道从 1 开始,0 是无效值 if (channelNo < 1) { Debug.LogError($"通道号非法: {channelNo},必须 >= 1"); return null; } string url = $"https://open.ys7.com/api/lapp/live/video/list" + $"?accessToken={_accessToken}" + $"&deviceSerial={deviceSerial}" + $"&channelNo={channelNo}" + $"&protocol={protocol}"; return url; }

这里有个血泪经验:设备序列号必须转大写。我有次调试一下午,接口一直返回“设备不存在”,最后发现是序列号里有个小写字母。另外通道号别传 0,萤石云不会报错但会返回空列表,排查起来很费劲。工程源码里在BuildStreamUrl外面还包了一层缓存,同一个设备+通道的组合 30 秒内不重复请求,避免触发接口频率限制。

3. WebGL 平台下的视频帧渲染:从 RenderTexture 到自定义 Shader

WebGL 构建后,Unity 没法直接调系统解码器,视频帧的获取和上屏方式跟 PC 端完全不一样。这一章讲工程里怎么把视频流变成 Unity 里能用的 Texture。

3.1 WebGL 端用 VideoPlayer 播 HLS 的配置要点

WebGL 平台下,工程用的是 Unity 自带的VideoPlayer组件播 HLS 流,但有几个参数必须改,否则要么黑屏要么卡死。

using UnityEngine; using UnityEngine.Video; public class WebGLStreamPlayer : MonoBehaviour { [SerializeField] private Renderer targetRenderer; private VideoPlayer _videoPlayer; private RenderTexture _renderTexture; void Start() { _videoPlayer = gameObject.AddComponent<VideoPlayer>(); // 关键配置一:WebGL 下必须用 URL 模式,不能走 VideoClip _videoPlayer.source = VideoSource.Url; // 关键配置二:关闭音频输出,WebGL 下音频和视频同时播容易爆音 _videoPlayer.audioOutputMode = VideoAudioOutputMode.None; // 关键配置三:设置渲染纹理,尺寸建议 1280x720,再大 WebGL 扛不住 _renderTexture = new RenderTexture(1280, 720, 0); _videoPlayer.targetTexture = _renderTexture; // 关键配置四:WebGL 下不要开启 skipOnDrop,否则丢帧逻辑会出问题 _videoPlayer.skipOnDrop = false; // 把 RenderTexture 赋给材质 targetRenderer.material.mainTexture = _renderTexture; // 准备完成后播放 _videoPlayer.prepareCompleted += OnPrepared; _videoPlayer.Prepare(); } private void OnPrepared(VideoPlayer vp) { vp.Play(); } void OnDestroy() { if (_renderTexture != null) { _renderTexture.Release(); Destroy(_renderTexture); } } }

参数说明:RenderTexture的尺寸别贪大,1280x720 在 WebGL 下已经够用,上 1920x1080 后浏览器内存占用飙升,低配机器直接崩。audioOutputMode设成None是因为 WebGL 的音频输出和视频解码抢资源,监控场景本来也不需要声音。skipOnDrop关掉是因为 WebGL 下视频帧的时间戳和 Unity 主循环不同步,开了反而会频繁跳帧。

3.2 非 WebGL 平台用 FLV 解析 + Texture2D 更新

PC 和 Android 端走的是另一条路:C# 侧用HttpWebRequest拉 FLV 流,解析出 H.264 裸流后交给Texture2D.LoadRawTextureData更新。工程里这部分在FlvStreamDecoder.cs,核心是 FLV Tag 的解析循环。

using System.IO; using System.Net; using System.Threading; using UnityEngine; public class FlvStreamDecoder : MonoBehaviour { private Thread _decodeThread; private volatile bool _running; private Texture2D _videoTexture; // FLV 流地址 private string _streamUrl; public void StartStream(string url) { _streamUrl = url; _videoTexture = new Texture2D(1280, 720, TextureFormat.RGBA32, false); _running = true; // 解码放在独立线程,避免阻塞 Unity 主线程 _decodeThread = new Thread(DecodeLoop); _decodeThread.IsBackground = true; _decodeThread.Start(); } private void DecodeLoop() { while (_running) { try { var request = (HttpWebRequest)WebRequest.Create(_streamUrl); request.Timeout = 10000; request.ReadWriteTimeout = 10000; using (var response = request.GetResponse()) using (var stream = response.GetResponseStream()) { ParseFlvStream(stream); } } catch (System.Exception e) { Debug.LogWarning($"FLV 流断开,5 秒后重连: {e.Message}"); Thread.Sleep(5000); } } } private void ParseFlvStream(Stream stream) { // 跳过 FLV Header(9 字节)+ PreviousTagSize(4 字节) byte[] header = new byte[13]; ReadFull(stream, header, 13); byte[] tagHeader = new byte[11]; while (_running) { // 读取 Tag Header if (ReadFull(stream, tagHeader, 11) == 0) break; // Tag 数据长度(3 字节,大端) int dataSize = (tagHeader[1] << 16) | (tagHeader[2] << 8) | tagHeader[3]; byte[] tagData = new byte[dataSize]; ReadFull(stream, tagData, dataSize); // Tag 类型:8=音频,9=视频,18=脚本 int tagType = tagHeader[0] & 0x1F; if (tagType == 9) { // 视频 Tag,交给解码器处理 OnVideoTag(tagData); } // 跳过 PreviousTagSize(4 字节) byte[] prevSize = new byte[4]; ReadFull(stream, prevSize, 4); } } private void OnVideoTag(byte[] data) { // 这里对接 H.264 解码器,解码后的帧数据更新到 _videoTexture // 工程源码里用的是软解方案,具体解码库在 Plugins 目录下 } private int ReadFull(Stream stream, byte[] buffer, int count) { int offset = 0; while (offset < count) { int read = stream.Read(buffer, offset, count - offset); if (read == 0) return offset; offset += read; } return offset; } void OnDestroy() { _running = false; _decodeThread?.Join(2000); } }

这段代码的关键点:FLV 的 Tag Header 是 11 字节,其中第 1 字节的低 5 位是 Tag 类型,第 2 到 4 字节是数据长度(大端序)。解析出视频 Tag 后,真正的 H.264 解码在OnVideoTag里对接,工程源码的Plugins目录下带了对应的解码库。ReadFull方法保证每次读满指定字节数,因为网络流一次Read不一定返回完整数据,这是流式解析最容易翻车的地方。

3.3 纹理更新与主线程同步的坑

解码线程拿到帧数据后,不能直接调Texture2D.LoadRawTextureData,Unity 的纹理操作必须在主线程执行。工程里用了一个ConcurrentQueue<byte[]>做线程间数据传递,主线程Update里出队更新纹理。

using System.Collections.Concurrent; using UnityEngine; public class TextureUpdateDispatcher : MonoBehaviour { private ConcurrentQueue<byte[]> _frameQueue = new ConcurrentQueue<byte[]>(); private Texture2D _texture; // 解码线程调用此方法入队 public void EnqueueFrame(byte[] frameData) { // 队列超过 3 帧就丢弃旧帧,避免延迟累积 while (_frameQueue.Count > 3) { _frameQueue.TryDequeue(out _); } _frameQueue.Enqueue(frameData); } void Update() { // 主线程每帧最多处理一帧,避免卡顿 if (_frameQueue.TryDequeue(out byte[] frame)) { _texture.LoadRawTextureData(frame); _texture.Apply(false); } } }

队列长度限制在 3 帧是个实用技巧:网络抖动时解码线程可能瞬间产出多帧,如果全塞进队列,主线程消费不过来会导致画面延迟越积越大。丢旧帧保新帧,监控场景下画面实时性比完整性重要。

4. 避坑与排查:萤石云取流在 Unity 里的五个翻车现场

这一章记录的是我在实际项目里踩过的坑,每个都按“现象 → 原因 → 解决”写清楚,省得你再花时间试。

4.1 WebGL 构建后视频黑屏但控制台无报错

现象:编辑器里播放正常,打包 WebGL 后页面加载完成,视频区域全黑,浏览器控制台没有明显错误。

原因:Unity WebGL 的VideoPlayer在浏览器里依赖<video>标签,但 HLS 的 m3u8 地址如果跨域,浏览器会静默拦截。萤石云的取流地址和你的 WebGL 部署域名大概率不同源。

解决:在萤石云开放平台的控制台里配置允许的域名白名单,或者在自己的服务器上做一层反向代理,把流地址转成同源。工程源码里WebGLStreamPlayer.cs的Start方法开头有一段注释专门提醒这个。

4.2 FLV 流播放几秒后卡死,日志显示 Tag 解析越界

现象:PC 端 FLV 流播放正常,但播到某个时间点后画面卡住,日志里出现IndexOutOfRangeException。

原因:FLV Tag 的数据长度字段是 3 字节大端,但有些摄像头固件在关键帧后会插入扩展字段,导致实际数据长度比 Tag Header 里声明的大。按声明长度读会截断,后续解析全乱。

解决:在ParseFlvStream里加一层容错,读完 Tag Data 后检查流位置和 PreviousTagSize 是否匹配,不匹配就跳过这个 Tag 重新同步。工程源码里FlvStreamDecoder.cs的ResyncToNextTag方法就是干这个的。

4.3 accessToken 过期后画面卡住但不报错

现象:直播跑了几天后突然画面定格,没有任何错误日志,重启应用又正常。

原因:萤石云的 accessToken 过期后,取流地址返回的是 401 状态码,但HttpWebRequest在某些 .NET 版本下不会抛异常,而是返回一个空流。解码线程读到空流后进入死循环,不报错也不退出。

解决:在DecodeLoop里加超时检测,如果连续 30 秒没有读到有效 Tag,主动断开重连并刷新 Token。工程里EzvizTokenManager.NeedRefresh()配合FlvStreamDecoder的重连逻辑一起用。

4.4 多路视频同时播放时帧率骤降

现象:单路视频流畅,开到 4 路以上时 Unity 帧率从 60 掉到 15 以下。

原因:每路视频都开了一个独立解码线程和一个Texture2D,CPU 解码和纹理上传的带宽被吃满。WebGL 下更严重,浏览器对同时解码的视频路数有限制。

解决:工程里的做法是限制同时活跃的视频路数,WebGL 端最多 4 路,PC 端最多 9 路。超出的路数走轮询切换,或者降分辨率到 640x360。StreamManager.cs里有个MaxConcurrentStreams常量控制这个上限。

4.5 打包 WebGL 后内存持续增长直至崩溃

现象:WebGL 页面开着不动,几小时后浏览器标签页内存占用从 200MB 涨到 2GB 以上,最终崩溃。

原因:RenderTexture和Texture2D没有及时释放,每次断线重连都新建一个,旧的没销毁。WebGL 的垃圾回收机制和 PC 端不同,不显式释放就会一直占着。

解决:在断线重连和切换视频源时,先调RenderTexture.Release()和Destroy(texture),再创建新的。工程源码里所有创建纹理的地方都配了对应的释放逻辑,在OnDisable和OnDestroy里各清一次。

5. 进阶技巧:用 Shader 做视频帧的 YUV 转 RGB 与画面校正

工程源码里视频帧解码出来是 YUV 格式,直接转 RGB 在 CPU 侧做的话,1280x720 每帧要跑 92 万次像素转换,帧率直接砍半。我的做法是把转换放到 Shader 里,GPU 并行处理,CPU 侧只负责传 YUV 三个分量。

5.1 YUV 转 RGB 的 Shader 实现

Shader "Custom/YUVToRGB" { Properties { _MainTex ("Y Plane", 2D) = "white" {} _UTex ("U Plane", 2D) = "gray" {} _VTex ("V Plane", 2D) = "gray" {} } SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _MainTex; sampler2D _UTex; sampler2D _VTex; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { // 采样 YUV 三个分量 float y = tex2D(_MainTex, i.uv).r; float u = tex2D(_UTex, i.uv).r - 0.5; float v = tex2D(_VTex, i.uv).r - 0.5; // BT.601 转换矩阵 float r = y + 1.402 * v; float g = y - 0.344 * u - 0.714 * v; float b = y + 1.772 * u; return fixed4(r, g, b, 1.0); } ENDCG } } }

这个 Shader 的核心是 BT.601 转换矩阵,u和v要先减 0.5 做偏移。参数上注意_UTex和_VTex的采样坐标要和_MainTex一致,但 YUV 的 UV 分量分辨率通常是 Y 的一半,所以实际使用时要把 UV 纹理的 Filter Mode 设成 Bilinear,让 GPU 自动插值。

5.2 画面校正:去噪与亮度均衡

监控摄像头在夜间模式下噪点很多,直接投到大屏上观感很差。工程里在 Shader 后面加了一个后处理 Pass,做简单的 3x3 均值滤波和亮度拉伸。

// 在 frag 函数里追加 float3 SampleWithDenoise(float2 uv) { float3 sum = float3(0, 0, 0); float2 texelSize = float2(1.0 / 1280, 1.0 / 720); // 3x3 均值滤波 for (int x = -1; x <= 1; x++) { for (int y = -1; y <= 1; y++) { float2 offset = float2(x, y) * texelSize; sum += tex2D(_MainTex, uv + offset).rgb; } } return sum / 9.0; }

这个滤波在 1280x720 下每帧多跑 9 次采样,GPU 完全扛得住。亮度均衡用smoothstep把暗部拉亮,参数根据实际摄像头调,我一般把暗部阈值设在 0.15 左右。

5.3 验证方法:用 Unity Profiler 看 GPU 耗时

改完 Shader 后别凭感觉判断性能,开 Unity Profiler 看Camera.Render的 GPU 耗时。WebGL 端用浏览器开发者工具的 Performance 面板,看requestAnimationFrame的间隔是否稳定在 16ms 以内。如果 GPU 耗时超过 8ms,说明 Shader 里的采样次数太多,把 3x3 滤波改成 2x2 或者直接关掉。

从那以后我每次接视频流项目,都强制先跑一遍 Profiler 再调 Shader 参数,不再靠肉眼估。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询