简介:UE5实时录屏插件(FFmpeg)是一套基于FFmpeg库实现的UE5实时屏幕录制解决方案,面向需要在Windows/Linux平台为项目增加录制能力的游戏开发者,解决UE5没有内置录屏功能的问题。资源包为zip压缩包,共570个文件,约164.02MB,包含346个h头文件、29个dll动态库、26个lib导入库、多个c源文件及14个pc配置文件与14个html文档,其中头文件与库文件对应不同编译环境,文档提供配置参考,涵盖FFmpeg编译产物、接口封装与使用说明等。已有3005人学习下载,得到开发者广泛关注。资源内置EasyFFMPEG-main封装源码、使用说明.docx以及一整套FFmpeg开发库,能够帮助读者从库的引入、接口封装到逐帧编码调用完整落地,理解avformat/avcodec等API的调用方式,并结合SceneCaptureComponent2D实现逐帧数据转码、线程同步与性能优化等关键环节。适合希望深入掌握UE5与FFmpeg集成技巧、具备一定C++基础的中高级开发者作为参考模板。
1. 为什么UE5实时录屏要选FFmpeg
1.1 引擎自带录屏方案的真实痛点
做UE5项目时,录屏需求从来不是"能录就行"这么简单。我最早偷懒直接用引擎自带的Movie Render Queue,结果踩了一脚泥:它走的是离线渲染管线,录制一帧可能要几十毫秒甚至更久,完全不适合"边跑边录"的场景。你要录一个运营Demo、录一个自动化测试结果、或者在游戏里加一个"一键分享录像"功能,Movie Render Queue的延迟渲染会直接拖垮帧率,而且中途切换关卡、玩家进出场都会让录像端掉链子。
另一个常见思路是用Windows系统级录屏,比如Game Bar、Windows Graphics Capture(WGC)。Game Bar确实零成本,但它不受引擎控制,开始、停止、文件命名全部依赖用户手动操作,没法嵌入到项目流程里;WGC API能定制,但它是COM组件风格,工程结构繁琐,而且抓取的是桌面/窗口画面,不是UE5内部的RT视口,多显示器、DWM合成、分辨率缩放等一堆边缘Case能把你磨到怀疑人生。
这就说到了FFmpeg的价值:它是跨平台的多媒体处理库,封装了视频编码、复用、推流、滤镜在内的全套能力。把FFmpeg嵌进UE5,直接截取引擎渲染出来的RT(Render Target)像素,在内存里交给libx264编码成H.264,再封装成MP4。整个过程完全在引擎内闭环,渲染线程、游戏线程、编码线程互不干扰,录制开始/停止时机、分辨率、码率、帧率全部由你决定。这正是做工具链、自动化测试、游戏内录像功能最需要的自由度。
1.2 什么样的人适合这个方案
我做了这几年UE5工具链开发,见过太多人在录屏这里临时抱佛脚:要么用外部软件录,要么用引擎离线渲染录,最后都无法和自动化流程打通。如果你属于下面任何一类,FFmpeg方案值得认真投入:
- 做UE5编辑器工具链,需要批量录屏做自动化截图/录像回归测试的。
- 做数字孪生、看板、展览类项目,需要长时间循环播放并实时记录画面的。
- 做带"录像回放"功能的游戏或仿真应用,想把这套能力集成进产品的。
- 做像素流或者其他音视频服务,本身就需要和FFmpeg打交道的。
简而言之,只要"录制"不是一次性手工操作,而是产品的一个功能模块,就必须在引擎内实现本地编码。这也是我想把这篇文章写完整的原因。
2. 插件整体设计与方案选型
2.1 不同录屏路线的对比
不光是UE5本身有自带方案,FFmpeg其实也有好几种接入路径。很多人一上来就纠结"用FFmpeg的gdigrab不就行了吗",如果你真试过就会知道,gdigrab抓的是屏幕,不是UE5的视口。某个项目里用户开了高分屏缩放,录出来的分辨率乱七八糟,字还发虚,根本没法用。核心区别在于:
| 方案 | 采集源 | 实时性 | 可定制性 | 工程集成复杂度 | 适合场景 |
|---|---|---|---|---|---|
| Movie Render Queue | 引擎离线渲染缓冲 | 差 | 中 | 低 | 影视级离线输出 |
| Windows Graphics Capture | 桌面/窗口 | 好 | 中 | 高 | 系统级窗口录制 |
| FFmpeg gdigrab | 桌面 | 好 | 低 | 低 | 临时录屏 |
| FFmpeg + UE RenderTarget | UE视口/RT | 好 | 高 | 中 | 引擎内嵌录屏、录像回放 |
我的结论很明确:真正要在UE5产品里做实时录屏,必须走"引擎RT采集 + FFmpeg编码"的路线。这样录什么、录多清晰、录多大码率全在引擎里控制,还能配合关卡流送、Camera切换,做到游戏逻辑层面绝对同步。
2.2 插件流水线架构
在设计插件之前,先想想数据从渲染完成到文件落盘经历了什么。按照从业者的思维习惯,我习惯把它拆成四段:
- 采集段:从UE5渲染管线拿到一帧BGRA8或者RGBA8像素数据。
- 转换段:H.264编码器要求YUV420空间(实际是YUV4:2:0布局),需要把BGRA转成YUV,同时做RGB->YUV色彩空间变换和降采样。
- 编码段:libx264拿到YUV帧,按预设的码率、GOP、preset进行编码,输出H.264 Annex-B流。
- 封装段:把编码后的包写入MP4/FLV/MKV容器,生成时间戳、音视频索引等元数据。
这四段中任何一段慢了,整个录像帧率就会掉。我在项目里遇到最典型的坑是把像素转换放在游戏线程做,结果直接掉帧20多。原因很简单:ReadPixels从GPU读回CPU本身就带一次同步卡顿,再叠加sws_scale做颜色转换,游戏线程根本扛不住。所以我后来把所有CPU密集操作全部挪到独立编码线程,游戏线程只做两件事:发一帧数据到队列,然后立即返回。
2.3 FFmpeg库的集成方式
FFmpeg集成到UE5时,很多人纠结静态库还是动态库。我的实践经验是:
- 开发阶段用动态库(DLL)。升级版本、排查问题都方便,不用每次重新链接整个工程。
- 正式分发阶段如果需要单包部署,再考虑静态链接到exe里,避免DLL缺失被用户投诉。
不管哪种方式,FFmpeg的头文件和库建议统一放到插件目录下的ThirdParty/FFmpeg,方便后面打包。需要引入的头文件大部分集中在libavformat、libavcodec、libavutil、libswscale几个模块。如果编码器选择libx264,那还需要确认FFmpeg构建时开启了这个模块,很多纯Lite版没有x264,录出来的东西格式兼容性会差很多。
3. 核心模块实现细节
3.1 像素采集:RenderTarget还是BackBuffer
这一块是很多自己写过录屏的人最容易翻车的地方。UE5里拿到渲染像素的数据源主要有三条路:
SceneViewport::OnBackBufferReadyToPresent回调:发生在后备缓冲区即将被Present的时刻,可以直接拿到FTexture2DRHIRef。数据非常新鲜,但需要注意这个回调在RHI线程上触发,且纹理生命周期很短,不能在回调里直接做长时间操作。正确做法是立即发起异步拷贝,把像素拷到StagingTexture,再在另一个线程Map后读出CPU内存。SceneCapture2D+RenderTarget:可以指定相机和分辨率,把渲染内容渲染到RT上,然后通过FRenderTarget::ReadPixels读回CPU。好处是灵活,可以录任意视角、任意分辨率;坏处是多了一次场景渲染或场景拷贝开销。如果你的需求是"录当前主视口",这个方案会多出一份冗余开销。自定义
BeginRenderCommand在RHI线程做CopyTexture到StagingBuffer。这是效率最高的路,也是我在正式项目里用的方式。它绕过了ReadPixels的同步等待,直接用GPU拷贝,拷贝完成后异步映射回CPU,编码线程拿数据时完全不用卡渲染。
我给插件的默认实现选了第3种,但在配置项里保留"兼容模式"让用户切换到SceneCapture2D方案。为什么?因为StagingBuffer这套对多平台、多RHI(DX11/DX12/Vulkan)有一些细节差异,新手排查起来太崩溃。兼容模式虽然慢一点,但能保证先跑通流程。
实际代码里核心就这几行:
// 在OnBackBufferReadyToPresent回调中发起异步拷贝 FRHICommandListImmediate& RHICmdList = FRHICommandListExecutor::GetImmediateCommandList(); void* RHITexture = BackBuffer->GetNativeResource(); RHICmdList.Transition(FRHITransitionInfo(BackBuffer, ERHIAccess::Present, ERHIAccess::CopySrc)); RHICmdList.CopyTexture( FRHICopyTextureInfo{}, BackBuffer, StagingTexture );然后编码线程对StagingTexture调用RHICmdList.MapStagingSurface取出像素缓冲。如果你只是想快速验证,直接ReadPixels也够用,但要知道它会把渲染线程和游戏线程一起拖慢。
3.2 FFmpeg编码流程的关键步骤
FFmpeg是一套纯C接口的库,调用顺序错了轻则崩溃重则文件打不开。我建议按照固定的生命周期模板来写,别跳步:
// 1. 分配输出上下文 AVFormatContext* FormatCtx = nullptr; avformat_alloc_output_context2(&FormatCtx, nullptr, nullptr, OutputFilename); // 2. 创建视频流并绑定编码器 AVStream* Stream = avformat_new_stream(FormatCtx, nullptr); AVCodec* Codec = avcodec_find_encoder_by_name("libx264"); AVCodecContext* CodecCtx = avcodec_alloc_context3(Codec); // 3. 设置编码参数 CodecCtx->width = Width; CodecCtx->height = Height; CodecCtx->time_base = {1, static_cast<int>(FPS)}; CodecCtx->pix_fmt = AV_PIX_FMT_YUV420P; CodecCtx->codec_type = AVMEDIA_TYPE_VIDEO; CodecCtx->bit_rate = Bitrate; // 例如 8'000'000 表示8Mbps CodecCtx->gop_size = FPS * 2; // 关键帧间隔2秒 // 4. 打开编码器 if (avcodec_open2(CodecCtx, Codec, nullptr) < 0) { /* 处理错误 */ } // 5. 打开输出文件并写入头部 avio_open(&FormatCtx->pb, OutputFilename, AVIO_FLAG_WRITE); avformat_write_header(FormatCtx, nullptr); // 6. 每次编码一帧后调用 avcodec_send_frame(CodecCtx, Frame); AVPacket Pkt; av_new_packet(&Pkt, Size); avcodec_receive_packet(CodecCtx, &Pkt); av_interleaved_write_frame(FormatCtx, &Pkt); // 7. 结束录制 avcodec_send_frame(CodecCtx, nullptr); while (avcodec_receive_packet(CodecCtx, &Pkt) == 0) { av_interleaved_write_frame(FormatCtx, &Pkt); } av_write_trailer(FormatCtx); avio_closep(&FormatCtx->pb); avformat_free_context(FormatCtx);这里面最容易忽略的是第6步只处理了单帧,没有处理编码器内部缓存的多帧数据。你录完视频发现最后1-2秒缺失或者时长比实际短,基本都是因为在结束时没有把编码器缓冲的帧刷出来。avcodec_send_frame(CodecCtx, nullptr)就是刷缓冲的关键动作,一定不能漏。
另一个容易错的是PTS。网上很多文章说PTS用帧序号i就行,这在固定帧率下勉强能用,但一旦丢帧或者帧率波动,录像就会出现"快进/卡住"的现象。正确做法是用递减的PtsCounter,然后写入时根据实际采集时间计算:
Frame->pts = PtsCounter++;配合CodecCtx->time_base和封装容器的time_base,FFmpeg会把播放速率控制在正确节奏上。
3.3 多线程同步与帧率控制
录屏插件一旦跑起来,就是一条三线程流水线:游戏线程产生帧、编码线程做转换和编码、IO线程写文件。最常见的错误是给编码线程挂一个无界队列,结果录制5分钟后内存涨了2个G,玩家开始骂娘。
解决方法是设计一个"带丢弃策略的环形缓冲":队列固定3-5帧,如果编码线程跟不上,新帧直接覆盖队首最旧的那一帧。对录屏来说,丢几帧远好过内存爆炸。你可以把采集帧率独立于游戏帧率,比如游戏跑120帧,录像只需要30帧,那游戏线程每4帧才抓取1次,大幅减轻编码负担。
帧率控制我不建议直接凭空造轮子。用真实时间来驱动是更稳的:
double Accumulator = 0.0; double LastTime = FPlatformTime::Seconds(); double FrameInterval = 1.0 / TargetFPS; // 每次从队列获取渲染帧时 double Now = FPlatformTime::Seconds(); Accumulator += Now - LastTime; LastTime = Now; if (Accumulator >= FrameInterval) { Accumulator = FMath::Fmod(Accumulator, FrameInterval); // 执行编码帧写入 }这样即使某帧因为加载地图、打开UI之类卡顿,录像时间轴也不会凭空多出内容。
3.4 音频要不要做
这个问题一开始就要想清楚。我的经验是第一版建议只做视频轨,先把画面链路跑通,后面再加音频。原因很现实:音频采集在UE5里要分两条路,一条是抓WASAPI系统环回设备(录系统的声音,但用户可能没开环回),另一条是从UE的Audio Engine拿PCM数据(用FAudioDevice的混音回调),两条路都要同步管理音视频时间戳,复杂度翻倍。
如果确实需要音频,我记得后来项目里是用WASAPI的AUDCLNT_STREAMFLAGS_LOOPBACK抓环回,把PCM喂给FFmpeg的AAC编码器,并单独开一条音频线程维持PTS。但是同步问题至今都很烦人:常出现视频先行、音频迟到半秒的怪现象。所以如果实在要音频,建议直接参考FFmpeg官方muxing.c示例里的音视频双流写法,不要自己凭感觉造。
4. 从零搭建插件并跑通的实操记录
4.1 准备FFmpeg库文件
Windows环境下我比较推荐用预编译的release包,节省宝贵时间。下载时要注意架构必须选x64,版本选较新的stable(比如4.4/5.1/6.1都行)。解压后的目录结构整理成:
ThirdParty/FFmpeg/ ├─ include/ │ ├─ libavcodec/ │ ├─ libavformat/ │ ├─ libavutil/ │ └─ libswscale/ ├─ lib/ │ ├─ avcodec.lib │ ├─ avformat.lib │ ├─ avutil.lib │ └─ swscale.lib └─ bin/ ├─ avcodec-*.dll ├─ avformat-*.dll ├─ avutil-*.dll └─ swscale-*.dll注意:发行DLL时,FFmpeg 6.0以后的DLL文件名带-61这种版本后缀,打包时要一起拷贝,别只拷一个不拷另一个。不然用户本机缺少运行库,录制功能直接失效。
4.2 在UE5里创建插件模块
用编辑器自带的New Plugin工具创建一个Blank插件,然后在Build.cs里配置第三方库:
public class FFmpegRecorder : ModuleRules { public FFmpegRecorder(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicIncludePaths.Add(Path.Combine(ModuleDirectory, "../ThirdParty/FFmpeg/include")); PublicAdditionalLibraries.Add(Path.Combine(ModuleDirectory, "../ThirdParty/FFmpeg/lib/avcodec.lib")); PublicAdditionalLibraries.Add(Path.Combine(ModuleDirectory, "../ThirdParty/FFmpeg/lib/avformat.lib")); PublicAdditionalLibraries.Add(Path.Combine(ModuleDirectory, "../ThirdParty/FFmpeg/lib/avutil.lib")); PublicAdditionalLibraries.Add(Path.Combine(ModuleDirectory, "../ThirdParty/FFmpeg/lib/swscale.lib")); // 开发阶段使用delay load,让DLL在运行时加载 PublicDelayLoadDLLs.AddRange(new string[] { "avcodec-61.dll", "avformat-61.dll", "avutil-61.dll", "swscale-7.dll" }); RuntimeDependencies.Add("$(BinaryOutputDir)/avcodec-61.dll"); RuntimeDependencies.Add("$(BinaryOutputDir)/avformat-61.dll"); RuntimeDependencies.Add("$(BinaryOutputDir)/avutil-61.dll"); RuntimeDependencies.Add("$(BinaryOutputDir)/swscale-7.dll"); } }RuntimeDependencies是打包时必写的,不然打包后的程序跑起来提示找不到DLL。我早期忽略过这个,编辑器里能录,打包后立刻报错。
4.3 核心封装类
我会把FFmpeg相关逻辑封装成一个原生C++类FFFmpegVideoEncoder,不直接塞进Actor,方便随时在工具、Actor、插件后台中使用。核心接口大致这样:
class FFFmpegVideoEncoder { public: bool StartRecording(const FString& FilePath, int32 Width, int32 Height, int32 FPS, int32 Bitrate); bool WriteFrame(const uint8* BGRA8Data, int32 Width, int32 Height); void StopRecording(); private: AVFormatContext* FormatCtx = nullptr; AVCodecContext* CodecCtx = nullptr; AVFrame* Frame = nullptr; SwsContext* ScaleCtx = nullptr; };WriteFrame内部做两件事:用sws_scale把BGRA8的AVFrame转成YUV420,然后avcodec_send_frame。注意sws_scale的输入参数要对应UE的BGRA存储方式,一般指定AV_PIX_FMT_BGRA。这里有一个非常经典的坑:UE5的FColor在内存里是BGRA顺序,你如果按RGBA配置sws_scale,录出来的画面会红蓝互换——我第一次遇到时排查了大半天。
4.4 给蓝图暴露接口
插件最终给谁用?大概率是蓝图开发者或设计师,你不可能要求他们去写C++。所以我的习惯是把插件核心逻辑封装在UFunction(BlueprintCallable)里,在UActorComponent上暴露三个接口:
StartRecording:传入文件名、分辨率、FPS、码率。StopRecording:停止编码并关文件。IsRecording:查询状态。
蓝图侧只需在关卡Bluprint里拖个UI按钮,点击时调用StartRecording,再次点击时调用StopRecording。像素采集部分的逻辑完全藏在组件内部,不暴露给蓝图。这样维护起来边界清晰,谁都不越界。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 直接原因 | 解决方式 |
|---|---|---|
| 录出来的视频绿屏/花屏 | 像素格式配置错误(BGRA/RGBA/YUV不对) | 检查sws_scale的输入格式,UE用AV_PIX_FMT_BGRA |
| 视频里红色和蓝色互换 | 颜色通道顺序未对齐 | 改用AV_PIX_FMT_BGRA或者手动交换R/B |
| 录到一半内存持续增长 | 帧队列无界堆积 | 改用固定大小环形缓冲,并加丢帧策略 |
| 录制结束文件打不开 | 没有调用av_write_trailer | 正确flush编码器缓冲并写尾 |
| 打包后提示DLL缺失 | 没有配置RuntimeDependencies或不带DLL | 在Build.cs里配置RuntimeDependencies |
| 找不到libx264编码器 | FFmpeg版本未编译x264 | 换带x264的full版,或改用libsvtav1等其他编码器 |
| 视频前几秒卡顿 | 没有设置GOP大小或者B帧策略异常 | 显式设置gop_size和max_b_frames=0 |
| 录制中游戏掉帧明显 | 像素读取导致CPU/GPU同步阻塞 | 改用CopyTexture+StagingBuffer异步映射 |
| 视频时长和实际录制时间不符 | PTS计算错误 | 用真实的采集时间戳计算帧PTS |
| 音视频不同步 | 音视频PTS基准不一致 | 用同一条时间轴,不要用各自的帧计数 |
5.2 我最想单独拎出来讲的两个坑
第一个是编码器缓存机制。很多人录到结尾发现视频突然缺最后一两秒,这是老生常谈但依然频繁发生。本质上是H.264编码器为了压缩率会缓存几帧在未来帧之后输出,如果你录制结束时没有把缓存中的帧冲洗出来,它们就永远不写进文件。解决方式就是上面提到的:avcodec_send_frame(CodecCtx, nullptr),然后循环avcodec_receive_packet直到返回AVERROR_EOF,再把包全部写进文件。
第二个是长时间录制的崩溃。项目里遇到过录制4小时后内存涨到14G的情况,最终定位是每一帧在av_packet_unref上少调用了一次。AVPacket是个引用计数容器,av_interleaved_write_frame不会自动释放你传入的packet内存,每次用完后必须av_packet_unref(&Pkt),否则就是妥妥的内存泄漏。类似的还有av_frame_unref。写编码循环时必须保证每个AVPacket、AVFrame都用一次unref一次,顺序错一个就有隐患。
5.3 性能调优心得
录屏功能上线前,我会先固定一个参数组合来跑基准测试:1080p、30fps、8Mbps、preset=veryfast。这几个参数对大部分游戏足够清晰,体积也不会失控。如果录制过程平均掉帧小于3帧,就会尝试把preset降为medium;如果掉帧明显,先检查是不是编码线程CPU占用已经到了95%以上,是就把preset调回fast或faster,而不是优先去砍码率。码率过低反而会导致画面模糊,视觉体验更差。
还有一个很少有人提的点:FFmpeg的av_interleaved_write_frame可能会在IO速度慢时阻塞,这意味着编码线程长时间卡在文件写入上。稳妥的做法是编码线程和文件IO线程分离,编码线程写完包后挂到IO队列,IO线程统一写磁盘。但一般项目直接在编码线程里写磁盘也够用,除非录的是4K超高清,才需要考虑拆IO线程。
6. 最后再分享一点实际经验
这套插件方案我在三个项目里落地过,从最初的ReadPixels硬抗,到后来改成StagingBuffer异步拷贝,再到现在跑得顺滑稳定的版本,中间踩过的坑不少。最深的体会是:FFmpeg本身并不复杂,复杂的是和UE5渲染管线的集成时机、线程模型和资源生命周期管理。只要这些理顺,后面加音频轨、加推流直播、加分段录制都只是顺着Pipeline再扩展模块而已。
另外提醒一句,FFmpeg的许可证在某些使用场景下是GPL/LGPL的。如果你只是内部工具用,问题不大;如果发出去给商业用户,提前走一遍授权检查,别等到产品都上架了才想起这回事,到时候换编码器重写一遍成本极高。技术选型做在前面,永远比事后补窟窿省心。
本文还有配套的精品资源,点击获取