1. 项目概述:为什么要在虚幻引擎里折腾RTSP?
如果你正在开发一个需要接入真实世界视频源的虚幻引擎项目,比如一个数字孪生监控大屏、一个虚拟演播室系统,或者一个需要实时分析摄像头画面的XR应用,那么RTSP(Real Time Streaming Protocol)这个协议你大概率绕不开。我最近刚完成一个智慧园区项目,核心需求就是把分布在几十个点位的安防摄像头画面,实时地“搬”到虚幻引擎构建的3D可视化场景里,并且还要能随时录制关键片段。整个过程踩了不少坑,也总结了一套相对稳定、高效的方案。
简单来说,RTSP就像是一个视频流的“遥控器”协议。它本身不传输音视频数据,而是负责建立和控制会话——比如播放、暂停。真正的音视频数据是通过RTP(Real-time Transport Protocol)传输的。在虚幻引擎(UE)里,原生并没有提供一个开箱即用的RTSP客户端组件。这意味着,你需要自己想办法把网络上的RTSP流“拉”下来,解码成一帧帧的图像,再喂给UE的纹理或媒体框架去渲染。这听起来有点复杂,但拆解开来,无非是“取流-解码-渲染-录制”四个核心环节。市面上常见的方案,要么依赖第三方插件,要么需要自己集成C++库。我这次选择的是后者,因为可控性更高,能更好地处理高并发、低延迟以及自定义的录制逻辑。
这个实战指南,就是把我从零开始,在UE5中实现RTSP流播放与实时录制的完整过程、技术选型思考和踩过的那些“坑”记录下来。无论你是想做一个简单的摄像头预览窗口,还是构建一个复杂的多路视频融合系统,这里面的思路和细节都能给你直接的参考。
2. 核心方案选型与架构设计
面对“在UE中集成RTSP”这个需求,第一步不是埋头写代码,而是根据你的项目约束(如平台、性能、功能复杂度)选择合适的技术路径。我调研并实践了以下几种主流方案,各有优劣。
2.1 方案一:使用现成的第三方插件
这是最快捷的入门方式。在虚幻商城里,你可以找到一些如“RTSP Media Player”或“VaRest”结合FFmpeg的变种插件。这些插件通常封装了底层的网络和解码逻辑,提供蓝图节点,让你通过一个URL就能创建视频播放器。
优点:
- 开发速度快:几乎零编码,拖拖节点就能出效果,适合原型验证或功能简单的项目。
- 降低门槛:对不熟悉C++和流媒体协议的开发者友好。
缺点与坑点:
- 黑盒化与定制难:插件内部如何实现解码、如何管理内存、网络超时怎么处理,你很难控制和优化。当遇到特定的流格式(如某些厂商的私有RTP负载)、需要特定的录制封装格式、或者需要深入统计丢包率时,你会非常被动。
- 性能与开销:一些插件可能为了通用性,采用了效率不是最高的解码路径(比如在CPU上进行软解码),或者每一路流都独立创建大量资源,在多路视频场景下,性能开销可能成为瓶颈。
- 许可与成本:商业插件需要付费,且可能涉及FFmpeg等库的License合规性问题,在商业项目中需要仔细评估。
- 平台兼容性:插件可能对Windows支持良好,但对Android、iOS或Linux的支持较弱,或者需要额外的配置。
注意:如果选用插件,务必在项目早期进行多路流压力测试和目标平台测试,避免后期发现性能或兼容性问题导致方案推翻。
2.2 方案二:集成FFmpeg库(推荐用于生产级项目)
这是我所采用的方案,也是我认为在追求可控性、性能和定制化时的最佳选择。FFmpeg是一套完整的跨平台音视频处理解决方案,几乎可以处理任何格式的编解码和协议。我们的任务就是将它集成到UE的C++模块中。
核心架构设计如下:
- RTSP客户端线程:我们创建一个独立的工作线程(FRunnable),专门负责通过
libavformat(FFmpeg的格式与协议处理库)打开RTSP URL,读取网络数据包。这个线程需要稳定运行,处理网络波动和重连。 - 解码线程:从RTSP客户端线程拿到压缩的视频数据包(AVPacket)后,送入解码队列。解码可以使用CPU软解(
libavcodec),也可以利用GPU硬解(如通过CUDA、DXVA2、VideoToolbox等API)。硬解能极大降低CPU占用,对于多路1080P以上的流至关重要。 - 纹理更新与渲染:解码后得到RGB或YUV格式的像素数据(AVFrame)。我们需要在UE的渲染线程(或通过RHI命令)安全地将这些数据拷贝到一个
UTexture2D或UTextureRenderTarget2D中。这里通常需要一次色彩空间转换(YUV to RGB)和尺寸缩放。 - 录制流水线:录制并非简单保存纹理截图。它需要同步音视频流,重新进行编码和封装。我们可以复用FFmpeg的编码器(如H.264)和封装器(如MP4或MKV),在另一个独立线程中,将解码后的AVFrame再次编码并写入文件。关键是要处理好时间戳(PTS/DTS),确保录制的文件播放顺畅。
为什么选择此方案?
- 极致可控:从网络超时、解码器选择、内存池管理到录制参数(码率、GOP大小),每一个环节都可以精细调控。
- 高性能:可以方便地集成GPU硬解,并设计高效的多线程流水线,避免阻塞游戏主线程。
- 灵活性:不仅能处理RTSP,还能轻松扩展支持RTMP、HTTP-FLV、SRT等协议。录制功能也可以灵活定制,比如按时间分段、动态添加水印或图形叠加(OSD)。
- 成本合规:你可以自己管理FFmpeg的编译和链接,选择符合项目许可证要求的编解码器。
2.3 方案三:使用平台原生API或中间件
在特定平台上,可能有更原生的选择。例如在Windows上,可以结合Media Foundation框架;在Android/iOS上,可以使用MediaPlayer或AVPlayer。也有一些专业的中间件,如Wowza、GStreamer(也可集成),它们提供了更上层的SDK。
适用场景:
- 目标平台单一,且该平台原生支持良好。
- 项目预算充足,可以使用商业中间件来降低开发复杂度。
- 对延迟要求不是极端苛刻,可以接受平台播放器带来的缓冲。
对于我们这个以PC(Windows/Linux)为主要部署环境,且需要低延迟多路处理和自定义录制的项目,方案二(集成FFmpeg)的优势是决定性的。
3. 详细实现步骤拆解
确定了FFmpeg集成方案后,我们进入具体的实现环节。这里以Windows平台、UE5.2为例,使用CPU软解进行说明(GPU硬解集成是类似的原理,但API调用更复杂)。
3.1 环境准备与FFmpeg集成
首先,你需要获取FFmpeg的开发库。强烈建议自己编译,而不是下载预编译的二进制文件,以确保编译器版本(MSVC)、运行时库(MT/MD)与你的UE项目完全匹配,避免诡异的运行时崩溃。
编译FFmpeg:
- 从官网下载源码,在MSYS2或Linux环境下进行交叉编译。关键配置参数包括
--enable-shared --enable-protocols --enable-demuxer=rtsp --enable-decoder=h264 --enable-encoder=libx264 --enable-filter=scale,format等,根据你的需要开启解码器、编码器和滤镜。 - 编译后得到
.lib(导入库)、.dll(动态库)和头文件(.h)。
- 从官网下载源码,在MSYS2或Linux环境下进行交叉编译。关键配置参数包括
在UE项目中集成:
- 在你的插件或游戏模块的
.Build.cs文件中,添加FFmpeg库的包含路径和链接库。
// YourModule.Build.cs PublicDependencyModuleNames.AddRange(...); PrivateDependencyModuleNames.AddRange(...); // 添加FFmpeg头文件路径 PublicIncludePaths.Add(Path.Combine(FFmpegDir, "include")); // 添加FFmpeg库文件路径 PublicAdditionalLibraries.Add(Path.Combine(FFmpegDir, "lib", "avcodec.lib")); PublicAdditionalLibraries.Add(Path.Combine(FFmpegDir, "lib", "avformat.lib")); PublicAdditionalLibraries.Add(Path.Combine(FFmpegDir, "lib", "avutil.lib")); PublicAdditionalLibraries.Add(Path.Combine(FFmpegDir, "lib", "swscale.lib")); PublicAdditionalLibraries.Add(Path.Combine(FFmpegDir, "lib", "swresample.lib")); // 如果处理音频 // 确保DLL在运行时可用(可复制到输出目录)- 将编译好的FFmpeg DLL(如
avcodec-59.dll,avformat-59.dll等)放置到你的可执行文件(.exe)同级目录,或者系统的PATH路径下。
- 在你的插件或游戏模块的
3.2 RTSP拉流与解码线程实现
这是最核心的C++类,我将其命名为FRTSPStreamReader,继承自FRunnable。
// 伪代码和关键步骤说明 class FRTSPStreamReader : public FRunnable { public: FRTSPStreamReader(const FString& InURL); virtual ~FRTSPStreamReader(); // FRunnable interface virtual bool Init() override; virtual uint32 Run() override; // 主工作循环在这里 virtual void Stop() override; virtual void Exit() override; bool IsFrameReady() const; bool GetLatestFrame(TArray<uint8>& OutRGBData, int32& OutWidth, int32& OutHeight); // 主线程调用此函数获取帧数据 void StartRecording(const FString& FilePath); void StopRecording(); private: FString StreamURL; FRunnableThread* Thread; FThreadSafeBool bStopping; // FFmpeg 上下文 AVFormatContext* FormatCtx; AVCodecContext* VideoCodecCtx; int VideoStreamIndex; AVPacket* Packet; AVFrame* Frame; AVFrame* RGBFrame; SwsContext* SwsCtx; // 帧数据缓存与同步 FCriticalSection FrameCriticalSection; TArray<uint8> CachedFrameData; int32 CachedWidth, CachedHeight; // 录制相关 AVFormatContext* OutputFormatCtx; AVCodecContext* OutputVideoCodecCtx; AVStream* OutputVideoStream; // ... 其他录制所需变量 };Init()函数关键操作:
avformat_open_input(&FormatCtx, TCHAR_TO_UTF8(*StreamURL), nullptr, nullptr): 打开RTSP流。这里要特别注意网络超时参数。FFmpeg默认的超时可能很长,对于不稳定的网络,需要自定义AVDictionary设置参数,如stimeout(以微秒为单位的socket超时),我通常设为3000000(3秒)。avformat_find_stream_info: 查找流信息。- 遍历
FormatCtx->streams,找到codecpar->codec_type == AVMEDIA_TYPE_VIDEO的流,记录其索引VideoStreamIndex。 avcodec_find_decoder和avcodec_open2: 根据流的编码ID(如AV_CODEC_ID_H264)找到解码器并打开。- 分配
Packet、Frame、RGBFrame。初始化SwsCtx(用于后续的YUV到RGB转换和尺寸缩放)。
Run()函数工作循环(核心):
uint32 FRTSPStreamReader::Run() { while (!bStopping) { int ret = av_read_frame(FormatCtx, Packet); if (ret < 0) { // 处理错误或EOF,可能是网络中断 UE_LOG(LogTemp, Warning, TEXT("Failed to read frame or stream ended. Retrying...")); // 实现重连逻辑 FPlatformProcess::Sleep(1.0f); // ... 尝试重新初始化 FormatCtx continue; } if (Packet->stream_index == VideoStreamIndex) { // 发送包到解码器 ret = avcodec_send_packet(VideoCodecCtx, Packet); if (ret < 0 && ret != AVERROR(EAGAIN)) { // 解码错误处理 } av_packet_unref(Packet); // 从解码器接收帧 while (ret >= 0) { ret = avcodec_receive_frame(VideoCodecCtx, Frame); if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { break; } else if (ret < 0) { break; } // **关键步骤:转换和缓存帧** { FScopeLock Lock(&FrameCriticalSection); // 计算目标尺寸(可适配纹理尺寸) int dstWidth = TargetWidth; // 例如纹理的宽度 int dstHeight = TargetHeight; // 初始化或更新SwsCtx(如果尺寸变了) SwsCtx = sws_getCachedContext(SwsCtx, Frame->width, Frame->height, (AVPixelFormat)Frame->format, dstWidth, dstHeight, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); // 确保RGBFrame缓冲区足够大 av_image_fill_arrays(RGBFrame->data, RGBFrame->linesize, CachedFrameData.GetData(), AV_PIX_FMT_RGB24, dstWidth, dstHeight, 1); // 执行转换 YUV -> RGB sws_scale(SwsCtx, Frame->data, Frame->linesize, 0, Frame->height, RGBFrame->data, RGBFrame->linesize); // 更新缓存尺寸信息 CachedWidth = dstWidth; CachedHeight = dstHeight; } // **如果正在录制,将Frame送入录制编码器** if (bIsRecording) { EncodeAndWriteFrame(Frame); // 需要处理时间戳同步 } av_frame_unref(Frame); } } else { av_packet_unref(Packet); // 非视频流,直接释放 } } return 0; }3.3 UE纹理更新与蓝图暴露
解码线程准备好了RGB数据,现在需要安全地传递给游戏主线程,更新一个UTexture2D。
- 创建动态纹理: 在UObject(如一个
ARTSPStreamActor)中,使用UTexture2D::CreateTransient创建一个格式为PF_B8G8R8A8的纹理。注意,UTexture2D的数据需要在渲染线程更新。 - 从工作线程获取数据: 在Actor的
Tick或一个定时器事件中,调用FRTSPStreamReader::GetLatestFrame。这个函数内部需要用之前提到的FCriticalSection锁住对CachedFrameData的访问,拷贝数据到临时缓冲区。 - 渲染线程更新纹理:
void URTSPStreamComponent::UpdateTexture(const TArray<uint8>& RGBData, int32 Width, int32 Height) { if (RGBData.Num() == 0 || !DynamicTexture || DynamicTexture->GetSizeX() != Width || DynamicTexture->GetSizeY() != Height) { return; } // 将更新命令排入渲染线程 ENQUEUE_RENDER_COMMAND(UpdateTextureData)( [TexRef = DynamicTexture->GetResource(), Data = RGBData, Width, Height](FRHICommandListImmediate& RHICmdList) { if (!TexRef) return; // 锁定纹理内存进行写入 uint32 Stride; uint8* TextureData = (uint8*)RHILockTexture2D(TexRef->GetTexture2DRHI(), 0, RLM_WriteOnly, Stride, false); if (TextureData) { // 注意:RGBData是RGB24,而纹理是BGRA8,需要转换并添加Alpha通道 for (int32 y = 0; y < Height; ++y) { uint8* DestRow = TextureData + (y * Stride); const uint8* SrcRow = Data.GetData() + (y * Width * 3); for (int32 x = 0; x < Width; ++x) { DestRow[x * 4 + 0] = SrcRow[x * 3 + 2]; // B <- R DestRow[x * 4 + 1] = SrcRow[x * 3 + 1]; // G <- G DestRow[x * 4 + 2] = SrcRow[x * 3 + 0]; // R <- B DestRow[x * 4 + 3] = 255; // A } } RHIUnlockTexture2D(TexRef->GetTexture2DRHI(), 0, false); } }); } - 蓝图暴露: 将
ARTSPStreamActor或URTSPStreamComponent的纹理变量暴露给蓝图,就可以像普通纹理一样,赋值给UMaterial或UMediaTexture(如果需要),在UI或3D物体上显示了。
3.4 实时录制功能实现
录制功能独立于播放,但复用了解码后的AVFrame。我们在FRTSPStreamReader类中增加录制状态和函数。
开始录制 (StartRecording):
avformat_alloc_output_context2(&OutputFormatCtx, nullptr, nullptr, TCHAR_TO_UTF8(*FilePath)): 根据文件后缀(如.mp4)猜测输出格式。avcodec_find_encoder(AV_CODEC_ID_H264)和avcodec_open2: 创建视频编码器上下文。这里需要设置关键的编码参数:bit_rate(码率)、width/height(分辨率,通常与输入一致或缩放)、time_base(时间基,通常设为{1, framerate})、framerate、gop_size(关键帧间隔)、pix_fmt(像素格式,如YUV420P)。avformat_new_stream(OutputFormatCtx, OutputVideoCodec): 创建输出流。avcodec_parameters_from_context(OutputVideoStream->codecpar, OutputVideoCodecCtx): 将编码器参数复制到流。avio_open(&OutputFormatCtx->pb, TCHAR_TO_UTF8(*FilePath), AVIO_FLAG_WRITE)和avformat_write_header(OutputFormatCtx, nullptr): 打开输出文件并写入头部。
编码与写入帧 (EncodeAndWriteFrame):在Run循环中,如果bIsRecording为真,对每个解码后的Frame(注意是YUV格式的Frame,不是RGB的RGBFrame)执行:
- 设置
Frame的pts(显示时间戳)。这是一个容易出错的地方。需要根据输入流的时间基和帧率,计算出一个递增的pts。一个简单的方法是使用一个自增的帧计数:Frame->pts = FrameCount++。 avcodec_send_frame(OutputVideoCodecCtx, Frame)和avcodec_receive_packet(OutputVideoCodecCtx, OutputPacket): 送入编码器,获取编码后的包。av_packet_rescale_ts(OutputPacket, OutputVideoCodecCtx->time_base, OutputVideoStream->time_base): 将包的时间戳从编码器时间基转换到流的时间基。av_interleaved_write_frame(OutputFormatCtx, OutputPacket): 写入文件。使用av_interleaved_write_frame而非av_write_frame可以确保音视频包交错存储,有利于播放兼容性。av_packet_unref(OutputPacket)。
停止录制 (StopRecording):
- 发送一个
nullptr的帧到编码器,以刷新编码器缓冲区(刷出所有缓存的帧)。 av_write_trailer(OutputFormatCtx): 写入文件尾部。- 按顺序释放所有资源:
avio_close,avcodec_free_context,avformat_free_context。
4. 性能优化与关键参数调优
直接实现基础功能后,你会发现可能面临延迟高、CPU占用大、内存增长等问题。以下是我在实践中总结的优化点。
4.1 降低延迟:从数秒到百毫秒内
RTSP流的默认延迟可能高达2-5秒,这对于交互应用是不可接受的。
- FFmpeg参数调优:在
avformat_open_input时,通过AVDictionary设置以下参数:AVDictionary* opts = nullptr; av_dict_set(&opts, "rtsp_transport", "tcp", 0); // 强制使用TCP,虽然可能增加延迟,但稳定性远高于UDP,尤其在有丢包的网络。对于低延迟,可以尝试“udp”,但要做好丢包处理。 av_dict_set(&opts, "buffer_size", "1024000", 0); // 减小缓冲区大小,减少累积延迟。 av_dict_set(&opts, "max_delay", "500000", 0); // 设置最大延迟(微秒),例如500ms。 av_dict_set(&opts, "stimeout", "3000000", 0); // Socket超时3秒。 av_dict_set(&opts, "flags", "low_delay", 0); // 低延迟标志。 av_dict_set_int(&opts, "analyzeduration", 100000, 0); // 减少分析时长。 - 解码器刷新策略:解码器(
avcodec_receive_frame)默认可能会缓存几帧以保证流畅性。对于实时流,可以考虑在打开解码器后设置VideoCodecCtx->flags |= AV_CODEC_FLAG_LOW_DELAY;(如果编解码器支持)。更激进的做法是,在每次av_read_frame后,如果解码器返回EAGAIN,可以尝试清空解码器缓冲区(通过发送一个nullptr的packet并接收所有帧),但这可能引起画面跳跃,需谨慎。 - 缩短渲染流水线:确保从解码线程到纹理更新的数据通道尽可能短。避免在主线程进行复杂的图像处理。如果使用GPU硬解,数据可以直接在GPU内存间传递,避免CPU-GPU之间的回读,这是降低延迟的最大杀器。
4.2 控制CPU与内存占用
多路高清流是资源消耗大户。
- 启用GPU硬解:这是最有效的优化。在Windows上,可以通过
avcodec_find_decoder_by_name("h264_cuvid")(NVIDIA)或avcodec_find_decoder_by_name("h264_qsv")(Intel)来初始化硬件解码器。解码后的AVFrame格式可能是AV_PIX_FMT_CUDA或AV_PIX_FMT_D3D11,你需要使用av_hwframe_transfer_data将数据拷贝回系统内存(如果后续需要CPU处理),或者更优的是,利用DX11/DX12的共享纹理,直接在GPU端将解码后的纹理用于UE渲染,这需要更深入的图形API知识。 - 合理设置纹理尺寸:不一定需要原分辨率渲染。在
SwsContext缩放时,可以将帧缩放到一个更小的尺寸(如1080P->720P),能显著减轻像素转换和纹理上传的压力。 - 管理线程与对象生命周期:确保流停止时,所有FFmpeg资源(
avformat_close_input,avcodec_free_context,sws_freeContext)都被正确释放,避免内存泄漏。使用FRunnable的Stop()和Exit()函数进行有序关闭。 - 限制帧率:如果视频源是30fps,但你的应用不需要这么高刷新率,可以在解码线程中通过计时器来跳过一些帧,只处理特定间隔的帧。
4.3 提升稳定性与健壮性
网络不稳定、流服务中断是常态。
- 实现重连机制:在
Run循环中,当av_read_frame持续失败或返回AVERROR_EOF时,不能简单退出线程。应该进入一个重连循环:先释放当前的FormatCtx和相关资源,等待几秒(FPlatformProcess::Sleep),然后尝试重新调用Init()流程。需要设置一个最大重试次数,避免无限循环。 - 心跳与超时管理:除了FFmpeg自带的
socket timeout,可以自己实现一个“看门狗”计时器。在解码线程中定期(比如每秒)更新一个时间戳。主线程检查这个时间戳,如果超过一定阈值(如10秒)没有更新,则认为流已死,触发重启。 - 错误隔离:一路流的崩溃(如编解码器异常)不应导致整个程序崩溃。使用
try-catch包裹核心的FFmpeg调用,并将错误信息通过日志或委托传递出来,便于诊断。
5. 常见问题排查与实战心得
即使按照步骤操作,你也一定会遇到各种奇怪的问题。下面是我踩过的一些典型坑和解决方法。
5.1 流连接失败或花屏
- 现象:
avformat_open_input返回错误,或者能连接但画面是绿色、破碎的。 - 排查:
- URL与认证:确保RTSP URL正确。对于需要认证的摄像头,URL格式通常是
rtsp://username:password@ip:port/path。某些设备可能有特殊的路径,需要查阅设备手册。 - 网络抓包分析:这是终极武器。使用Wireshark过滤RTSP和RTP包。观察RTSP的
DESCRIBE,SETUP,PLAY交互是否成功。观察RTP包是否持续收到。如果收不到RTP包,可能是防火墙或端口问题。如果RTP包序列号不连续,说明有丢包。 - FFmpeg日志:调用
av_log_set_level(AV_LOG_DEBUG)将FFmpeg日志级别调到最高,可以在输出中看到详细的协议交互和错误信息,非常有用。 - 解码器不支持:确认你的FFmpeg编译时包含了对应的解码器(如H.264)。有些摄像头使用H.265,需要额外开启。
- TCP/UDP问题:如果使用UDP模式花屏,大概率是丢包。尝试切换到TCP模式(
rtsp_transport=tcp)。TCP虽然延迟稍高,但能保证数据完整。
- URL与认证:确保RTSP URL正确。对于需要认证的摄像头,URL格式通常是
5.2 录制文件无法播放或音画不同步
- 现象:录制的MP4文件用某些播放器打不开,或者播放时声音和画面逐渐对不上。
- 排查:
- 时间戳(PTS/DTS)错误:这是音画不同步最常见的原因。确保你传递给编码器的
Frame->pts是正确且递增的。对于固定帧率的视频,最简单的计算方式是:Frame->pts = FrameIndex * (OutputVideoCodecCtx->time_base.den / OutputVideoCodecCtx->time_base.num) / Framerate。更严谨的做法是使用输入流的时间戳,经过av_rescale_q转换到输出流的时间基。 - 缺少B帧或GOP设置:有些播放器对没有B帧或GOP(关键帧间隔)设置异常的文件支持不好。在编码器上下文中,确保设置了合理的
gop_size(如帧率的2倍)。 - 封装格式问题:确保
avformat_alloc_output_context2时指定的格式(或根据文件名猜测的格式)支持你使用的编码器。H.264视频通常封装在MP4或MKV中。在写入文件尾部(av_write_trailer)之前,确保所有数据包都已写入。 - 录制未正确结束:如果程序异常退出,没有调用
av_write_trailer,文件可能不完整。确保在StopRecording和对象析构时,完整执行了收尾流程。
- 时间戳(PTS/DTS)错误:这是音画不同步最常见的原因。确保你传递给编码器的
5.3 多路流时性能急剧下降
- 现象:开1路流很流畅,开4路流帧率暴跌,CPU占用100%。
- 排查与优化:
- 线程数量爆炸:每一路流一个
FRunnable线程是合理的,但10路流就是10个线程,线程切换开销很大。可以考虑使用线程池,或者将多路流的av_read_frame放在一个或少数几个线程中进行IO多路复用(使用libavformat的avformat_open_input结合自定义IO上下文,或使用select/poll,但这属于高级用法)。 - CPU软解瓶颈:这是最主要的原因。必须上GPU硬解。在支持的情况下,为每一路流创建独立的硬件解码器上下文。注意GPU显存限制,同时解码过多的高清流可能会爆显存。
- 内存拷贝瓶颈:检查
sws_scale(色彩转换和缩放)和纹理数据拷贝(memcpy或RHI更新)是否成为热点。使用性能分析工具(如Unreal Insights, VTune)定位。优化方法包括:减少不必要的格式转换(如果渲染管线支持YUV纹理)、使用更高效的缩放算法(如SWS_FAST_BILINEAR)、或者将缩放和转换也放到GPU上进行(通过Compute Shader)。 - 纹理更新策略:不必每帧都更新纹理。可以检查帧数据是否有实质变化(比较哈希),或者限制纹理更新频率,与游戏渲染帧率同步。
- 线程数量爆炸:每一路流一个
5.4 在Android/iOS上的特殊问题
- 交叉编译FFmpeg:为移动平台编译FFmpeg非常麻烦,需要配置正确的toolchain和sysroot。网上有成熟的脚本(如
FFmpeg-Android),但要注意与UE的编译环境(NDK版本)匹配。 - 使用平台原生解码:在移动端,集成FFmpeg可能过于臃肿。可以考虑使用Android的
MediaCodec和iOS的VideoToolbox框架进行硬解,通过JNI或Objective-C桥接与UE通信。这需要平台原生开发能力,但能获得更好的性能和功耗表现。 - 权限与网络:确保应用有网络权限。在Android上,从Android 9.0 (Pie)开始,默认禁止明文流量,如果你的摄像头RTSP流是HTTP(非HTTPS),需要在
AndroidManifest.xml中配置android:usesCleartextTraffic="true"。
最后一点个人心得:在虚幻引擎中集成RTSP,本质上是在游戏引擎这个“实时交互图形系统”中,引入一个“流媒体处理系统”。两者在内存管理、线程模型、渲染流程上都有不同的哲学。最大的挑战不在于单个功能的实现,而在于如何让这两个系统高效、稳定地协同工作,并妥善处理各种边界情况和异常状态。从最简单的Demo到能在生产环境稳定运行,中间需要大量的测试、调试和优化。建议从一路流开始,把播放、停止、重连、录制、资源释放这个完整生命周期做稳定,再逐步扩展到多路。每增加一个功能或一路流,都进行充分的压力和异常测试。这个过程很磨练人,但一旦走通,你就拥有了一套强大的、可定制的视频处理能力,能解锁很多有趣的实时交互应用场景。