简介:这份压缩包收录了与虚幻引擎相关的多个开源项目与学习资源,面向游戏开发初学者、技术美术及希望参与Hacktoberfest2020开源贡献的开发者,用来理解UE工程结构、蓝图系统与资源管理方式。包内共523个文件,大小约639MB,以427个uasset资源文件与25个umap关卡地图为核心,另有1个uproject工程文件、39个ini配置文件、少量json与log日志,并附带预览截图,能从素材组织、关卡脚本到项目配置较完整呈现目录体系。目前已有586人学习查阅。逐一打开uasset材质、蓝图和umap关卡,可观察光照布置、碰撞设定与物理模拟的配置思路;结合Hacktoberfest相关标记,其中部分项目适合二次开发与社区共建,帮助学习者在真实工程中练习蓝图交互、性能优化和插件扩展等技能。
1. 虚幻引擎项目:把一百个机位的截图变成一条自动管线
做关卡验收的人大概都经历过这种时刻:一百个镜头机位在关卡里摆好了,你得逐个切过去看构图、等光照稳定、手动按截图键,再回去整理图片和对应坐标。一下午过去,大部分时间浪费在重复操作上。这个虚幻引擎项目的核心价值就是把这件事自动化——它遍历关卡里打了标签的相机,自动切换视角并输出 PNG,同时把每个机位的坐标、旋转、FOV 和渲染时间一并写进 JSON 清单。
它不是一个大而全的框架,而是一个可以立刻装进工程的实用工具。骨架脱胎于某实验室在 Hacktoberfest 2020 期间整理的内部工作流,后来拆成运行时模块和编辑器模块两部分,蓝图和 C++ 都能调用。对做关卡验收、文档配图、镜头预览的人来说,这是一份可以直接跑起来的源码;对想研究 Unreal 插件怎么组织的人来说,它又是一个干净到能当教材的样例。
2. 架构设计:为什么拆成两个模块,数据如何流动
这个项目最值得先看的一层,是它把代码分成两个独立模块:SceneShotTool(运行时模块)和SceneShotToolEditor(编辑器模块)。前者负责接收相机列表、调度截图、写元数据,所有核心逻辑不依赖任何编辑器 UI;后者只负责把按钮、输入框挂到编辑器窗口上,调用前者的公开函数。
这种拆分在 Unreal 插件里是常见做法,原因很实际:批量截图这个动作既可能由人在编辑器面板上点按钮触发,也可能被写进自动化命令、在打包后的客户端里执行。如果把 UI 和逻辑揉在一个模块里,打包时需要连带引入一堆编辑器依赖,自动化路径也会被编辑器面板拖住。拆开之后,运行时模块可以进任何打包产物,编辑器模块只在编辑环境中加载,两边共享同一套调度逻辑。
2.1 模块依赖关系与编译单元划分
先看整个工程的文件组织方式:
SceneShotTool/ ├─ SceneShotTool.uproject ├─ Source/ │ ├─ SceneShotTool/ 运行时模块 │ │ ├─ SceneShotTool.Build.cs │ │ ├─ Public/ │ │ │ └─ SceneShotSubsystem.h │ │ └─ Private/ │ │ ├─ SceneShotSubsystem.cpp │ │ └─ SceneShotScheduler.cpp │ └─ SceneShotToolEditor/ 编辑器模块 │ ├─ SceneShotToolEditor.Build.cs │ └─ Private/ │ ├─ SceneShotPanel.cpp │ └─ SceneShotCommands.cpp ├─ Config/ │ └─ DefaultEngine.ini └─ Content/ └─ BP_ShotLauncher.uasset运行时的SceneShotTool是核心,编辑器模块只依赖它,反向不成立。SceneShotSubsystem是入口,负责收集关卡里的目标相机;SceneShotScheduler是队列管理器,处理“切视角—截图—回读—切下一个”的循环。编辑器侧的SceneShotPanel是一个 Slate 面板,SceneShotCommands负责注册菜单命令和面板的按钮回调。
Content 下那个示例蓝图BP_ShotLauncher是一个可选启动器,它只是把 Subsystem 的函数封装成蓝图节点,实际逻辑并不在蓝图里。这样做的考虑是:蓝图便于做产品原型,但队列调度这种有状态逻辑放在 C++ 里更稳,蓝图调用方写起来也更简单,一个节点就能启动整条管线。
2.2 一次完整截图的调用链
从点击按钮到 PNG 落盘,整条链路可以写成:SceneShotPanel的 Run 按钮触发SceneShotCommands里的 ExecuteCapture;ExecuteCapture 从USceneShotSettings里读取 TagFilter、分辨率倍率、帧等待数;SceneShotSubsystem.CollectCameraActors()返回过滤并排序后的相机数组;SceneShotScheduler.StartQueue()注册截图回调并启动逐张调度;每张截图完成后回调触发,推进队列,直到全部处理完,最后统一写出 manifest.json。
这条链路上最能看出功底的是回调注册这一环。UGameViewportClient::OnScreenshotCaptured是一个动态多播委托,截图异步回读完成后触发。调度器在StartQueue时先RemoveAll(this)再AddUObject,避免上一次运行残留的回调把队列推进两次——这个细节后面踩坑章节还会展开讲,很多从蓝图转 C++ 的人第一次写这个工具都在这里翻车。
2.3 依赖表:为什么必须带这几个模块
Build.cs 里的依赖选择也值得抄一下:
// SceneShotTool.Build.cs using UnrealBuildTool; public class SceneShotTool : ModuleRules { public SceneShotTool(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" }); PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore", "Json", "JsonUtilities" }); } }JsonUtilities是为了用FJsonObjectConverter做结构体到 JSON 的序列化,比手动拼字符串省事得多。Slate和SlateCore在运行时模块里其实用不到,但因为调度器需要弹提示消息框,面板代码又引用了调度器,我一般会直接把 Slate 依赖放在运行时模块,避免编辑器模块间接引用的麻烦。ImageWriteQueue这个模块没有出现在这里,因为截图走的是引擎自带的FScreenshotRequest,异步 PNG 写入引擎内部已经处理。
这里有一个常见误用需要提醒:Engine模块的FScreenshotRequest虽然能在编辑器里用,但它读取的是GameViewportClient的渲染结果,而不是编辑器布局视口(Level Viewport)。这意味着直接在编辑器面板上点 Run,截图逻辑对的目标和用户眼睛看到的视口不是同一个东西,这个问题在第 5 章会有专门的处理方案。
3. 核心模块实现:相机遍历、截图队列和元数据输出的三段关键代码
这一章我把运行时模块里最值得抄的三段代码拆开讲。它们分别对应管线里的三个节点:收集哪些相机、如何一张接一张地截图、以及截图之后怎么把元数据落盘。这三段代码加起来不到两百行,却是整个工具能稳定跑起来的地基。
3.1 相机收集器:按 Tag 过滤并稳定排序
工具第一步是找出关卡里要截哪些相机,核心实现如下:
// SceneShotSubsystem.cpp —— 收集相机列表 TArray<ACameraActor*> USceneShotSubsystem::CollectCameraActors() { TArray<ACameraActor*> OutCams; if (!World) return OutCams; // 默认只处理打了 ShotCam 标签的相机,面板上可以自定义 for (TActorIterator<ACameraActor> It(World); It; ++It) { ACameraActor* Cam = *It; if (TagFilter.IsEmpty() || Cam->Tags.Contains(*TagFilter)) { OutCams.Add(Cam); } } // 按世界坐标 X 轴排序,保证两次运行顺序一致 OutCams.Sort([](const ACameraActor& A, const ACameraActor& B) { return A.GetActorLocation().X < B.GetActorLocation().X; }); return OutCams; }逻辑说明:TActorIterator只遍历指定 UClass 的 Actor,拿到的是当前 Level 以及其子 Level 里所有存活实例。TagFilter来自面板输入框,为空时全收,这个设计是为了兼容两种使用习惯——有些人习惯给所有相机统一打标签,有些人希望一个场景里临时筛几个。排序放在这里是因为后续截图文件名带序号,如果顺序不稳定,两次运行生成的文件对不上,后续做自动化对比就没法看。
参数说明:TagFilter一旦设置就严格匹配Actor.Tags里的字符串,大小写敏感。如果想让过滤更灵活,可以在循环里改成ContainsStartsWith之类的前缀匹配,但默认实现刻意保持严格,避免出现明明没打标却被截出来的情况。
3.2 截图调度器:串行队列与回读等待
截图不能像散弹枪一样一次性全发出去,那会让渲染线程瞬间挤爆。这个工具的策略是严格串行——同一时刻只有一张截图在途,回读完成才推下一张:
// SceneShotScheduler.cpp —— 串行截图调度器 void ASceneShotScheduler::StartQueue(const TArray<ACameraActor*>& Cameras, int32 InFramesToWait) { Queue = Cameras; CurIndex = 0; FramesToWait = InFramesToWait; UGameViewportClient* Viewport = World->GetGameViewport(); // 先清旧回调,避免上一次运行的残留把队列推进两次 Viewport->OnScreenshotCaptured().RemoveAll(this); Viewport->OnScreenshotCaptured().AddUObject(this, &ASceneShotScheduler::OnShotCaptured); DispatchNext(); }DispatchNext负责切换视角并发起截图请求:
void ASceneShotScheduler::DispatchNext() { if (!Queue.IsValidIndex(CurIndex)) { FlushMetaJson(); return; } ACameraActor* Cam = Queue[CurIndex]; APlayerController* PC = World->GetFirstPlayerController(); if (PC) { PC->SetViewTargetWithBlend(Cam, 0.f); PC->bShowMouseCursor = false; } const FString Name = BuildShotFilename(CurIndex); FScreenshotRequest::RequestScreenshot(Name, false, false); }这里有一个时序细节:SetViewTargetWithBlend的 BlendTime 传 0 表示直接切换,但视角真正生效要到下一帧再渲染。所以截图请求发出后,实际回读到的内容来自下一次渲染。为了避开这个时序坑,回调里延迟一小段时间再推下一张:
void ASceneShotScheduler::OnShotCaptured(int32 SizeX, int32 SizeY, const TArray<FColor>& BitmapData) { if (!Queue.IsValidIndex(CurIndex)) return; // 回调触发时 CurIndex 指向尚未推进的相机 AppendMetaRecord(Queue[CurIndex], SizeX, SizeY); CurIndex++; if (!Queue.IsValidIndex(CurIndex)) { FlushMetaJson(); return; } // 不让 DispatchNext 在回调栈里直接执行,等几帧再切机位 FTimerHandle Handle; World->GetTimerManager().SetTimer(Handle, this, &ASceneShotScheduler::DispatchNext, 0.05f, false); }逻辑说明:OnShotCaptured的触发时机会在回读数据可用之后。如果在这里直接递归调用DispatchNext,渲染线程还没完全释放资源,下一张请求可能与回读发生竞争。用FTimerManager延迟 50ms,按 60fps 算大约 3 帧,足够让视口状态稳定下来。
参数说明:InFramesToWait是面板输入的外部等待帧数请求,当前实现用固定 0.05f 做帧等待,InFramesToWait保留在结构里是为了后续扩展——比如某些场景需要等 SkyLights 烘焙或 nanite 流送,可以把这个延迟拉长到 0.2f 甚至 0.5f。实测下来,动态天光或体积雾场景里,0.05f 偶尔会出现亮度未稳定的截图,拉长到一帧半才不会碰运气。
3.3 文件名模板与 manifest.json 落盘
每张截图要能对应回具体的机位和参数,文件名必须可读。BuildShotFilename的做法是把相机 Actor 名称、时间戳、序号拼在一起。调试时看到文件名就知道这是哪个机位,不必打开图片看内容。
FString ASceneShotScheduler::BuildShotFilename(int32 Index) const { const ACameraActor* Cam = Queue[Index]; const FString Stamp = FDateTime::Now().ToString(TEXT("%Y%m%d_%H%M%S")); const FString Dir = UserOutputDir.IsEmpty() ? FPaths::ProjectSavedDir() / TEXT("SceneShots") : UserOutputDir; IFileManager::Get().MakeDirectory(*Dir, true); return Dir / FString::Printf(TEXT("%s_%s_%02d.png"), *Sanitize(Cam->GetName()), *Stamp, Index); }这里埋了一个坑:相机 Actor 名称如果包含非 ASCII 字符(比如直接把蓝图命名为“镜头三号”),PNG 写入时会被引擎的文件系统拒绝或变名叫乱码。所以Sanitize这个辅助函数会把所有非字母数字字符替换成下划线,保证文件名纯 ASCII。文件名不含中文这一点也算是这个工具刻进骨子里的习惯。
元数据方面,每张截图的记录被追加到MetaArray,最后统一序列化。单条记录这样构造:
void ASceneShotScheduler::AppendMetaRecord(ACameraActor* Cam, int32 W, int32 H) { TSharedPtr<FJsonObject> Obj = MakeShareable(new FJsonObject()); Obj->SetStringField(TEXT("camera"), Cam->GetName()); Obj->SetStringField(TEXT("class"), Cam->GetClass()->GetName()); Obj->SetStringField(TEXT("location"), Cam->GetActorLocation().ToString()); Obj->SetStringField(TEXT("rotation"), Cam->GetActorRotation().ToString()); UCameraComponent* C = Cam->GetCameraComponent(); Obj->SetNumberField(TEXT("fov"), C ? C->FieldOfView : 0.f); Obj->SetNumberField(TEXT("width"), W); Obj->SetNumberField(TEXT("height"), H); Obj->SetStringField(TEXT("shot_at"), FDateTime::Now().ToString(TEXT("%Y-%m-%d %H:%M:%S"))); MetaArray.Add(MakeShareable(new FJsonValueObject(Obj))); }逻辑说明:location和rotation直接用引擎的ToString,得到的是形如X=100 Y=200 Z=0的字符串。如果想在二次开发里做坐标数值计算,建议改成ExportText或拆出三个独立字段,但作为人类可读的清单,当前格式更直观。class字段记录相机类型,方便后面对比 CineCamera 和普通 CameraActor 的差异。
清单输出的部分用FJsonSerializer统一序列化:
void ASceneShotScheduler::FlushMetaJson() { TSharedPtr<FJsonObject> Root = MakeShareable(new FJsonObject()); Root->SetNumberField(TEXT("count"), MetaArray.Num()); Root->SetArrayField(TEXT("shots"), MetaArray); FString Out; TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Out); FJsonSerializer::Serialize(Root.ToSharedRef(), Writer); const FString Path = OutputDir() / TEXT("manifest.json"); FFileHelper::SaveStringToFile(Out, *Path, FFileHelper::EEncodingOptions::ForceUTF8); }参数说明:ForceUTF8是这里必须显式指定的编码选项。如果省略,引擎默认按系统编码写出,在中文 Windows 环境会出现文本编辑器里一坨乱码。JSON 字段全部用英文命名,这也是给后续其他团队接入时少留一个编码坑。
4. 拉起来跑:装进工程、配置面板、校验 manifest 一份不落
代码拆解得再细,不如实际跑一遍来得直观。这一章讲怎么把这个工具从一个源码片段变成自己工程里能点的按钮,以及跑完之后从哪里校验结果。
4.1 把模块接进自己的工程
如果你的工程已经有 Source 目录和 4.25 以上的引擎版本,接法很简单:把Source/SceneShotTool和Source/SceneShotToolEditor两个目录整个复制进工程的 Source 目录。引擎在生成项目时会自动发现带 Build.cs 的子目录模块,不需要手工改任何 Build 文件。
如果你的工程还没有 Source 目录,先在引擎里创建一个 C++ 基础工程(不是蓝图工程),这样会生成对应的 uproject 和 Source 主模块。复制完成后,在 uproject 上右键选择 Generate Project Files,等引擎重新扫描模块并生成 IDE 解决方案,然后用 IDE 打开编译。编译期如果报找不到模块的错误,九成是复制目录时漏掉了.Build.cs文件,或者模块路径嵌套层级不对,比如多套了一层目录。
编译完成后我一般会先跑一次编辑器,打开 Window 菜单确认菜单栏里出现了 SceneShotTool 这一项。如果菜单没出现,去 Output Log 搜SceneShotToolEditor关键词,能看到模块加载失败的黄字警告。这一步能过滤掉大部分环境问题。
4.2 在编辑器里配置并执行第一轮截图
打开 Window > SceneShotTool > Shot Panel,会看到一个极简的 Slate 面板,上面只有四个输入项和一个 Run 按钮。准备关卡时,在 Outliner 里选中几个 CameraActor,给它们加上ShotCam标签,可以多选后批量加。这一步是唯一需要人工准备的关卡内容。
面板参数如下:
| 参数 | 示例值 | 作用 |
|---|---|---|
| Tag Filter | ShotCam | 只截图带此标签的相机,留空则截全部相机 |
| Frame Delay | 0.05 | 每张截图完成后的等待时间(秒),数值越大越稳 |
| Output Scale | 100 | 分辨率倍率,200 表示按 2 倍分辨率截取 |
| Write Manifest | 勾选 | 结束时输出 manifest.json |
点 Run 之前,务必确认当前处于 PIE 模式,也就是点击绿色的 Play 按钮进入游戏模拟。这不是可选项而是硬性条件——FScreenshotRequest的回调绑定在 GameViewportClient 上,编辑器布局视口并不会触发这个回调。直接在一个没按 Play 的编辑器视口上点 Run,队列不会动,Output Log 里也没有任何错误,看起来像按钮坏了一样。这是刚接触这个工具的人遇到概率最高的问题,栽在这里不丢人,多跑两次自然就记住了。
Run 执行时,Output Log 会逐条打印当前截图的机位名和输出路径。看到第一行日志开始刷出来后,就可以切到游戏视口观察画面变化——每张图间隔不到 1 秒,画面会飞快地从一个机位跳到下一个机位,有点直播切镜头的感觉。全部跑完后,日志末尾会打印一行Manifest written和文件路径。
4.3 校验输出:文件、元数据、像素三个层面
跑完后打开 Saved/SceneShots 目录,预期输出是 N 张 PNG 加一个 manifest.json,N 等于过关通过的机位数量。第一次验证时我不建议只看文件数量,而是抽一张图片放大查看边缘细节——如果画面出现块状噪点,大概率是截图请求发出时分辨率倍率还停在上一轮设置,重跑一次即可。
manifest.json 的字段含义如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| camera | String | 相机 Actor 名 |
| class | String | Actor 类型名,如 CineCameraActor |
| location | String | 世界坐标 |
| rotation | String | 欧拉旋转 |
| fov | Number | 视场角 |
| width / height | Number | 截图尺寸 |
| shot_at | String | 截图时刻 |
如果要做自动化验收,location和rotation的格式现在还不能直接参与数值比较,需要解析字符串。建议在做第二轮对接时,在AppendMetaRecord里单独输出loc_x、loc_y、loc_z三个数值字段,一劳永逸。接口设计上宁可在源头多给字段,不要留字符串给下游解析。
5. 避坑记录:引擎版本差异和截图黑屏的四个翻车现场
这个工具表面简单,实际跑的链路横跨编辑器模块、渲染线程和异步文件写入,任何一个环节磕碰都会表现出“就是不出图”这种迷惑现象。这一章把我在拆读和复现过程中整理到的四个高频问题按现象、原因、解决三步列出来。
第一个坑:截图出来全是黑色。现象是 PNG 文件生成正常、尺寸正确,但打开全黑,连 UI 都没有。原因是RequestScreenshot发出时,视口还在处理前一个相机的视角切换,渲染缓冲里要么没内容要么还是上一帧的待渲染数据,回读拿到的就是黑图。解决方法是检查调度器里回调后的延时是否生效,常见做法是把 0.05f 提到 0.1f,让渲染线程有足够时间完成至少一帧完整渲染。注意不能只靠SetViewTargetWithBlend的 BlendTime 参数,引擎的视口回读与视角切换是两个独立线程,延时是必须的。
第二个坑:截到了上一个机位的画面。现象是队列推进正常,但每一张图都比预期的机位慢一拍。原因是相机切换后紧接着发出截图请求,当前帧的SetViewTarget还没在渲染线程提交,回读拿到的还是上一视角的完成帧。解决方法是把“等待半帧”变成“等待完整帧边界”——在DispatchNext里不直接调RequestScreenshot,而是先World->GetTimerManager().SetTimerForNextTick再发请求,确保当前帧的渲染提交已经完成。实测四帧延迟以内,这个错位问题基本消失。
第三个坑:编辑器非 PIE 模式下队列不推进。现象是没按 Play,直接在关卡编辑视口点 Run,日志只打印一条截图请求后全部静默。原因前面提过,OnScreenshotCaptured绑定的是 GameViewportClient,而编辑器的 Level Viewport 不是 GameViewportClient。解决方式有两个方向:一是强制所有验证流程在 PIE 里执行,把面板上的 Run 按钮在非 PIE 状态下置灰,避免误触;二是改用FHighResScreenshot系统,它走的是独立回调路径,也能兼容编辑器视口输出。如果你想要的是“不进 PIE 也能出图”,二方案是正解,但要注意高分辨率截图有自己的配置结构和更严格的存放目录规则,接进来时多花些时间读它的配置头文件。
第四个坑:截图文件写入失败或路径变乱码。现象是文件名里出现下划线横线堆叠,或者 Output Log 报 not found。原因是FScreenshotRequest对路径的处理在不同引擎小版本之间存在差异,有的版本接受绝对路径,有的版本会相对Saved目录拼路径;中文路径或含空格路径在部分平台会被文件系统拒绝。解决方法是BuildShotFilename内部统一把输出目录用FPaths::ConvertRelativePathToFull转成绝对路径,再对文件名做 ASCII 清洗。从那以后我每次做路径拼接都强制走一遍这个转换,因为它的失败模式很隐蔽,不会抛异常,只是安静地生成一个你永远找不到的文件。
6. 二次开发:把批量截图升级成自动化验收和序列帧导出小工具
这个工具拿到手之后,最顺手的两个改造方向是把它变成自动化验收工具和过场动画预览工具。两个方向都不需要动核心架构,只需要替换一层数据结构。
6.1 方向一:接入 Automation Framework 做回归验收
改造思路是把CollectCameraActors的输出结果与上一轮 manifest.json 做对比,在每张截图生成后对指定 ROI 区域做平均像素值比对。我一般的做法是给SceneShotScheduler增加一个可选的回调委托OnShotProcessed,每完成一张就把BitmapData和当前FShotMetaRecord一起抛出去,由自动化测试侧计算两个版本的像素差。如果平均色差大于阈值,就认定为渲染回归,输出失败报告。
这样改之后,SceneShotTool本身的职责没有变,依然是收集相机、排队截图、写清单,只是多了一个对外广播的口子。自动化测试代码完全独立于工具模块,不进正式包,也不污染运行时逻辑。验证方法很简单:先跑一轮作为基线,然后故意改一盏灯光的位置,重跑后看色差数据是否抬升。
6.2 方向二:把相机队列替换成帧号队列
这个改法更直接,适配的是过场动画帧导出。把TArray<ACameraActor*>换成TArray<int32>,每个元素是一个帧号,DispatchNext里不再切换相机,而是通过World->GetGameViewport()->GetWorld()->GetFirstPlayerController()->SetViewTarget固定一个机位,然后World->GetTimeManager().SetTime设置当前时间,再发截图请求。配合FramesToWait参数,把延迟调到 0.016f 左右,可以稳定做到每帧导出一次画面,形成一段序列帧。
这个改动的巧妙之处在于,CurIndex++变成FrameIndex++,回调里只需把元数据的 camera 字段替换成 frame 编号,整个 JSON 清单依然有效。视频后期工具可以直接按帧号拼接成视频预览。
从那以后,我每次接这类截图相关需求,都会先把文件命名模板和回调注册这两处代码钉死,再考虑渲染时序问题——大多数跑不起来的问题,溯源到底都会回到这两处。这个工具代码量不大,但把 Unreal 插件模块划分、渲染线程时序、异步回读和 JSON 落盘串成了一整套,花一个下午读一遍,收获比想象中多。希望帮到你。
本文还有配套的精品资源,点击获取