使用 Unreal Engine 4 采集 360°全景视频

本文部分内容摘自 Unreal Engine 的官方博客文章:从虚幻 4 中采集 360 度立体电影,其余部分为修正该文章错误和提供一个现成可行的解决方案。

采集单帧双眼图像

首先,我们需要确保启用了相应的插件。

在编辑器打开的情况下,转到 编辑(Edit)-> 插件(Plugins),然后选择左边的 电影采集(Movie Capture)设置,确保对 立体全景电影采集(Stereo Panoramic Movie Capture)选择 启用(Enabled)
然后重新启动场景编辑器。

注:您可能还需要再次快速 构建 (Build),具体取决于您是否在分支中获得了本地更改,因为工具附带的插件 dll 可能已经 陈旧

当编辑器重新启动后,再次转到 编辑器(Editor)-> 插件(Plugins)-> 电影采集(Movie Capture),并再次检查其是否已启用。

打开关卡蓝图 (level Blueprint),新建Event BeginPlay 事件,然后再新建数个 (具体依据需求而定)Execute Console Command 节点来存放我们需要执行的命令。
我们可以先来一次采集测试,将下面这两条命令放入 Execute Console Command 节点中:

1
2
3
SP.OutputDir F:/StereoCaptureFrames
// 采集单帧
SP.PanoramicScreenshot

如图:

然后就可以 启动 (Play) 项目了,此时系统可能会长时间无响应(估计有一分钟左右),然后将会有两帧影像存储到您在先前用 SP.OutputDir 指定的目录中(其实是在该目录中的一个日期与时间目录下),一个是左眼图像,另一个是右眼图像。

将左右眼图像自动组合成单一图像

这部分是我依据上面提到的官方文章修改的,也是上述文章中错误最多的部分。
在执行下列步骤之前首先需要保证你使用官方插件采集全景时可以成功导出左右眼图像。

首先先将引擎中的全景采集插件 (Stereo Panoramic Movie Capture) 目录剪切 (注意是 cut 而不是 copy) 出来,一是供我们修改,二是防止和我们自己编译的有冲突。Unreal 引擎中的插件在路径 \4.12\Engine\Plugins 下,在这里我们需要将其中的 StereoPanorama(\Engine\Plugins\Experimental\StereoPanorama) 剪切出来。

然后打开你要采集全景视频的项目文件夹,在项目文件夹的根目录下新建一个 Plugins 文件夹,将上一步剪切的 StereoPanorama 文件夹粘贴到这里。
此时该项目的文件结构应该是这样的(碍于篇幅只列出必要文件):

后面我会着重讲到修改 SceneCapturer.cpp 中的代码来实现我们想要的功能。

现在,打开你想要采集全景视频的项目。
在场景编辑器中依次打开 Edit->Plugins->Project->MovieCapture 启用 Stereo Panoramic Movie Capture 然后重启场景编辑器。
再次检查 Stereo Panoramic Movie Capture 是否被启用。
如果上面都 OK,那么下面就开始正式开搞。

首先,打开 \YouProjectFolder\Plugins\StereoPanorama\Source\StereoPanorama\Private\SceneCapturer.cpp,大概八九百行代码的样子,而且我用 diff 对比了一下引擎版本 4.11 和 4.12 两个引擎版本的 SceneCapturer.cpp 区别,其实没有实质性的改变,就是修改了一些不合规范 (随意) 的变量命名,所以这份教程在 4.11 和 4.12 中都是通用的。

为了使我们能够方便地控制合成的开关,我们需要定义一个 bool 常量在文件的头部,这样,在我们不需要开启合并的时候修改该常量的值即可,不必再修改其余的代码。

1
2
// Newly inserted code.Defined a const bool
const bool CombineAtlasesOnOutput = true;

现在我们需要在代码中有条件地禁用每只眼睛的输出 (通过上面定义的CombineAtlasesOnOutput 来控制)。
然后找到 USceneCapturer::SaveAtlas() 的底部,找到这样一段代码:

1
2
3
4
IImageWrapperPtr ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG);
ImageWrapper->SetRaw(SphericalAtlas.GetData(), SphericalAtlas.GetAllocatedSize(), SphericalAtlasWidth, SphericalAtlasHeight, ERGBFormat::BGRA, 8);
const TArray<uint8>& PNGData = ImageWrapper->GetCompressed(100);
FFileHelper::SaveArrayToFile(PNGData, *AtlasName);

这几行代码就是控制左右眼输出的,如果我们定义的 CombineAtlasesOnOutputtrue,就意味这我们需要合并两张眼睛的图像,那么我们就需要禁掉它 (左右单独输出),如果为false 则我们需要输出左右眼的单独序列帧,所以就需要执行它。
综上,可以写一个 if 语句来判断 CombineAtlasesOnOutput 的值:

1
2
3
4
5
6
7
IImageWrapperPtr ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::PNG);
if (!CombineAtlasesOnOutput)
{
ImageWrapper->SetRaw(SphericalAtlas.GetData(), SphericalAtlas.GetAllocatedSize(), SphericalAtlasWidth, SphericalAtlasHeight, ERGBFormat::BGRA, 8);
const TArray<uint8>& PNGData = ImageWrapper->GetCompressed(100);
FFileHelper::SaveArrayToFile(PNGData, *AtlasName);
}

这样会导致一个错误,因为 PNGData 是在 if 的作用域内定义的,如果执行到了 if 后 (被释放掉) 或者根本没有执行到 (if 判断为 false(!true)) 就会导致后面对 PNGData 的使用造成错误。
在上面 if 语句之后的代码块中对 PNGData 的使用处为:

1
2
3
4
5
6
7
8
9
10
if (FStereoPanoramaManager::GenerateDebugImages->GetInt() != 0)
{
FString FrameStringUnprojected = FString::Printf(TEXT("%s_%05d_Unprojected.webp"), *Folder, CurrentFrameCount);
FString AtlasNameUnprojected = OutputDir / Timestamp / FrameStringUnprojected;

ImageWrapper->SetRaw(SurfaceData.GetData(), SurfaceData.GetAllocatedSize(), UnprojectedAtlasWidth, UnprojectedAtlasHeight, ERGBFormat::BGRA, 8);
const TArray<uint8>& PNGDataUnprojected = ImageWrapper->GetCompressed(100);
// 原来的代码为 FFileHelper::SaveArrayToFile(PNGData, *AtlasNameUnprojected);
FFileHelper::SaveArrayToFile(PNGDataUnprojected, *AtlasNameUnprojected);
}

diff:

对禁用左右眼单帧输出部分,如果只写这部分代码,现在再执行采集是不会有任何有意义图像输出的(因为现在已经把左右眼输出禁用了)。下面继续搞将两张合并到一块的方法。

官方博文中给出了这部分代码,但是不知道是什么原因,模板类 (TArray<T>) 的所有参数都不见了,这样直接粘贴到代码里是无论如何也编译不过的。
官方提供的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
TArraySphericalLeftEyeAtlas = SaveAtlas(TEXT("Left" ), UnprojectedLeftEyeAtlas );
TArraySphericalRightEyeAtlas = SaveAtlas(TEXT("Right"), UnprojectedRightEyeAtlas);

//*NEW* - Begin
if (CombineAtlasesOnOutput)
{
TArrayCombinedAtlas;
CombinedAtlas.Append(SphericalLeftEyeAtlas);
CombinedAtlas.Append(SphericalRightEyeAtlas);
IImageWrapperPtr ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::JPEG);
ImageWrapper->SetRaw(CombinedAtlas.GetData(), CombinedAtlas.GetAllocatedSize(), SphericalAtlasWidth, SphericalAtlasHeight * 2, ERGBFormat::BGRA, 8);
const TArray& PNGData = ImageWrapper->GetCompressed(100);
// Generate name
FString FrameString = FString::Printf(TEXT("Frame_%05d.webp"), CurrentFrameCount);
FString AtlasName = OutputDir / Timestamp / FrameString;
FFileHelper::SaveArrayToFile(PNGData, *AtlasName);
ImageWrapper.Reset();
}
//*NEW* - END

// Dump out how long the process took
FDateTime EndTime = FDateTime::UtcNow();
FTimespan Duration = EndTime - StartTime;
UE_LOG(LogStereoPanorama, Log, TEXT("Duration:%g seconds for frame %d" ), Duration.GetTotalSeconds(), CurrentFrameCount);
StartTime = EndTime;

真是满腹槽点,因为之前没读过 Unreal 引擎的代码,我还以为是我自己搞错了,后来读了相关的代码 (自己对了一下上面代码的语法) 才发现是官方代码写错了…

上面所有的 TArray 的模板参数都没有了,正确的用法是 TArray<T>,模板类的类型推导是在编译时计算的,而现在的问题是怎么通过现有的代码逆推回去TArray 该有的模板参数。
没办法(其实有,看后面),来一点一点分析下吧。

首先,我们可以通过以下两行代码可以确定 SphericalLeftEyeAtlasSphericalRightEyeAtlas是同一种类型(废话)

1
2
TArraySphericalLeftEyeAtlas = SaveAtlas(TEXT("Left" ), UnprojectedLeftEyeAtlas );
TArraySphericalRightEyeAtlas = SaveAtlas(TEXT("Right"), UnprojectedRightEyeAtlas);

现在我们可以通过查看引擎中已有的代码 (SaveAtlas() 的定义)来获取 SphericalLeftEyeAtlasSphericalRightEyeAtlas应该是什么类型,就是看下调用 SaveAtlas() 的返回类型是什么。

引擎中定义的 SaveAtlas() 返回类型为TArray<FColor>

1
TArray<FColor> USceneCapturer::SaveAtlas(FString Folder, const TArray<FColor>& SurfaceData)

OK 现在可以确定 SphericalLeftEyeAtlasSphericalRightEyeAtlas都是 TArray<FColor> 了。

而后面 CombinedAtlas 的类型可以通过 SphericalLeftEyeAtlasSphericalRightEyeAtlas推导出来:

1
2
3
TArrayCombinedAtlas;
CombinedAtlas.Append(SphericalLeftEyeAtlas);
CombinedAtlas.Append(SphericalRightEyeAtlas);

成员函数 append() 从其函数名可以看出来其是在用容器 TArray 定义的一个实例中添加一个元素,所以我们可以确定 CombinedAtlas 的类型和 SphericalLeftEyeAtlasSphericalRightEyeAtlas一样,即TArray<FColor>

剩余的代码中用到 TArray<T> 的就只有 PNGData 了:

1
2
const TArray& PNGData = ImageWrapper->GetCompressed(100);

方法同上,我们查看一下 GetCompressed() 的定义就可以得到 PNGData 真正的类型。

GetCompressed()FImageWrapperBase 类中的成员:

1
const TArray<uint8>& FImageWrapperBase::GetCompressed(int32 Quality)

即得到 PNGData 真正的类型为TArray<uint8>&

修改完的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
TArray<FColor> SphericalLeftEyeAtlas  = SaveAtlas(TEXT("Left" ), UnprojectedLeftEyeAtlas );
TArray<FColor> SphericalRightEyeAtlas = SaveAtlas(TEXT("Right"), UnprojectedRightEyeAtlas);

//*NEW* - Begin
if (CombineAtlasesOnOutput)
{
TArray<FColor> CombinedAtlas;
CombinedAtlas.Append(SphericalLeftEyeAtlas);
CombinedAtlas.Append(SphericalRightEyeAtlas);
IImageWrapperPtr ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::JPEG);
ImageWrapper->SetRaw(CombinedAtlas.GetData(), CombinedAtlas.GetAllocatedSize(), SphericalAtlasWidth, SphericalAtlasHeight * 2, ERGBFormat::BGRA, 8);
const TArray<uint8>& PNGData = ImageWrapper->GetCompressed(100);
// Generate name
FString FrameString = FString::Printf(TEXT("Frame_%05d.webp"), CurrentFrameCount);
FString AtlasName = OutputDir / Timestamp / FrameString;
FFileHelper::SaveArrayToFile(PNGData, *AtlasName);
ImageWrapper.Reset();

}
//*NEW* - END

我修改好的 SceneCapturer.cpp 可以 点此下载

此时再编译这个插件然后再启动项目就会采集并将左右眼合并成一张图片了。

其实修复上面的代码还有一个更简单的方法:在 C++11 中,其实可以不用上面写得这么麻烦去翻定义来查返回类型是什么,在定义新对象来接收调用返回的对象的时候都可以用 auto 关键字来定义,这样编译器就会自动给我们推导出所要接收的对象真正的类型。

如,上面的代码可以写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
auto SphericalLeftEyeAtlas  = SaveAtlas(TEXT("Left" ), UnprojectedLeftEyeAtlas );
auto SphericalRightEyeAtlas = SaveAtlas(TEXT("Right"), UnprojectedRightEyeAtlas);

//*NEW* - Begin
if (CombineAtlasesOnOutput)
{
// 此处不可以用 auto,auto 定义的变量必须有初始值,因为若没有编译器无法推导其类型
TArray<FColor> CombinedAtlas;
CombinedAtlas.Append(SphericalLeftEyeAtlas);
CombinedAtlas.Append(SphericalRightEyeAtlas);
IImageWrapperPtr ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::JPEG);
ImageWrapper->SetRaw(CombinedAtlas.GetData(), CombinedAtlas.GetAllocatedSize(), SphericalAtlasWidth, SphericalAtlasHeight * 2, ERGBFormat::BGRA, 8);
auto PNGData = ImageWrapper->GetCompressed(100);
// Generate name
FString FrameString = FString::Printf(TEXT("Frame_%05d.webp"), CurrentFrameCount);
FString AtlasName = OutputDir / Timestamp / FrameString;
FFileHelper::SaveArrayToFile(PNGData, *AtlasName);
ImageWrapper.Reset();

}
//*NEW* - END

是不是方便了很多!但是这样写以后如果要重构的话…那画面太美。俗话说的好,动态类型一时爽,代码重构火葬场。虽然具有了 auto 的 C++ 仍然是静态类型语言,但是在编译期的类型推导足够让程序猿们造成依赖 (主要是懒) 了。

C++11 中有很多很多好用的新特性,而且我翻看 UnrealEngine 的代码也发现其中大规模运用了 C++11 的特性,所以还是要好好补充一下 C++11 特性的知识比较好。

更多 C++11 的内容可以看我这篇博文:C++11 的语法糖

采集连续的序列帧

通过上面的几个步骤我们可以顺利地采集出合并左右眼睛的单帧,但是我们终究还是需要采集连续帧然后来合并这些序列帧生成视频的。

我们可以通过这个命令来采集连续的序列帧:

1
2
3
4
// 采集多个连续帧数 (一段视频) 可以用 SP.PanoramicMovie,其后应该有两个参数 startime 和 endtime
// startime 和 endtime 皆为帧数
// 假如我要采集 1s 的视频,且已经在工程中设置其 fps 为 30,我们就可以采集从第 0 帧到第 29 帧的图像(1s)
SP.PanoramicMovie 0 29

但是直接这样做会有问题,假如你项目中设置 FPS 为 30,(单独)直接加这一条命令 (SP.PanoramicMovie 0 29) 会导致你采集的帧数 30 帧并不是实际项目 1s 的帧数,如果这样采集出来合成视频的话,大概等同于快进了 4-5 倍,所以说直接这样会导致采集掉帧。其实就是因为采集视频的时候引擎不是按固定步长时间运行的。

在采集电影时,首先 一定要记住 的最重要的事情是:要按固定的时间步长运行。

假如我们需要采集一秒 30 帧的图像,我们必须要告诉引擎必须按固定的时间步长使时间流逝,除非你希望 2 秒钟的视频只有两个输出帧。

你需要依次做以下几步:

  1. 场景编辑器 (UE4Editor)-> 编辑 (Edit)-> 项目设置 (Projects settings)-> 引擎 (Engine)-> 一般设置 (General Settings)->Framerate 中勾选上 Fixed Frame Rate 并设置为30(依据你想要采集的 FPS)

  2. 在启动引擎时添加参数。
    我们可以在引擎启动时添加 -usefixedtimestep -fps= 来让引擎以固定的步长时间流逝。
    可以在 UE4Editor 快捷方式中添加该指令:

    1
    X:\Unreal\4.12\Engine\Binaries\Win64\UE4Editor.exe -usefixedtimestep -fps=30 -notexturestreaming

    -notexturestreaming参数的作用是关闭 纹理流

执行上面两步之后再使用 SP.PanoramicMovie 来采集就不会出现丢帧的效果了。

合并采集的图像序列为全景视频

通过上面几个步骤以及再参照官方文档的部分教程,我们可以顺利地得到一些序列帧。

如何将这些序列帧合并为可以播放的全景视频是当前的任务,官方那篇博文推荐的是 ffmpeg(we tend to just use ffmpeg.)
你可以自己去 ffmpeg 官网 下载,或者 在此下载 我离线的版本。

使用方法就是,将上一步下载下来的 ffmpeg.exe 放在导出全景图的目录, 然后打开 CMD 跳转到该目录后执行以下命令:

1
ffmpeg -y -r 30 -i Frame_%05d.webp -vcodec mpeg4 -qscale 0.01 video.mp4

-r是帧率 (FPS),更多的参数可以看 ffmpeg 的文档——ffmpeg Documentation
执行完毕就会在当前目录下生成一个 video.mp4 文件。

或者要是实在不想动手也可以用下面的批处理命令(注意: 不要把 ffmpeg 和该批处理放在和图片的同级的目录)

1
2
3
4
@echo off
set /p fps=Please input FPS(1-60):
set /p quality=Please input video quality(0.01-255).The smaller number more clarity:
ffmpeg -y -r %fps% -i ../Frame_%%05d.webp -vcodec mpeg4 -qscale %quality% ../video.mp4

你的目录结构应该是下面这样的,才可以执行ConvertImagesToVideo.bat

含有 ConvertImagesToVideo.bat 的 ffmpeg 压缩包可以 点此下载

资源整理

如果不想动手改代码且懒得编译的话可以 点此下载 我修改并编译好的版本,使用方法类似上面。将官方的插件文件夹中的 StereoPanorama 删掉 (或者自己留存) 之后,将前面下载的插件压缩包解压到你需要采集的项目的根目录(或者直接替换掉官方的插件)。

此时该项目的文件结构应该是这样的(碍于篇幅只列出必要文件):

然后启动项目,在场景编辑器里启用 (并添加好采集节点) 就可以开始采集全景序列帧了。

另附其他工具:

  1. 修改并编译好的StereoPanorama
  2. 修改好的SceneCapturer.cpp
  3. ffmpeg(There is no bat)
  4. ffmpeg and bat

参考文章

  1. 从虚幻 4 中采集 360 度立体电影
  2. ffmpeg Documentation
  3. C++11 的语法糖

结语

其实写了这么多,总共精要的部分是没有多少的,主要是记录了一下 debug 的方式…
另外,还是要多读引擎的代码以及不盲目的相信官方才行。