使用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的方式…
另外,还是要多读引擎的代码以及不盲目的相信官方才行。

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:使用Unreal Engine 4采集360°全景视频
文章作者:查利鹏
发布时间:2016年09月05日 19时44分
本文字数:本文一共有4.8k字
原始链接:https://imzlp.com/posts/64044/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!