
1. 项目概述与核心价值最近在做一个智慧园区或者安防监控相关的Unity项目时一个绕不开的需求就是如何把市面上主流的网络摄像头比如海康、大华的实时视频流稳定、低延迟地接入到Unity的3D场景里。你可能想在大屏上展示一个3D园区然后点击某个楼栋就能弹出这个楼门口的实时监控画面或者做一个AR巡检应用把摄像头画面叠加在真实设备上。这个需求听起来很直接但真动手做你会发现坑不少海康、大华自家的SDK虽然强大但往往很重而且和Unity的跨平台特性尤其是WebGL、移动端兼容性不佳。更常见的情况是设备已经接入了像萤石云这样的公有云平台你拿不到设备的局域网RTSP流只能通过云平台的API来获取直播流。这个项目要解决的就是如何在Unity3D中通过C#代码调用萤石云的开放API获取已接入该平台的海康或大华摄像头的直播视频流地址并最终在Unity的UI比如RawImage或3D物体比如材质球上播放出来。我会把完整的实现思路、踩过的坑、以及可以直接拿来用的核心源码都分享出来。无论你是做数字孪生、安防可视化还是简单的监控集成这套方案都能给你提供一个清晰、可落地的技术路径。2. 整体技术方案设计与选型考量2.1 为什么选择萤石云API而非设备直连首先得搞清楚我们为什么舍近求远不直接用OpenCV或者FFmpeg去拉设备的RTSP流而要绕道云平台。这里有几个很现实的考量设备直连RTSP的局限性网络穿透问题大部分安防摄像头部署在局域网或通过NAT隔离。从公网的Unity客户端比如WebGL应用直接访问局域网的RTSP端口默认554几乎不可能需要复杂的网络配置如端口映射、内网穿透这在很多客户现场是行不通的。安全性直接暴露RTSP端口和认证信息到公网有安全风险。萤石云等平台提供了Token鉴权、流加密等安全机制。平台兼容性海康、大华的设备SDK如HCNetSDK通常提供的是C/C或Java库在Unity的某些平台尤其是WebGL和iOS上集成和编译非常困难甚至不可行。流媒体格式设备原始的RTSP流可能是H.264/H.265编码需要额外的解码库才能在Unity中渲染。而云平台往往提供了更友好的输出格式比如FLV、HLS甚至WebRTC。萤石云API方案的优势标准化接入萤石云为海康威视及其兼容设备提供了统一的云服务平台。通过其开放的API我们可以用标准的HTTP/HTTPS请求获取到经过转码、适配网络环境的直播流地址。跨网络访问只要设备在线无论它在哪里我们都能通过公网API获取到可访问的流地址完美解决了网络穿透问题。功能丰富除了直播流API还提供了设备管理、云台控制、录像回放、报警信息订阅等一系列功能为项目扩展留下了空间。相对轻量我们只需要处理HTTP请求和JSON解析无需集成庞大的设备原生SDK项目更简洁跨平台部署更容易。所以如果你的摄像头已经注册到了萤石云这是海康设备的常见做法或者你愿意将设备接入萤石云那么通过其API获取视频流是最务实、最稳定的方案。2.2 Unity端播放流媒体的技术选型拿到流地址通常是一个URL后下一步就是在Unity里把它播出来。这里有几个主流方案方案一使用VideoPlayer组件 透明通道这是Unity原生的方案。VideoPlayer支持播放网络视频VideoSource.Url。你可以将获取到的直播流URL例如萤石云提供的HLSm3u8地址直接赋给VideoPlayer。播放的内容可以渲染到RenderTexture再赋值给UI的RawImage或3D物体的Material。优点原生支持无需第三方插件对于HLS格式兼容性较好。缺点对RTMP、FLV等格式支持依赖平台如需要Android上集成ExoPlayer扩展延迟相对较高自定义控制如抓帧、分析能力弱。方案二集成FFmpeg或原生解码库通过C#调用本地FFmpeg库或者使用诸如libvlcfor Unity的插件如VLC for Unity进行软解码或硬解码。优点格式支持最全RTSP RTMP FLV HLS等延迟可控功能强大可自定义解码前、后的数据处理。缺点集成复杂度高库体积大跨平台尤其是WebGL支持非常困难甚至不可能。方案三使用专门的Unity流媒体插件市场上有一些成熟的付费插件如AVPro Video、uWebRTC等。它们封装了底层解码和渲染逻辑。优点开箱即用功能强大跨平台支持好有技术支持。缺点需要付费项目成本增加。方案四WebView或浏览器内核嵌入对于WebGL平台一个取巧的办法是将视频流在一个网页中播放然后通过Unity与JavaScript的交互来控制。或者使用能嵌入浏览器内核的插件在PC/移动端。优点可以充分利用浏览器强大的视频播放能力。缺点集成复杂性能开销大不适合需要与3D场景深度交互的场景。实操心得对于大多数以展示为主的数字孪生、监控大屏项目如果延迟要求不是极致的毫秒级比如游戏对战方案一VideoPlayer HLS是性价比最高的选择。萤石云API恰好提供了HLS格式的流地址与VideoPlayer是天作之合。这个方案实现简单、跨平台iOS/Android/PC基本没问题WebGL需注意且完全免费。本项目也将主要围绕这个方案展开。如果你的项目对延迟有苛刻要求1秒可能需要考虑方案二或三但那将是另一个复杂得多的课题。3. 萤石云API接入核心流程解析3.1 前期准备获取API密钥与设备信息在写代码之前你需要先在萤石云开放平台 open.ys7.com 完成以下准备工作注册开发者账号并创建应用登录开放平台创建一个应用。应用类型根据你的实际场景选择如“自用型应用”。创建成功后你会获得至关重要的AppKey和Secret。这两个参数相当于你调用API的账号和密码务必妥善保管不要泄露在客户端代码中重要。将摄像头接入萤石云确保你的海康或大华摄像头已经添加到萤石云账户下。在萤石云App或官网中你可以看到设备的序列号deviceSerial通常是一串字母数字组合和验证码如果有。同时确认设备已开启“视频分享”或相关权限。获取设备通道号一个摄像头可能有多通道如主码流、子码流。默认通道号通常是1。你可以在设备管理页面查看。注意事项AppKey和Secret是最高权限的凭证。绝对不要将它们硬编码在Unity的C#脚本里尤其是准备打包发布到客户端如PC、手机的版本。任何反编译工具都能轻易提取出这些字符串导致你的账户被盗用产生流量费用或安全风险。正确的做法是自己搭建一个简单的后端代理服务。Unity客户端只与你自己的服务器通信由服务器保管Secret并代为调用萤石云API。这是生产环境必须遵守的安全规范。下文为了演示完整流程代码中会包含这些参数但请务必牢记这一点。3.2 API调用链与AccessToken管理萤石云API调用遵循OAuth 2.0的客户端凭证模式。核心流程分为两步第一步获取访问令牌AccessToken这是所有后续API调用的“门票”。你需要使用AppKey和Secret来换取一个有一定有效期的AccessToken。接口POST /api/lapp/token/get参数appKey,appSecret返回包含accessToken有效期默认约7天、过期时间等。第二步使用Token获取设备直播地址拿到AccessToken后就可以查询指定设备的直播流地址了。接口POST /api/lapp/v2/live/address/get参数accessToken,deviceSerial设备序列号,channelNo通道号默认为1,protocol流协议如rtmp,hls,flv,quality视频质量如2代表高清返回包含url直播地址、expireTime地址过期时间等。Token的管理策略 由于Token有有效期我们不能每次播放视频都去申请一次。一个合理的策略是在Unity客户端启动时向自己的后端服务器请求一个可用的直播地址后端服务器会管理Token的获取与刷新。或者客户端首次请求时后端返回Token和地址客户端在内存中缓存这个地址直到播放失败可能因为地址过期再重新向后端请求。在下面的源码实现中为了保持逻辑完整我们将演示一个不安全的、仅供学习测试的客户端直连版本。它会直接在C#里调用萤石云API。再次强调产品化时请务必改为通过你自己的后端服务器中转。4. Unity C# 核心源码实现与详解接下来我们创建一个Unity C#脚本命名为EzvizStreamFetcher.cs。这个脚本将负责与萤石云API交互并控制VideoPlayer播放。4.1 定义数据结构与常量首先定义与API返回格式对应的数据结构以及所需的常量。using System; using UnityEngine; using UnityEngine.Networking; // 用于UnityWebRequest using UnityEngine.Video; using System.Collections; using System.Text; using System.Collections.Generic; [System.Serializable] public class EzvizTokenResponse { public string code; // 状态码 200表示成功 public string msg; // 消息 public EzvizTokenData data; } [System.Serializable] public class EzvizTokenData { public string accessToken; // 访问令牌 public long expireTime; // 过期时间戳毫秒 } [System.Serializable] public class EzvizLiveAddressResponse { public string code; public string msg; public EzvizLiveAddressData data; } [System.Serializable] public class EzvizLiveAddressData { public string url; // 直播流地址例如 https://hls.open.ys7.com/openlive/xxxxxx.m3u8 public long expireTime; public int delayTime; // 延迟时间 } public class EzvizStreamFetcher : MonoBehaviour { // 【警告】以下参数在生产环境中必须放在服务端 public string appKey 你的AppKey; public string appSecret 你的AppSecret; public string deviceSerial 你的设备序列号; public int channelNo 1; // 通道号 public string protocol hls; // 推荐使用hls兼容性好 public int quality 2; // 2-高清1-流畅 private string currentAccessToken ; private long tokenExpireTime 0; private VideoPlayer videoPlayer; private RenderTexture outputTexture; // 萤石云API地址 private const string API_BASE_URL https://open.ys7.com/api/lapp/; }代码解析这里定义了与萤石云API返回的JSON格式完全匹配的类结构。使用[System.Serializable]属性是为了方便Unity的JsonUtility进行序列化和反序列化。UnityWebRequest是Unity推荐的网络请求工具支持跨平台。我们将使用VideoPlayer组件进行播放。4.2 实现AccessToken获取方法这个方法负责调用第一个API获取AccessToken。我们会将其缓存起来。private IEnumerator GetAccessTokenAsync(System.Actionstring onSuccess, System.Actionstring onFailure) { string url API_BASE_URL token/get; WWWForm form new WWWForm(); form.AddField(appKey, appKey); form.AddField(appSecret, appSecret); using (UnityWebRequest request UnityWebRequest.Post(url, form)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { string jsonResponse request.downloadHandler.text; EzvizTokenResponse response JsonUtility.FromJsonEzvizTokenResponse(jsonResponse); if (response.code 200) { currentAccessToken response.data.accessToken; tokenExpireTime response.data.expireTime; Debug.Log($AccessToken 获取成功: {currentAccessToken.Substring(0, 20)}... 过期时间: {UnixTimeStampToDateTime(tokenExpireTime)}); onSuccess?.Invoke(currentAccessToken); } else { Debug.LogError($获取AccessToken失败: {response.msg} (代码: {response.code})); onFailure?.Invoke($API错误: {response.msg}); } } else { Debug.LogError($网络请求失败: {request.error}); onFailure?.Invoke($网络错误: {request.error}); } } } // 辅助方法将Unix时间戳毫秒转换为DateTime private DateTime UnixTimeStampToDateTime(long unixTimeStampMillis) { System.DateTime dtDateTime new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc); dtDateTime dtDateTime.AddMilliseconds(unixTimeStampMillis).ToLocalTime(); return dtDateTime; }实操要点这里使用了WWWForm来构建表单提交的POST请求。注意UnityWebRequest在完成使用后最好包裹在using语句中或手动调用Dispose()以释放网络资源这是一个好习惯。成功获取Token后我们将其存储在成员变量中并记录过期时间。在实际项目中你应该将这个时间与当前时间对比在Token即将过期前主动刷新。4.3 实现直播流地址获取方法有了Token我们就可以获取最终的视频流播放地址了。private IEnumerator GetLiveStreamUrlAsync(string accessToken, System.Actionstring onSuccess, System.Actionstring onFailure) { // 检查Token是否有效简单检查 if (string.IsNullOrEmpty(accessToken)) { onFailure?.Invoke(AccessToken无效); yield break; } string url API_BASE_URL v2/live/address/get; WWWForm form new WWWForm(); form.AddField(accessToken, accessToken); form.AddField(deviceSerial, deviceSerial); form.AddField(channelNo, channelNo); form.AddField(protocol, protocol); form.AddField(quality, quality); using (UnityWebRequest request UnityWebRequest.Post(url, form)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { string jsonResponse request.downloadHandler.text; EzvizLiveAddressResponse response JsonUtility.FromJsonEzvizLiveAddressResponse(jsonResponse); if (response.code 200) { string liveUrl response.data.url; Debug.Log($直播流地址获取成功: {liveUrl}); onSuccess?.Invoke(liveUrl); } else { Debug.LogError($获取直播地址失败: {response.msg} (代码: {response.code})); // 常见错误Token过期错误码 10002 if (response.code 10002) { Debug.Log(AccessToken可能已过期尝试重新获取...); // 这里可以触发Token刷新逻辑 } onFailure?.Invoke($API错误: {response.msg}); } } else { Debug.LogError($网络请求失败: {request.error}); onFailure?.Invoke($网络错误: {request.error}); } } }关键点解析这个方法接收上一步获取的accessToken作为参数。注意我们传入了protocol参数这里指定为”hls”因为我们要用Unity的VideoPlayer播放。如果获取失败并且错误码是”10002”这通常意味着Token过期在实际逻辑中应该触发重新获取Token的流程。4.4 整合流程与VideoPlayer播放控制现在我们将上述步骤串联起来并初始化VideoPlayer来播放获取到的URL。void Start() { // 初始化VideoPlayer组件 videoPlayer gameObject.AddComponentVideoPlayer(); videoPlayer.playOnAwake false; videoPlayer.waitForFirstFrame true; // 等待第一帧加载 videoPlayer.skipOnDrop true; // 允许丢帧以追赶实时流 videoPlayer.source VideoSource.Url; // 创建RenderTexture用于视频输出 outputTexture new RenderTexture(1920, 1080, 24); videoPlayer.targetTexture outputTexture; // 开始获取视频流的流程 StartCoroutine(FetchAndPlayStream()); } private IEnumerator FetchAndPlayStream() { Debug.Log(开始获取萤石云直播流...); string token null; bool tokenSuccess false; // 步骤1获取AccessToken yield return StartCoroutine(GetAccessTokenAsync( (accessToken) { token accessToken; tokenSuccess true; }, (error) { Debug.LogError($获取Token失败: {error}); } )); if (!tokenSuccess) yield break; string streamUrl null; bool urlSuccess false; // 步骤2使用Token获取直播地址 yield return StartCoroutine(GetLiveStreamUrlAsync(token, (url) { streamUrl url; urlSuccess true; }, (error) { Debug.LogError($获取直播地址失败: {error}); } )); if (!urlSuccess) yield break; // 步骤3使用VideoPlayer播放 PlayVideo(streamUrl); } private void PlayVideo(string url) { if (videoPlayer null) return; videoPlayer.url url; videoPlayer.Prepare(); // 准备视频 // 监听准备完成事件 videoPlayer.prepareCompleted (VideoPlayer source) { Debug.Log($视频准备就绪开始播放。分辨率: {source.width}x{source.height}); source.Play(); // 将RenderTexture赋值给一个RawImage或Material // 例如GetComponentRawImage().texture outputTexture; }; // 监听错误事件 videoPlayer.errorReceived (VideoPlayer source, string message) { Debug.LogError($视频播放出错: {message}); // 可以在这里加入重试逻辑比如重新获取流地址 }; } void OnDestroy() { if (videoPlayer ! null) { videoPlayer.Stop(); } if (outputTexture ! null) { outputTexture.Release(); } }播放设置详解waitForFirstFrame true: 对于网络流等待第一帧加载完成再开始播放可以避免黑屏或初始卡顿。skipOnDrop true: HLS是流式传输网络波动可能导致缓冲。开启此选项允许播放器在缓冲不足时跳过一些帧以保持实时性这对直播场景很重要。Prepare(): 这是一个异步操作不会阻塞主线程。我们需要监听prepareCompleted事件来知道何时可以开始播放。errorReceived: 网络不稳定、流地址失效等情况都会触发此事件这是进行错误处理和重试的关键入口。4.5 在UI或3D物体上显示视频最后一步是将VideoPlayer输出的RenderTexture显示出来。在UI上显示如RawImage在Canvas上创建一个RawImage组件。将脚本挂载到任意GameObject上并配置好参数。在PlayVideo方法的prepareCompleted事件回调中添加一行代码// 假设你有一个public RawImage targetRawImage的引用 if (targetRawImage ! null) { targetRawImage.texture outputTexture; }在3D物体上显示如材质球创建一个3D物体如Quad或Plane。为其创建一个新的材质MaterialShader选择Unlit/Texture或Standard需要调整。在脚本中将这个材质的Main Texture赋值为outputTexture。public Renderer targetRenderer; // 拖拽你的3D物体的Renderer组件到此 ... videoPlayer.prepareCompleted (VideoPlayer source) { source.Play(); if (targetRenderer ! null) { targetRenderer.material.mainTexture outputTexture; } };5. 常见问题、排查技巧与优化建议在实际部署和测试中你几乎一定会遇到下面这些问题。这里我把踩过的坑和解决方案整理出来。5.1 网络与API调用问题问题1UnityWebRequest报错 “Cannot connect to destination host”排查这通常是网络连通性问题。首先检查Unity编辑器或打包后的应用是否能正常访问互联网。其次检查萤石云API地址open.ys7.com是否被防火墙或网络策略屏蔽在某些企业内网可能出现。解决尝试在浏览器中直接访问https://open.ys7.com看是否能打开。如果不行需要配置网络代理或调整防火墙规则。问题2API返回错误码 “10002” (非法访问令牌) 或 “10005” (accessToken过期)排查这是最常见的问题。说明你使用的AccessToken无效或已过期。解决实现Token刷新机制在发起获取直播地址的请求前检查本地保存的tokenExpireTime是否已接近或超过当前时间。如果是则先调用GetAccessTokenAsync获取新的Token。错误重试在GetLiveStreamUrlAsync的失败回调中如果识别到错误码是10002或10005则自动触发一次重新获取Token并重试获取流地址的流程。问题3API返回错误码 “20002” (设备不存在) 或 “20032” (设备不在线)排查deviceSerial设备序列号填写错误或者设备确实没有接入萤石云或设备当前离线。解决登录萤石云官网或App仔细核对设备的序列号。确保设备电源和网络连接正常在萤石云平台上显示为在线状态。5.2 视频播放与渲染问题问题4VideoPlayer一直处于Preparing状态不开始播放排查HLSm3u8地址可能无效或者网络环境无法流畅加载流媒体数据。也可能是VideoPlayer对该格式的支持问题。解决将获取到的streamUrl复制到电脑的VLC播放器中测试看是否能播放。如果不能说明流地址本身有问题需要检查API调用参数如protocol,quality。在Unity中检查VideoPlayer的errorReceived事件看是否有具体的错误信息。尝试降低清晰度将quality设为1或者更换protocol为flv如果VideoPlayer目标平台支持试试。问题5视频播放卡顿、延迟高十几秒以上排查这是HLS协议的固有特性。HLS为了保障流畅性会将视频切片传输通常会有10-30秒的延迟不适合需要实时交互的场景。解决接受延迟如果只是用于监控查看这个延迟通常可以接受。寻求低延迟方案萤石云API也支持rtmp和flv协议延迟可以降到3-5秒。但Unity原生的VideoPlayer在大部分平台上不支持直接播放RTMP/FLV。你需要集成第三方插件如AVPro Video付费支持好或尝试使用FFmpeg库进行解码复杂跨平台坑多。使用WebRTC终极方案萤石云部分高端设备和支持WebRTC协议的接入服务。WebRTC可以实现亚秒级延迟。但这需要在Unity中集成WebRTC库如Unity官方WebRTC包并与萤石云的WebRTC信令服务器对接复杂度最高。问题6在UI上显示视频但画面扭曲或比例不对排查RawImage的RectTransform尺寸或RenderTexture的尺寸与视频源分辨率不匹配。解决在videoPlayer.prepareCompleted事件中可以获取到视频的原始宽高source.width,source.height。根据宽高比动态调整RawImage所在RectTransform的尺寸或者调整RawImage的uvRect来保持比例。也可以创建RenderTexture时使用视频的原始分辨率但可能性能开销大。5.3 安全与架构优化建议安全加固必须做 如前所述将AppKey和Secret放在客户端是极度危险的。请务必搭建一个轻量级后端服务可以用Node.js, Python Flask, C# ASP.NET Core等任何你熟悉的技术。客户端只向你自己的服务器发送请求例如GET /api/camera/stream?deviceIdxxx。服务器接收请求后用保存在服务器环境变量或配置文件中的AppKey和Secret去调用萤石云API获取流地址然后返回给客户端。同时服务器端可以实现Token的缓存和自动刷新效率更高。性能与体验优化预加载与缓存在场景加载时就提前开始获取Token和流地址而不是等用户点击时才进行减少等待时间。多个摄像头管理如果需要同时播放多个摄像头不要为每个摄像头都创建独立的VideoPlayer组件并同时Prepare。这会导致巨大的网络和CPU开销。应该实现一个视频流管理池按需加载和播放。错误重试与状态提示网络请求和视频播放都可能失败。设计良好的重试机制如指数退避和用户友好的加载中、错误提示界面能极大提升产品体验。释放资源在不需要播放时如切换场景、关闭窗口务必调用videoPlayer.Stop()并释放RenderTextureoutputTexture.Release()如OnDestroy方法中所做防止内存泄漏。这套从萤石云API调用到Unity内播放的完整链路我已经在多个数字孪生项目中实际应用过。核心难点不在于代码本身而在于对网络流媒体协议的理解、跨平台兼容性的把握以及生产环境下的安全架构设计。希望这份详细的拆解和源码能帮你避开我当年踩过的那些坑顺利把摄像头视频流搬进你的Unity世界。