Unity3D 播放 RTSP 视频流:FFmpeg 拉流解码与低延迟优化实战
发布时间:2026/10/7 14:33:07 锦皓数字建站

简介这份资源面向需要在Unity3D中播放RTSP实时视频流的开发者尤其适合做视频监控、在线直播、VR/AR交互展示等项目的初中级工程师。由于Unity原生播放器不支持RTSP协议资源借助VLCMediaPlayer for Unity插件打通播放链路并给出从插件导入、依赖配置到C#脚本控制播放、暂停、停止的完整实现思路同时涉及视频输出到相机纹理、UI Image显示以及网络缓存、错误处理与性能调优等实践要点。压缩包为rar格式共2274个文件约36.54MB其中dll动态库、meta与info配置、luac与bin资源、png贴图、asset与prefab场景资源等构成插件运行与示例工程的主体另有少量cs脚本、json与xml配置便于二次修改。目前已有1452人学习下载可作为快速验证RTSP播放方案、排查平台依赖与缓冲问题的参考素材。1. Unity3D 播放 RTSP 视频从取流地址到画面落地的完整路径安防监控、工业巡检、车载回传这类项目里十个需求有八个绕不开「在 Unity3D 里把摄像头画面显示出来」。而现场设备给出的地址绝大多数是 RTSP 拉流协议——海康、大华、萤石这些厂商的摄像头默认输出就是 RTSP主码流子码流各一条地址。问题在于Unity3D 原生只认本地视频文件和 VideoPlayer 组件支持的几种格式RTSP 这种实时流它压根不接。于是就有了这个标题要解决的事怎么让 Unity3D 稳定地把 RTSP 视频流播出来延迟压到可接受范围还得在安卓、Windows 这些目标平台上都跑得动。这篇内容面向的是手里已经拿到摄像头 RTSP 取流地址、需要在 Unity 项目里落地的开发者从协议理解到代码实现再到参数调优和踩坑排查一步步走完。2. RTSP 在 Unity3D 里为什么不能直接播协议链路与选型逻辑2.1 RTSP 拉流协议到底传了什么RTSP 本身不传视频数据它是个控制协议负责和摄像头「对话」——DESCRIBE 拿媒体描述SETUP 建传输通道PLAY 开始推数据。真正的视频帧走的是 RTP 包封装在 UDP 或 TCP 之上。摄像头把 H.264 或 H.265 编码后的帧切成 RTP 包发出来客户端收包、组帧、解码、渲染这一整条链路 Unity3D 的 VideoPlayer 是不管的。理解这一点很关键你要在 Unity3D 里播 RTSP本质上是要在 Unity 的渲染循环里塞进一个完整的「拉流 解码 纹理上传」管线。这条管线里拉流和解码是重活必须放到原生层或者独立线程不能卡在主线程上否则画面没出来项目先卡死了。常见做法有两类。一类是用 FFmpeg 做拉流和解码把解码后的帧通过共享内存或纹理指针交给 Unity 渲染另一类是用平台原生的媒体框架安卓上走 MediaPlayer 或 ExoPlayerWindows 上走 Media Foundation再桥接到 Unity。前者跨平台一致性好后者平台性能更优但代码分支多。我一般会优先选 FFmpeg 方案因为 RTSP 的兼容性坑太多FFmpeg 的协议栈经过大量设备验证省心。2.2 主码流和子码流选错了延迟直接翻倍海康、大华这些摄像头通常提供两条 RTSP 地址主码流和子码流。主码流分辨率高、码率高适合录像和本地显示子码流分辨率低、码率低适合网络传输和实时预览。很多开发者拿到地址就直接用主码流结果在 Unity3D 里延迟高、卡顿严重还以为是代码问题。实际项目里如果只是做实时预览子码流是更合理的选择。以海康为例主码流地址形如rtsp://admin:password192.168.1.64:554/Streaming/Channels/101子码流是.../102。101 对应通道 1 主码流102 对应通道 1 子码流。大华的结构类似rtsp://admin:password192.168.1.108:554/cam/realmonitor?channel1subtype0是主码流subtype1是子码流。选子码流的好处很直接1080P 主码流码率可能 4Mbps 起步子码流 720P 或 D1 通常 512Kbps 到 1Mbps网络抖动容忍度高解码压力小延迟自然低。代价是画面细节少如果业务需要看清车牌或人脸那就得在主码流和性能之间做权衡或者用双流方案——子码流预览需要时切主码流抓拍。2.3 Unity3D 侧渲染管线怎么接解码出来的帧要变成 Unity 里的 Texture2D中间有个数据搬运过程。FFmpeg 解码后得到的是 YUV 格式数据常见的是 YUV420P。Unity 的 Texture2D 支持 RGB 格式所以需要做 YUV 到 RGB 的转换。这个转换可以在 CPU 做也可以在 GPU 做。CPU 转换简单用 FFmpeg 的 sws_scale 直接转成 RGBA然后Texture2D.LoadRawTextureData上传。缺点是每帧都要在 CPU 上跑一遍转换1080P 下开销不小。GPU 转换效率高思路是把 Y、U、V 三个分量分别传到三张纹理在 Shader 里做色彩空间转换。这种做法在移动端尤其值得因为移动 GPU 对纹理采样很擅长能省下 CPU 算力给其他逻辑。我一般会在 Windows 平台用 CPU 转换快速验证安卓平台切 GPU 转换。如果项目对延迟极其敏感比如车载视频回传这种场景GPU 方案是必选项因为 CPU 转换引入的额外延迟在 30ms 到 50ms 量级累积起来很可观。3. 用 FFmpeg 在 Unity3D 里跑通 RTSP 的最小实现3.1 环境准备与依赖库放置第一步是把 FFmpeg 的动态库准备好。Windows 平台需要avcodec、avformat、avutil、swscale、swresample这几个 DLL放到 Unity 项目的Assets/Plugins/x86_64目录下。安卓平台需要对应 ABI 的.so文件通常是arm64-v8a和armeabi-v7a两套放到Assets/Plugins/Android/libs/对应目录。FFmpeg 版本选择上建议用 4.x 或 5.x 的稳定版。太老的版本对 H.265 支持不好太新的版本 API 变动大网上能找到的 Unity 封装代码不一定兼容。我一般会锁定一个经过验证的版本把库文件随项目一起管理避免不同机器上版本不一致导致的玄学问题。C# 侧需要写 P/Invoke 声明来调用 FFmpeg 的 C 接口。核心函数包括avformat_open_input、avformat_find_stream_info、avcodec_find_decoder、avcodec_open2、av_read_frame、avcodec_send_packet、avcodec_receive_frame、sws_scale这些。声明时注意参数类型和调用约定Windows 下用CallingConvention.Cdecl安卓下也是 Cdecl。// FFmpeg P/Invoke 声明片段Windows/Android 通用 using System; using System.Runtime.InteropServices; public static class FFmpegNative { private const string DllName avformat; [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern int avformat_open_input(out IntPtr ps, string url, IntPtr fmt, IntPtr options); [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern int avformat_find_stream_info(IntPtr ic, IntPtr options); [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern int av_read_frame(IntPtr ic, out AVPacket packet); [DllImport(DllName, CallingConvention CallingConvention.Cdecl)] public static extern void av_packet_unref(ref AVPacket packet); }这段声明只是入口实际项目里还需要把avcodec、avutil、swscale的相关函数都声明出来。参数说明上avformat_open_input的url就是 RTSP 地址options可以传字典设置超时、传输协议等参数后面会细说。av_read_frame每次读一个 packet读完必须av_packet_unref释放否则内存泄漏跑不掉。3.2 拉流线程与解码循环的代码骨架拉流和解码必须放在独立线程不能占用 Unity 主线程。下面是一个最小循环的骨架实际项目里需要加上线程安全的状态管理和退出标志。// 拉流解码线程核心逻辑简化版 private void StreamLoop() { IntPtr formatCtx IntPtr.Zero; // 打开 RTSP 流设置超时和 TCP 传输 AVDictionary options new AVDictionary(); av_dict_set(ref options, rtsp_transport, tcp, 0); av_dict_set(ref options, stimeout, 5000000, 0); // 5秒超时单位微秒 int ret avformat_open_input(out formatCtx, rtspUrl, IntPtr.Zero, options); if (ret 0) { Debug.LogError(打开 RTSP 失败); return; } ret avformat_find_stream_info(formatCtx, IntPtr.Zero); if (ret 0) { Debug.LogError(找不到流信息); return; } // 找到视频流索引 int videoIndex -1; for (int i 0; i formatCtx-nb_streams; i) { if (formatCtx-streams[i]-codecpar-codec_type AVMEDIA_TYPE_VIDEO) { videoIndex i; break; } } // 打开解码器 IntPtr codecCtx avcodec_alloc_context3(IntPtr.Zero); avcodec_parameters_to_context(codecCtx, formatCtx-streams[videoIndex]-codecpar); IntPtr codec avcodec_find_decoder(codecCtx-codec_id); avcodec_open2(codecCtx, codec, IntPtr.Zero); // 解码循环 AVPacket packet new AVPacket(); AVFrame frame av_frame_alloc(); while (isRunning) { ret av_read_frame(formatCtx, out packet); if (ret 0) { /* 处理断流重连 */ break; } if (packet.stream_index videoIndex) { avcodec_send_packet(codecCtx, ref packet); while (avcodec_receive_frame(codecCtx, frame) 0) { // 此处将 frame 转为 RGB 并上传纹理 ConvertAndUpload(frame); } } av_packet_unref(ref packet); } }逻辑说明先设置rtsp_transport为tcp这是为了避免 UDP 丢包导致的画面花屏代价是延迟略高但稳定性好得多。stimeout设置超时防止网络断开时线程卡死。找到视频流后打开解码器进入循环读包、送解码、收帧。avcodec_send_packet和avcodec_receive_frame是 FFmpeg 3.x 之后的新 API一个 packet 可能解出多帧所以 receive 要循环调用直到返回错误码表示当前没有更多帧。参数说明stimeout单位是微秒5000000 就是 5 秒。如果现场网络质量差可以适当加大到 10 秒但太大会导致断流后重连反应慢。rtsp_transport还可以设成udp延迟更低但丢包时画面会花安防场景一般不建议。3.3 YUV 转 RGB 与纹理上传解码出来的 frame 是 YUV420P 格式需要转成 RGBA 才能上传给 Texture2D。用 sws_scale 做转换转换目标缓冲区提前分配好避免每帧分配内存。// YUV 转 RGBA 并上传纹理 private void ConvertAndUpload(IntPtr frame) { int width frame-width; int height frame-height; // 初始化转换上下文只做一次 if (swsCtx IntPtr.Zero) { swsCtx sws_getContext(width, height, frame-format, width, height, AVPixelFormat.AV_PIX_FMT_RGBA, SWS_BILINEAR, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); rgbaBuffer new byte[width * height * 4]; } // 将 frame 数据转到 rgbaBuffer IntPtr[] srcData { frame-data[0], frame-data[1], frame-data[2] }; int[] srcLinesize { frame-linesize[0], frame-linesize[1], frame-linesize[2] }; IntPtr dstData Marshal.AllocHGlobal(rgbaBuffer.Length); int[] dstLinesize { width * 4 }; sws_scale(swsCtx, srcData, srcLinesize, 0, height, new IntPtr[] { dstData }, dstLinesize); Marshal.Copy(dstData, rgbaBuffer, 0, rgbaBuffer.Length); Marshal.FreeHGlobal(dstData); // 主线程更新纹理 lock (textureLock) { pendingTextureData rgbaBuffer; textureDirty true; } }逻辑说明sws_getContext只需要初始化一次后续复用。sws_scale把 YUV 三个平面的数据转换成连续的 RGBA 缓冲区。转换完的数据通过锁机制交给主线程主线程在 Update 里检查textureDirty标志调用Texture2D.LoadRawTextureData和Apply更新画面。参数说明SWS_BILINEAR是缩放算法如果解码分辨率和显示分辨率一致可以用SWS_POINT更快。rgbaBuffer大小是宽乘高乘 41080P 下约 8MB每帧拷贝这个量级的数据在移动端有压力所以安卓上更推荐 GPU 转换方案。纹理更新时注意Texture2D的格式要设成RGBA32并且创建时标记为linear或sRGB要和项目色彩空间匹配否则画面颜色会偏。4. 避坑与排查RTSP 在 Unity3D 里翻车的五个典型场景4.1 画面黑屏但日志无报错现象代码跑起来日志显示流打开成功、解码器也打开了但 Unity 里就是黑屏没有任何错误。原因最常见的是纹理更新没生效。解码线程把数据写进了缓冲区但主线程没有正确读取或者Texture2D.Apply没调用。另一个可能是 RTSP 地址的通道号写错了比如海康的 101 写成了 201摄像头返回了流但内容是空的。解决先在解码循环里打印 frame 的 width 和 height确认有实际解码输出。然后在主线程 Update 里加日志确认textureDirty被置位。如果数据都有但画面还是黑检查 Shader 或 RawImage 的材质是否正确绑定了纹理。通道号问题直接拿 VLC 或 ffplay 验证地址ffplay rtsp://...能出画面说明地址没问题。4.2 延迟越跑越大几分钟后画面滞后十几秒现象刚启动时延迟一两秒跑一段时间后延迟累积画面越来越滞后。原因解码速度跟不上拉流速度packet 在缓冲区里堆积。FFmpeg 默认会缓冲一定量的数据如果解码线程处理慢缓冲区越积越多。另一个原因是渲染帧率低于解码帧率纹理更新排队。解决在拉流循环里加丢帧逻辑当缓冲区堆积超过阈值时丢弃非关键帧只保留 I 帧。具体做法是在av_read_frame之后判断 packet 的flags是否包含AV_PKT_FLAG_KEY如果不是且队列长度超限就跳过。另外把avcodec_open2的thread_count设为 0 让 FFmpeg 自动多线程解码能明显提升解码速度。渲染侧确保 Update 里每帧都检查纹理更新不要用协程隔帧更新。4.3 安卓平台上直接崩溃或提示找不到库现象Windows 编辑器里跑得好好的打包到安卓安装后闪退Logcat 显示UnsatisfiedLinkError或dlopen failed。原因.so文件没有放到正确的 ABI 目录或者 FFmpeg 编译时没有开启对应架构的支持。另一个常见原因是 Unity 的Player Settings里Target Architectures只勾了ARM64但.so只放了armeabi-v7a。解决确认Assets/Plugins/Android/libs/arm64-v8a/和armeabi-v7a/下都有对应的.so。用unzip -l检查 APK 里lib/目录下是否包含这些库。如果用了 IL2CPP 打包还要注意 FFmpeg 库的编译工具链要和 IL2CPP 一致否则符号冲突。安卓 10 以上还需要在AndroidManifest.xml里声明网络权限虽然 RTSP 不走 HTTP但底层 socket 需要INTERNET权限。4.4 海康摄像头取流地址正确但连不上现象用 ffplay 能播但 Unity 里avformat_open_input返回失败错误码是-138或超时。原因海康摄像头默认可能开启了摘要认证FFmpeg 对某些认证方式支持不完整。另一个原因是摄像头同时连接的客户端数有限制VLC 占了一个连接Unity 再连就被拒了。解决先在地址里显式带上用户名密码格式rtsp://admin:passwordip:554/...。如果还不行在avformat_open_input的 options 里加rtsp_flags设为prefer_tcp强制 TCP 传输。连接数限制的话关掉 VLC 再试或者进摄像头 Web 后台把最大连接数调大。海康的 RTSP 地址里通道号规则是101主码流、102子码流201是通道 2别搞混。4.5 画面花屏、绿屏或颜色错乱现象画面能出来但颜色不对偏绿或偏紫或者局部花屏。原因颜色错乱通常是 YUV 转 RGB 时的色彩空间没匹配比如摄像头输出 BT.709 但转换时用了 BT.601。花屏多半是 UDP 传输丢包RTP 包不完整导致解码器输出损坏帧。解决颜色问题在sws_getContext里指定SWS_CS_ITU709或SWS_CS_ITU601根据摄像头实际输出选。不确定就两个都试看哪个颜色正常。花屏问题把rtsp_transport改成tcp牺牲一点延迟换稳定性。如果 TCP 下还花检查解码器是否支持摄像头的编码格式H.265 需要 FFmpeg 编译时开启hevc解码器有些精简编译版本不带。5. 延迟压到 200ms 以内三个可验证的调优手段5.1 用子码流加 TCP 传输做基线先把基线跑通子码流地址 TCP 传输 解码线程独立。这套组合在局域网下延迟通常在 300ms 到 500ms。验证方法是拿手机秒表对着摄像头看 Unity 画面和真实秒表的差值。如果超过 1 秒说明缓冲区堆积回到 4.2 的丢帧逻辑处理。子码流的选择上海康子码流默认可能是 640x480 或 704x576码率 512Kbps 左右。如果业务允许把子码流分辨率调到 720P码率 1Mbps画质和延迟的平衡点比较好。大华的子码流参数在 Web 后台的「视频」设置里改改完记得重启摄像头。5.2 解码器参数与缓冲区控制FFmpeg 打开解码器时把thread_count设为 0 启用自动多线程flags里加AV_CODEC_FLAG_LOW_DELAY降低解码延迟。另外在avformat_open_input之前设置max_delay为 0fflags设为nobuffer让 FFmpeg 不额外缓冲。// 低延迟参数设置 av_dict_set(ref options, rtsp_transport, tcp, 0); av_dict_set(ref options, stimeout, 3000000, 0); av_dict_set(ref options, max_delay, 0, 0); av_dict_set(ref options, fflags, nobuffer, 0); av_dict_set(ref options, flags, low_delay, 0);这些参数的效果是减少 FFmpeg 内部的排队和等待。max_delay为 0 表示不额外等待nobuffer关闭输入缓冲。代价是对网络抖动的容忍度降低网络差的时候容易断流所以只适合局域网或质量好的专线。5.3 渲染侧用 GPU 转换替代 CPU 转换CPU 转换在 1080P 下每帧耗时 10ms 到 20msGPU 转换能压到 2ms 以内。实现思路是把 Y、U、V 三个平面分别创建成Texture2D格式用R8然后在 Shader 里采样三张纹理按 YUV 转 RGB 公式计算最终颜色。Shader 里的转换公式// YUV420P 转 RGB 的 Shader 核心 float y tex2D(_YTex, i.uv).r; float u tex2D(_UTex, i.uv).r - 0.5; float v tex2D(_VTex, i.uv).r - 0.5; // BT.709 转换矩阵 float r y 1.5748 * v; float g y - 0.1873 * u - 0.4681 * v; float b y 1.8556 * u; return float4(r, g, b, 1.0);Y、U、V 纹理的更新在解码线程完成用Texture2D.LoadRawTextureData分别上传三个平面的数据。注意 U、V 平面在 YUV420P 下宽高是 Y 的一半上传时数据量小开销低。Shader 里采样 U、V 时要注意纹理坐标的缩放或者直接用tex2D采样时依赖 GPU 的线性插值。这套方案在安卓中低端机上能把解码到显示的延迟压到 150ms 左右配合子码流和 TCP整体延迟 200ms 以内可以做到。验证方法还是秒表对比同时用Profiler看 CPU 占用GPU 方案下 CPU 解码线程占用应该明显低于 CPU 转换方案。我自己的习惯是每接一个新摄像头型号先用 ffplay 验证地址和编码格式再在 Unity 里跑最小循环确认能出画面最后才上优化参数。这样出问题时能快速定位是流的问题还是代码的问题。RTSP 这东西设备厂商的实现差异比想象中大同一个品牌不同固件版本行为都可能不一样留好日志和降级方案比什么都重要。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。