Unity WebGL接入萤石云监控:HLS流与jslib互操作实战

📅 2026/8/27 4:17:10
Unity WebGL接入萤石云监控:HLS流与jslib互操作实战
简介视频监控在Web平台实时播放常面临浏览器兼容性与底层解码限制的挑战。浏览器沙箱环境下传统的RTSP直连和本地解码插件无法直接工作HLS这类基于HTTP的分片流自然成为Web端视频播放的主流方案。Unity WebGL项目要将摄像头画面嵌入游戏界面需充分利用浏览器原生video标签的媒体能力并通过jslib实现C#与JavaScript的互操作将Unity业务逻辑与浏览器渲染层有效衔接。萤石云OpenAPI提供了获取HLS播放地址的能力开发者只需完成Token鉴权、地址拉取、DOM元素坐标同步等步骤即可在不依赖付费插件的前提下实现低延迟、稳定的多路监控画面显示。这套方案同样适用于安防、工业巡检、智慧农业等需要将实时视频流集成到Web应用的场景为Unity WebGL开发者提供了一条高性价比的技术路径。 最近项目里接了个需求在Unity WebGL平台上展示萤石云摄像头监控画面。之前做PC端和安卓端时我直接用UMPUnity Media Player插件几行代码就能把RTSP流拉起来当时觉得这个插件是真省心。结果切到WebGL平台一测UMP直接罢工——不是报错就是白屏折腾一圈才发现它在WebGL下根本不支持。后来查资料、翻社区、调接口最终用“萤石云OpenAPI HLS流 浏览器原生video标签 Unity与JavaScript互操作”这套组合把问题解决了。整套方案跑通之后画面响应速度、清晰度和稳定性都还不错而且完全不需要付费插件。这篇文章就是把我这次从调研到落地的完整过程整理出来包括为什么UMP在WebGL下不可用、萤石云接口怎么调、jslib插件怎么写、C#和浏览器怎么对接、踩了哪些坑。如果你也在做Unity WebGL下的视频监控接入或者纯粹想搞清楚WebGL平台为什么不能随便拿视频插件就上这篇文章应该对你有帮助。1. 项目背景与方案选型思考1.1 UMP为什么在WebGL下跑不起来UMP在Unity圈子里知名度很高它在Windows、Mac、Android、iOS上都能通过底层解码库去拉RTSP、RTMP、HLS这些流。用到它的项目基本是安防监控、直播、本地视频播放一类场景。我当初选UMP就是看中它对RTSP的支持毕竟萤石云摄像头最常见的协议就是RTSP。但UMP的底层依赖的是操作系统的网络栈和解码能力或者自己打包的ffmpeg这种原生库。到了WebGL平台Unity的C#代码会编译成JavaScript/WASM跑在浏览器沙箱里。浏览器不给你直接开socket拉RTSP也不允许你随便调用底层的硬件解码器。UMP那套原生库在WebGL下没法编译插件自然就废了。这不是UMP一个插件的问题是所有依赖底层网络协议和ffmpeg解码的Unity插件到了WebGL平台都会遇到的通病。我在WebGL平台还试过Unity自带的VideoPlayer组件。它倒是支持一些浏览器能解码的视频格式比如mp4、webm但问题是它不支持HLS的m3u8播放地址。即使把m3u8地址塞给VideoPlayerUnity WebGL也拿不到流数据。因为VideoPlayer内部在WebGL下也是走浏览器媒体能力浏览器不支持的格式它就无能为力。1.2 WebGL视频播放的几条可行路线既然UMP和VideoPlayer都走不通我当时列出了几条可选的路线逐一对比方案实现思路优点缺点结论A. 服务器转码为mp4再播放后端定时抓帧或转封装为mp4交给VideoPlayer实现简单Unity原生能力可用延迟大实时性差无法用于监控不适用B. VideoPlayer直接播m3u8把HLS地址塞给VideoPlayer代码量最小WebGL下不支持实测黑屏不适用C. 浏览器video标签播放HLS通过jslib调用浏览器能力在DOM上创建video元素播放萤石云HLS地址延迟低稳定性好能实时出画面无需付费插件需要写jslib互操作DOM覆盖Unity画布需要做坐标管理选用D. iframe嵌入萤石云官方播放页面把官方H5播放器页面嵌到页面里开发量最小界面不可定制交互受限无法跟Unity UI集成不适用最终我选了C。这个方案的本质是把视频播放这件事从Unity内部挪到浏览器原生层Unity负责业务逻辑、UI布局和与萤石云API通信浏览器负责视频解码渲染。各干各擅长的事问题就解开了。1.3 整体架构与数据流这套方案的核心数据流大概是这样的Unity侧发送HTTP请求到萤石云OpenAPI传入AppKey和AppSecret换取accessToken。Unity侧拿着accessToken加上设备序列号deviceSerial和通道号channelNo请求该设备的播放地址列表。萤石云返回该设备对应的HLS地址.m3u8。Unity把这个m3u8地址通过jslib互操作传给浏览器里的video元素。浏览器加载m3u8拉取TS分片渲染到视频画面。Unity的UI层通过坐标同步把浏览器video元素的位置和大小对齐到Unity画布上对应的区域实现“看起来像是在Unity里播放”的效果。看完这个流程你就明白了Unity WebGL的启动页面其实是一个index.html里面有个canvas是Unity的渲染区域。我们完全可以在这个页面上动态创建video元素然后把它盖在canvas的某个区域上面。从用户视角看监控画面就像嵌在Unity界面里一样。2. 萤石云开放平台接入Token与播放地址获取2.1 创建开发者应用与鉴权准备要做萤石云的API对接第一步是去萤石云开放平台注册一个开发者账号创建应用。创建应用后会拿到一对关键参数AppKey和AppSecret。AppKey相当于你的应用IDAppSecret相当于应用密码后续所有接口签名和身份认证都靠这两个值。我踩过的第一个坑是“设备没有绑定到应用”。光有开发者账号不行你需要在应用管理里把摄像头设备添加进来。用设备序列号deviceSerial和验证码就能添加。设备序列号一般在设备机身上或包装盒上能找到验证码的话如果是海康/萤石设备通常是在设备底部标签上。如果设备不在同一个局域网或者设备已经绑定了别人的账号可能需要在萤石云App里先解绑或重置这一步经常被忽略却会直接导致后面接口调用报“设备不存在或未添加”。我还要提醒一点创建应用的时候要注意AP权限范围。有些应用没有申请视频相关接口权限后续调用播放地址接口会返回无权限错误。创建应用时直接把能开通的视频权限都勾上省得后面来回改。2.2 获取访问令牌accessToken萤石云OpenAPI获取accessToken的接口是接口地址https://open.ys7.com/api/lapp/token/get请求方式POST参数appKey、appSecret这个token有效期按官方文档说明一般是7天左右。过期后需要重新获取。调试的时候你可能会频繁请求token注意别太频繁接口有频率限制。Unity侧用UnityWebRequest发POST请求完整代码可以这样写using System.Collections; using UnityEngine; using UnityEngine.Networking; public class YsCloudApi { public string appKey 你的AppKey; public string appSecret 你的AppSecret; private string accessToken; public IEnumerator GetAccessToken() { WWWForm form new WWWForm(); form.AddField(appKey, appKey); form.AddField(appSecret, appSecret); using (UnityWebRequest request UnityWebRequest.Post(https://open.ys7.com/api/lapp/token/get, form)) { yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError($获取Token失败{request.error}); yield break; } string json request.downloadHandler.text; Debug.Log($Token接口返回{json}); TokenResponse response JsonUtility.FromJsonTokenResponse(json); if (response.code 200) { accessToken response.data.accessToken; Debug.Log($Token获取成功{accessToken}); } else { Debug.LogError($Token获取错误{response.msg}); } } } } [System.Serializable] public class TokenResponse { public string code; public string msg; public TokenData data; } [System.Serializable] public class TokenData { public string accessToken; public long expireTime; }这里用JsonUtility做JSON反序列化需要提前定义好对应的数据结构。萤石云返回的JSON结构里code字段是字符串200表示成功不是JSON里直接是数字200你留意一下类型就行。2.3 拉取指定设备的HLS播放地址拿到token之后下一步就是请求播放地址。接口是接口地址https://open.ys7.com/api/lapp/live/address/get请求方式POST参数accessToken、deviceSerial、channelNo、protocolprotocol参数可以指定协议类型1代表HLS2代表RTMP3代表RTSP具体以官方文档为准。如果只传1返回结果会带上hls相关的地址。如果设备不支持某些协议对应字段就是空。请求代码public IEnumerator GetLiveAddress(string deviceSerial, int channelNo) { WWWForm form new WWWForm(); form.AddField(accessToken, accessToken); form.AddField(deviceSerial, deviceSerial); form.AddField(channelNo, channelNo); form.AddField(protocol, 1); // 只拉HLS using (UnityWebRequest request UnityWebRequest.Post(https://open.ys7.com/api/lapp/live/address/get, form)) { yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError($获取播放地址失败{request.error}); yield break; } string json request.downloadHandler.text; Debug.Log($播放地址接口返回{json}); LiveAddressResponse response JsonUtility.FromJsonLiveAddressResponse(json); if (response.code 200) { if (response.data ! null response.data.Count 0) { string hlsUrl response.data[0].hls; Debug.Log($HLS播放地址{hlsUrl}); // 后续把hlsUrl交给播放器 } else { Debug.LogError(返回数据为空请检查设备是否在线); } } else { Debug.LogError($获取播放地址错误{response.msg}); } } } [System.Serializable] public class LiveAddressResponse { public string code; public string msg; public ListLiveAddressItem data; } [System.Serializable] public class LiveAddressItem { public string deviceSerial; public int channelNo; public string hls; public string hlsHd; public string rtmp; public string rtmpHd; public string rtsp; }我在实际调试中遇到过一个典型问题设备在线token也正常但hls字段返回空。这通常表示该设备或通道不支持HLS协议或者设备类型比较老建议在萤石云App里先验证一下设备本身能不能预览。如果设备本身预览正常但接口返回空大概率是通道号填错了萤石云多数设备默认通道号是1如果有多路摄像头通道号分别是1、2、3。还有一点要特别说明如果你在本地电脑上跑Unity编辑器调试这段代码HLS地址拿到之后通常是可以直接在浏览器打开的。为了快速验证接口返回的地址是否有效你可以先把这个m3u8地址黏贴到VLC媒体播放器里能出画面就说明接口没问题问题大概率出在Unity和浏览器对接这层。3. Unity C#侧核心实现jslib互操作与播放器封装3.1 WebGL工程的基础配置动手写代码之前有几个工程配置需要先确认不然打包之后各种怪问题。第一Edit - Project Settings - Player - WebGL Settings里建议把Compression Format设为Gzip或Brotli如果服务器支持的话否则包体很大加载很慢。但要注意如果用压缩格式服务器必须配置对应的Content-Encoding比如.unityweb文件对Gzip返回Content-Encoding: gzip。第二WebGL内存大小建议调大。如果项目里要同时处理多个视频画面、图片、模型资源默认的内存不够用。在Player Settings里把WebGL Memory Size调高比如256MB或更高具体看你项目资源量。第三如果Unity版本比较新默认会使用WebGL2。WebGL2在渲染性能上比WebGL1好但如果你自己写了额外的JS代码操作canvas上下文注意要兼容WebGL2的API差异。我们这套方案里video元素是独立于Unity canvas的所以对Unity的WebGL版本选择影响不大。第四很重要的一点WebGL默认生成模板里Unity的canvas元素id是#canvas这个在后续写JS代码定位元素时会用到。如果你修改过模板自己记住canvas的id是什么就行。3.2 创建jslib插件文件Unity WebGL支持通过jslib文件在C#和JavaScript之间互相调用。jslib文件本质上是一个JavaScript模块用mergeInto暴露方法给C#端调用。文件放在Assets/Plugins/WebGL/目录下Unity打包时会自动处理。我写的jslib核心功能包括创建video元素、设置播放地址、调整视频在页面中的位置大小、销毁video元素、事件回调。完整代码如下// Assets/Plugins/WebGL/YsWebVideoPlayer.jslib var YsWebVideoPlayer { // 创建全屏/指定区域video元素 CreateVideoElement: function () { var video document.createElement(video); video.id ys-live-video; video.autoplay true; video.muted true; video.playsInline true; video.setAttribute(webkit-playsinline, ); video.style.position fixed; video.style.left 0px; video.style.top 0px; video.style.zIndex 9999; video.style.pointerEvents none; video.style.objectFit fill; video.style.backgroundColor #000000; document.body.appendChild(video); // 事件回调play时通知Unityerror时通知Unity video.addEventListener(playing, function () { SendMessage(Main, OnVideoPlaying, ); }); video.addEventListener(error, function () { var code video.error ? video.error.code : -1; SendMessage(Main, OnVideoError, code); }); }, // 设置播放地址hlsUrl是C#传过来的字符串指针 PlayVideo: function (hlsUrl) { var url UTF8ToString(hlsUrl); var video document.getElementById(ys-live-video); if (!video) return; // 先尝试原生HLS支持Safari/Edge等 if (video.canPlayType(application/vnd.apple.mpegurl)) { video.src url; } else if (window.Hls Hls.isSupported()) { // Chrome/Firefox等不支持原生HLS的浏览器用hls.js if (window.__ysHls) { window.__ysHls.destroy(); } var hls new Hls({ enableWorker: true, lowLatencyMode: true }); hls.loadSource(url); hls.attachMedia(video); window.__ysHls hls; } else { // 都不支持只能报错 SendMessage(Main, OnVideoError, -2); return; } // 自动播放策略先静音播放 video.muted true; video.play().catch(function (err) { SendMessage(Main, OnVideoError, -3); }); }, // 设置video元素在页面中的位置和尺寸单位px SetVideoRect: function (x, y, width, height) { var video document.getElementById(ys-live-video); if (!video) return; var dpr window.devicePixelRatio || 1; video.style.left (x / dpr) px; video.style.top (y / dpr) px; video.style.width (width / dpr) px; video.style.height (height / dpr) px; }, // 销毁video元素 DestroyVideoElement: function () { var video document.getElementById(ys-live-video); if (!video) return; video.pause(); video.removeAttribute(src); video.load(); if (window.__ysHls) { window.__ysHls.destroy(); window.__ysHls null; } if (video.parentNode) { video.parentNode.removeChild(video); } }, // 显示/隐藏视频元素 SetVideoVisible: function (visible) { var video document.getElementById(ys-live-video); if (!video) return; video.style.display visible ? block : none; } }; mergeInto(LibraryManager.library, YsWebVideoPlayer);如果你看过一些Unity WebGL的jslib文档会发现字符串传参的标准做法就是用UTF8ToString这个函数是Unity提供的把C#传入的char指针转成JS字符串。返回值如果要用需要注意分配内存的问题一般我们传回给Unity的值都用SendMessage避免复杂的内存分配。jslib文件还有一个关键点SendMessage的第一个参数是Unity场景中GameObject的完整路径名第二个参数是挂在那个GameObject上的方法名第三个参数是传给方法的参数。这里我用的SendMessage(Main, OnVideoPlaying, )意思是把消息发给场景里名为Main的GameObject调用它的OnVideoPlaying方法参数为空字符串。3.3 C#外部函数声明与封装C#侧需要声明jslib里的函数为外部静态方法语法上用[DllImport(__Internal)]。注意__Internal是Unity WebGL的固定写法表示引用内置jslib。一个完整的WebGLVideoPlayer组件封装如下using System; using System.Runtime.InteropServices; using UnityEngine; public class WebGLVideoPlayer : MonoBehaviour { #if UNITY_WEBGL !UNITY_EDITOR [DllImport(__Internal)] private static extern void CreateVideoElement(); [DllImport(__Internal)] private static extern void PlayVideo(string url); [DllImport(__Internal)] private static extern void SetVideoRect(float x, float y, float width, float height); [DllImport(__Internal)] private static extern void SetVideoVisible(bool visible); [DllImport(__Internal)] private static extern void DestroyVideoElement(); #endif public event Action OnPlayStarted; public event Actionint OnError; public void Play(string hlsUrl) { #if UNITY_WEBGL !UNITY_EDITOR CreateVideoElement(); PlayVideo(hlsUrl); #else Debug.LogWarning(WebGLVideoPlayer只在WebGL平台有效当前平台不支持播放); #endif } public void SetRect(Rect rect) { #if UNITY_WEBGL !UNITY_EDITOR SetVideoRect(rect.x, rect.y, rect.width, rect.height); #endif } public void SetVisible(bool visible) { #if UNITY_WEBGL !UNITY_EDITOR SetVideoVisible(visible); #endif } public void Stop() { #if UNITY_WEBGL !UNITY_EDITOR DestroyVideoElement(); #endif } // 由JS回调 public void OnVideoPlaying() { OnPlayStarted?.Invoke(); } public void OnVideoError(int errorCode) { Debug.LogError($视频播放错误错误码{errorCode}); OnError?.Invoke(errorCode); } }这里有个很关键的写法#if UNITY_WEBGL !UNITY_EDITOR。为什么要排除UNITY_EDITOR因为在Unity编辑器里跑测试时jslib是不生效的。编辑器环境下调用这几个函数会直接报错。我一般会在这几个方法里做个平台判断让逻辑在编辑器模式下也能跑通一半流程至少能走通萤石云API请求只是不创建video元素。实际开发中我建议把事件回调通过事件机制暴露给上层而不是直接跟具体的UI控制耦合。这样以后接入多个摄像头、多个播放器实例时每个播放器有自己的WebGLVideoPlayer组件互不干扰。3.4 视频区域与Unity UI的坐标对接这是整个方案里最绕的一个环节值得单独拿出来说。浏览器里的video元素是独立DOM元素不在Unity的Canvas渲染范围内。要让视频画面正好显示在Unity界面上某个矩形区域里有两种常见处理方式。方式一全屏铺底把video元素设成position: fixed; left:0; top:0; width:100%; height:100%视频作为Unity页面的底层背景。Unity的Canvas设置成透明背景清屏颜色Alpha为0这样视频画面就在Unity UI后面透出来。这种方式最简单但Unity的UI不能完全遮住视频一般只适合视频作为背景的界面。方式二局部覆盖把video元素定位到Unity Canvas上某个UI区域对应的页面坐标。这种方式适合复杂界面视频只占其中一块屏。实现思路是在C#侧算出UI矩形区域的屏幕坐标再通过jslib把坐标传给video元素。我在项目里用的是方式二。C#侧要计算UI元素的屏幕坐标用RectTransformUtility或者Camera.main.WorldToScreenPoint都可以public void SyncRectToVideo(RectTransform targetRect) { if (targetRect null) return; Vector3[] corners new Vector3[4]; targetRect.GetWorldCorners(corners); // corners[0]是左下角corners[2]是右上角世界坐标 Vector3 screenMin Camera.main.WorldToScreenPoint(corners[0]); Vector3 screenMax Camera.main.WorldToScreenPoint(corners[2]); float width screenMax.x - screenMin.x; float height screenMax.y - screenMin.y; // Unity屏幕坐标原点在左下角浏览器DOM坐标原点在左上角 // 所以Y轴需要翻转 float domX screenMin.x; float domY Screen.height - screenMax.y; videoPlayer.SetRect(new Rect(domX, domY, width, height)); }有了这层封装你要把video区域放到哪个UI区域上就在那个UI元素的尺寸变化或位置变化时调用SyncRectToVideo重新同步一次。需要说明的是如果Canvas用的是Screen Space - Overlay模式Camera.main.WorldToScreenPoint可能拿不到正确的屏幕坐标。这种情况下可以直接用targetRect.rect加上Canvas位移来算。我建议在项目里把Canvas统一成Screen Space - Camera模式配合专用的UICamera这样坐标计算最简单。还有一点当浏览器页面的缩放比例变化时Unity的canvas和video元素的坐标会出现偏差。原因在于window.devicePixelRatio不是1页面CSS像素和物理像素不一致。我在jslib里已经做了dpr换算处理保证大部分情况下能对齐。如果你自己写JS一定要处理这个不然Retina屏上视频位置会偏移。4. 实操过程与完整调用链路4.1 从场景启动到出画面的关键顺序把上面的模块串起来一个完整的启动到出画面的流程大概是这样的场景中有一个MainGameObject挂上YsStreamManager和WebGLVideoPlayer两个组件。YsStreamManager负责调用萤石云APIWebGLVideoPlayer负责跟浏览器video元素交互。用户点击“开始预览”按钮执行InitAndPlay。先检查本地有没有缓存的有效token没有就请求token。拿到token后请求播放地址得到hlsUrl。调用videoPlayer.Play(hlsUrl)。视频播放后JS回调OnVideoPlaying此时再调用一次SyncRectToVideo把视频区域对齐到UI。完整代码参考using System.Collections; using UnityEngine; using UnityEngine.Networking; using UnityEngine.UI; public class YsStreamManager : MonoBehaviour { [Header(萤石云参数)] public string appKey 你的AppKey; public string appSecret 你的AppSecret; public string deviceSerial 设备序列号; public int channelNo 1; [Header(视频显示区域)] public RectTransform videoRect; private WebGLVideoPlayer videoPlayer; private string accessToken; private string cachedToken; private long cachedTokenExpireTime; private void Awake() { videoPlayer GetComponentWebGLVideoPlayer(); } public void InitAndPlay() { StartCoroutine(PlayFlow()); } private IEnumerator PlayFlow() { // 1. 获取token if (string.IsNullOrEmpty(accessToken) || cachedTokenExpireTime GetUnixTime()) { yield return StartCoroutine(GetToken()); } if (string.IsNullOrEmpty(accessToken)) { Debug.LogError(无法获取accessToken流程终止); yield break; } // 2. 获取HLS地址 string hlsUrl null; yield return StartCoroutine(GetHlsUrl(deviceSerial, channelNo, url hlsUrl url)); if (string.IsNullOrEmpty(hlsUrl)) { Debug.LogError(无法获取HLS地址流程终止); yield break; } // 3. 同步视频区域 SyncVideoRect(); // 4. 播放 videoPlayer.Play(hlsUrl); } private IEnumerator GetToken() { WWWForm form new WWWForm(); form.AddField(appKey, appKey); form.AddField(appSecret, appSecret); using (UnityWebRequest request UnityWebRequest.Post(https://open.ys7.com/api/lapp/token/get, form)) { yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError($获取Token失败{request.error}); yield break; } TokenResponse response JsonUtility.FromJsonTokenResponse(request.downloadHandler.text); if (response.code 200) { accessToken response.data.accessToken; cachedTokenExpireTime response.data.expireTime; Debug.Log($Token获取成功有效期至{cachedTokenExpireTime}); } else { Debug.LogError($获取Token错误{response.msg}); } } } private IEnumerator GetHlsUrl(string deviceSerial, int channelNo, System.Actionstring callback) { WWWForm form new WWWForm(); form.AddField(accessToken, accessToken); form.AddField(deviceSerial, deviceSerial); form.AddField(channelNo, channelNo); form.AddField(protocol, 1); using (UnityWebRequest request UnityWebRequest.Post(https://open.ys7.com/api/lapp/live/address/get, form)) { yield return request.SendWebRequest(); if (request.result ! UnityWebRequest.Result.Success) { Debug.LogError($获取播放地址失败{request.error}); yield break; } LiveAddressResponse response JsonUtility.FromJsonLiveAddressResponse(request.downloadHandler.text); if (response.code 200 response.data ! null response.data.Count 0) { callback?.Invoke(response.data[0].hls); } else { Debug.LogError($获取播放地址错误{response.msg}); } } } public void SyncVideoRect() { if (videoRect null) return; Vector3[] corners new Vector3[4]; videoRect.GetWorldCorners(corners); Vector3 screenMin Camera.main.WorldToScreenPoint(corners[0]); Vector3 screenMax Camera.main.WorldToScreenPoint(corners[2]); float width screenMax.x - screenMin.x; float height screenMax.y - screenMin.y; float domX screenMin.x; float domY Screen.height - screenMax.y; videoPlayer.SetRect(new Rect(domX, domY, width, height)); } private long GetUnixTime() { System.DateTime epochStart new System.DateTime(1970, 1, 1, 0, 0, 0, System.DateTimeKind.Utc); return (long)(System.DateTime.UtcNow - epochStart).TotalSeconds; } }你在自己项目里集成这段代码时记得把AppKey、AppSecret、设备序列号换成你自己的参数。第一次跑通之后再考虑把参数配置化。4.2 Token缓存与多设备管理的工程化处理实际项目中不太可能每台设备每次进场景都重新拿token。token有效期长达7天完全可以把token缓存到本地。Unity WebGL下的持久化存储方案有PlayerPrefs和IndexedDB其中PlayerPrefs在WebGL底层是IndexedDB实现可以放心用。我建议的处理方式是首次拿到token时存到PlayerPrefs同时存一个过期时间戳。每次启动先读本地缓存判断过期时间是否在当前时间之后有效就直接用无效再走网络请求。这里有个细节萤石云返回的expireTime是毫秒还是秒不同接口可能不一样我记得有的是毫秒级Unix时间戳。你调试的时候打印出来对比一下别搞错了单位。多设备管理是另一个常见需求。比如项目里有十几个摄像头界面做一个设备列表点击某台设备出画面。这种场景下YsStreamManager不应该为每台设备创建一个实例而是做一个资源池一个WebGLVideoPlayer负责当前激活的视频流切换设备时先销毁当前video元素再创建新的。也可以把每个摄像头预取HLS地址缓存起来切换时瞬间出画面。我做多设备切换时踩过一个小坑多次创建video元素之后旧的hls.js实例没有销毁导致内存泄露、视频开多了卡顿。后来我在PlayVideo方法里先判断是否存在旧hls实例存在就destroy再创建新的这个问题才解决。所以jslib里window.__ysHls这个全局变量就是用来跟踪当前hls实例的不要省这个步骤。4.3 断线重连与状态监听的扩展实现网络监控场景里设备掉线、网络抖动是家常便饭。这套方案可以监听浏览器video元素的各种事件把状态回调到Unity侧。我在jslib里已经监听了playing和error事件。实际上可以扩展更多事件public void OnVideoStalled() { // 网络卡顿事件可以在这里做缓冲提示 } public void OnVideoEnded() { // 播放结束一般直播流不会触发 } public void OnVideoWaiting() { // 等待缓冲 }如果要做自动重连建议监听stalled事件如果视频停滞超过一定时间就销毁当前播放器重新调用一次PlayFlow。重连不要太频繁可以设计一个退避策略第一次3秒后重连第二次5秒第三次10秒最多重试3次。注意无限重连会导致界面反复闪烁用户体验很差。我在实际项目中做了这样的重连逻辑private int reconnectCount; private float reconnectDelay 3f; private IEnumerator ReconnectCoroutine() { while (reconnectCount maxReconnectCount) { yield return new WaitForSeconds(reconnectDelay); reconnectCount; reconnectDelay * 1.5f; // 指数退避 videoPlayer.Stop(); yield return StartCoroutine(PlayFlow()); } }还要说一下如果设备本身离线萤石云API返回播放地址时就会报错这时候重连也不会成功。所以重试前最好先调一次“检测设备在线状态”的接口或者直接看播放地址接口的错误码。如果错误码明确表示设备离线就别傻乎乎地重连了提示用户检查设备状态更合理。5. 常见问题与排查技巧实录5.1 常见错误码与浏览器报错对照表调试过程中积累了一张问题速查表这里直接整理出来现象/报错根本原因解决方案Unity里调用PlayVideo后控制台报Uncaught TypeError: Cannot read property canPlayType of nullvideo元素还没创建就播放确保CreateVideoElement执行完再调用PlayVideo或者给两个jslib调用之间加一个短延迟浏览器控制台报NotSupportedError: Failed to load because no supported source was found浏览器不支持HLS且hls.js未引入检查index.html模板里是否引入了hls.min.js网络环境是否能访问CDN浏览器控制台报Access to XMLHttpRequest at https://xxx.m3u8 from origin https://你的域名 has been blocked by CORS policy萤石云HLS服务器未允许跨域请求联系萤石云客服确认CDN域名CORS配置或者把项目部署在和HLS同域的服务器下通常做不到只能确认对方开放CORS视频画面黑屏但Unity界面正常video元素位置/尺寸计算错误或者被Unity canvas遮挡在浏览器F12里查看video元素的DOM位置和样式手动调坐标验证Chrome控制台提示The play() request was interrupted by a new load request网络切换导致video重新加载停止重新加载确认不是代码里重复调用PlayVideo造成的画面有声音但Unity UI听不到video元素在浏览器层静音了需要在Unity侧通过按钮点击事件再调用一次JS的video.muted false打开页面后video元素挡住了其他Unity UI按钮zIndex层级问题调整video元素的zIndex或使用pointerEventsnone让它不响应鼠标事件5.2 画面黑屏/无法加载的排查步骤如果你遇到黑屏我建议按下面这个顺序排查效率最高在浏览器F12控制台看有没有红色报错。如果没有报错说明代码流程正常问题大概率在地址或者格式上。打开Network面板刷新页面看m3u8请求有没有发起。如果完全没有m3u8请求说明hls.js没有正常初始化检查hls.js有没有加载成功。如果m3u8请求发出了但返回403或者404把播放地址复制出来在浏览器新标签里直接访问。访问不了就说明地址本身有问题可能是token过期、设备离线、或者接口返回的地址根本不是可访问的公网地址。确认设备在线。萤石云的HLS地址只在设备在线时有效设备掉线了HLS地址请求能拿到但拉流失败。看video元素的networkState和readyState。在F12控制台执行document.getElementById(ys-live-video).networkState返回3表示网络加载失败。之前遇到一个很隐蔽的问题我在Unity里用UnityWebRequest去请求播放地址接口返回的字段解析出来hls地址是http://开头但项目部署在https://下。浏览器为了安全拦截了HTTP和HTTPS的混合内容请求导致黑屏。后来在萤石云后台把播放地址强制成HTTPS或者在页面上配置upgrade-insecure-requests才解决。建议大家在部署WebGL项目时统一用HTTPS。5.3 自动播放限制与音频策略浏览器为了用户体验对带声音的自动播放有严格限制。Chrome的自动播放策略是页面未交互前video.play()如果有声音会被拒绝如果video.muted true则可以自动播放。所以jslib里的策略是创建video后先静音播放等用户跟Unity界面交互了需要声音时再解除静音。在Unity侧解除静音的代码需要再写一个jslib函数SetVideoMuted: function (muted) { var video document.getElementById(ys-live-video); if (!video) return; video.muted muted; }C#侧[DllImport(__Internal)] private static extern void SetVideoMuted(bool muted); public void SetMuted(bool muted) { #if UNITY_WEBGL !UNITY_EDITOR SetVideoMuted(muted); #endif }用户点击Unity界面上的“声音”按钮时调用SetMuted(false)即可。有些项目喜欢把video元素加上controls属性让用户自己操作音量我不建议这么做因为浏览器原生的控制条样式跟Unity界面很难统一会显得很突兀。最好还是自己在Unity UI里画音量控制按钮。5.4 WebGL部署时的服务器配置代码全部写完了最后还差服务器部署这关。很多人在本地开发一切正常一部署到服务器就白屏。最常见的原因是WebGL的静态文件没有配好MIME类型和压缩编码。你需要确认服务器对以下扩展名返回正确的MIME类型.html-text/html.js-application/javascript.wasm-application/wasm.json-application/json.unityweb-application/octet-stream.data-application/octet-stream.mem-application/octet-stream如果你启用了Gzip/Brotli压缩还要确认.unityweb等文件在响应头里带上Content-Encoding: gzip或Content-Encoding: br。很多免费托管服务默认不支持自定义MIME这种时候就要考虑换成Nginx、IIS或者对象存储带自定义头。另外如果项目要部署到微信小程序、公众号网页等场景还要额外处理微信内浏览器的兼容性。微信内置浏览器对自动播放有限制而且部分Android机的WebView不支持hls.js的MediaSource扩展这些属于更深的坑了有相关需求的朋友可以单独研究。6. 最后再分享一段实际项目的经验这套方案整体跑通后我最大的感受是Unity WebGL做视频监控接入思路一定要打开——Unity不是万能的有些能力要交给浏览器去处理你只需要做好两者之间的桥梁。很多人卡在WebGL视频流播放上多半是思维还没从PC端插件模式切换过来总觉得必须在一个东西内部解决所有问题。实际上把video元素挂在DOM上、用坐标对齐到Unity界面这个做法在WebGL圈子里算是比较通用的成熟方案了。后续如果项目要扩展方向其实很多。比如把萤石云的云台控制接口、录像回放接口、设备报警事件接口全部接入Unity做一个完整的监控控制台。摄像机列表、画面预览、云台转向、截图、录制这些功能都能在Unity里实现只要掌握了一套接口对接规范和jslib互操作思路其他都是锦上添花。祝你项目顺利少踩坑。本文还有配套的精品资源点击获取