简介:这份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=3protocol参数是关键: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判断。
| 对比项 | HLS | HTTP-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 参数,不再靠肉眼估。希望帮到你。
本文还有配套的精品资源,点击获取