公司里我所在的小组每两周会安排人轮流在组会上进行分享(也没这么规律)。内容以技术为主,比如纯技术,或者比较通用/有特色的业务,或者相关的前沿领域(也有讲游戏设计的)。由于没在做什么很通用的模块,对各种东西也没有很硬的了解,我基本上就介绍一下UE的某个业务模块,主要是怎么用,大致思路是什么。

上班以来我已经做了两次分享了(虽然看人数其实不应该这么频繁),就在这里留个档。当然不会把做的各种资料直接放出来,主要是把提纲整理一下备忘。

后续如果我有更多的分享也会继续在博客里同步一份。

这段时间我主要在做试用期的大活,就讲了一下调研阶段看的一些东西。由于事先就写了一些调研文档,所以这次分享的内容成型的比较快。

第一部分是 UE 相关的源码学习,没什么多说的。第二则是图像、视频库(在分享的时候只看了图像库,但后续干活又看了视频库,现在就一起整理一下)。内容涉及一些影视、音视频领域的专业知识。我对各种视频后期软件都基本会用,很早的时候也有一个便宜的单反相机(新鲜劲一过就吃灰了),但对领域专业知识其实没了解过,所以经常看到一些眼熟但又不明所以的概念,挺有意思的。

跟把大象装到冰箱里一样,离线渲染也可以划分为三步走:

  • 组织场景数据送去渲染
  • 读回渲染结果
  • 把渲染数据输出到磁盘上

如果仅从工具层面上理解 MRQ,不关心 Sequencer 工具(负责把场景组织为用户想在这一帧看到的样子)、渲染模块(负责渲染并提供接口从 GPU 读回渲染结果)的事情,剩下的也是这次分享的主要内容:

  • 驱动游戏以合适的 delta time 前进,在该渲染的时候进行渲染
  • 把渲染结果输出到磁盘上

这也是为什么题目是离线渲染架构,而不是离线渲染本身,因为渲染那块我暂时不需要看(也看不懂)。

1. 如何使用 UE5 制作视频

Movie Render Queue 是 UE5 的高质量离线渲染方案,从 UE 4.25 被引入。

UE(尤其是 5.0 以后)本身的渲染特性非常强大,MRQ 也支持一些高级的离线渲染设置比如抗锯齿、运动模糊、高分辨率等。现在在视频网站上可以看到很多使用 UE 渲染的片子(比如渲染大赛、PV、广告、MMD之类的),可以说把 UE Sequencer MRQ 加入到离线渲染工作流是现在比较主流的选择了。

MRQ 其实只能输出图片,用户需要再将图片序列自己合成视频。MRQ 之前的离线渲染框架叫做 Movie Scene Capture,是可以直接输出 .avi 视频的,我看了一下是用的 windows 的 directshow,实在是太古老了。MRQ 顶替掉 MSC 后,UE 的 Sequencer 就不支持直接输出视频了,没做的原因我想可能是因为 FFmpeg 的 license 并不是完全的可发布可商用吧。

(不过我调研的时候好像看到 experimental 里好像有 UE 准备在做的自己的视频编码工具。其实有点记不清了。)

2. 驱动游戏以合适的步长前进

对步长的要求

离线渲染跟实时渲染不同,对游戏的 delta time 有两个要求:恒定和准确。

恒定

第一,输出帧之间使用固定的 delta time,视频跟游戏不同,帧之间必须是固定的步长,要不后期没法做了。

注意是“输出帧”之间的间隔恒定,而不是“渲染帧”。因为如果使用时序抗锯齿,或者动态模糊等高级设置,每一个输出帧内会进行多次渲染,把多个渲染帧的结果融合为输出帧的结果。

MRQ 的做法是内部有一个自己的管线 MoviePipeline,在录制的时候会直接接管引擎的 tick 步长的计算。

Engine tick 开始前,计算这一帧的 delta time,并使用计算出来的 delta time 驱动游戏世界的更新;Engine tick 结束后,将游戏世界送去渲染,读回渲染数据并处理成图片,写入磁盘。

准确

第二,尽量使用准确的时间表示方法

主要原因是离线渲染很容易让浮点误差快速累积:

  • 需要支持 23.976 (N 制)这种历史遗留的行业标准帧率,还要跟 24 fps (P 制)区分开
  • 要支持 240 这种非常高的帧率
  • 要支持扩展一些时序上的高级离线渲染设置(如动态模糊),把一个输出帧继续分为多个渲染帧

UE 的做法是在 UE 4.20 引入了时间重构,在 Sequencer 内部引入了 FFrameRate、FFrameTime 两个表示时间的数据结构,使用整数替代一部分浮点数。具体见 https://docs.unrealengine.com/4.27/zh-CN/AnimatingObjects/Sequencer/TechDocs/TimeRefactorNotes/。

FFrameRate 理解为一个”率“,由除数、被除数两个 int 组成。FFrameTime 理解为一个 ”量“,分为整数部分的 uint + 小数部分的 float。

步长的计算

MRQ 中用于计算步长的数据主要有这几个:

  • TickResolution:率,度量每秒有多少个 ticks
    • 不再以秒作为时间单位,把每秒拆分为若干个 ticks,TickResolution 就可以度量 level sequence 逻辑上的播放速率
    • 在 Sequencer 点开 Advanced Options 可以修改 TickResolution ,默认是 24000,可以支持绝大多数的真实影片帧率(23.976 = 2400/1001,29.97=3000/1001,24000 是分子的公倍数)
  • TicksPerOutputFrame:量,度量每个输出帧的 ticks 数
    • 根据用户设置的帧率和 Tick Resolution 计算而来
    • 24 fps(24/1)每个输出帧就有 1000 ticks,23.976 fps(24000 / 1001) 则是 1001 ticks,这样就很好地区分开了
  • ShutterAnglePercentage,TicksWhileShutterClosed,TicksWhileShutterOpen:量,用于支持动态模糊
    • 可以看影视飓风的科普文章 https://zhuanlan.zhihu.com/p/69271958,了解相关的影视概念,文章里 270° 快门角度的图画错了,可以看我下面改的
    • 引入动态模糊后,把一个输出帧分为镜头开启、镜头关闭两部分,这些变量度量开启、关闭各有多少 ticks
    • 动态模糊可以在 MRQ 里作为高级离线渲染设置被开启,也可以被场景内的后处理盒子、相机设置开启
  • TicksPerSample:量,度量每个时间采样的 ticks 数,用于支持时间采样
    • 也就是把镜头开启时间再划分为多个时间采样,用来做时间上的抗锯齿
    • 时间采样在 MRQ 离线渲染设置中进行配置
  • DeltaFrameTime:量,这一次时间采样的 ticks 数
    • 如果不开启时间采样,DeltaFrameTime 其实就是 TicksPerOutputFrame
    • 如果开启时间采样
      • 第一个采样的 DeltaFrameTime 为 TicksWhileShutterClosed + TicksPerSample
      • 后续的采样的 DeltaFrameTime 为每一采样的 ticks 数,也就是 TicksPerSample

一图流:

此外,每个采样会把小数部分累积下来,把整数部分加到每个输出帧的第一个采样中。

最后会用 DeltaFrameTime 跟 TickResolution 反算回这一次采样的浮点数步长,传给 engine loop。这样使用整数进行中间乱七八糟的计算,很大程度上避免了浮点数在计算、存储上的误差。

这一部分其实只会介绍 delta time,剩下的都是特定框架下的实现,就不属于此分享里的内容了

  • 在每个采样时间读回渲染数据
  • 累积多个采样的渲染结果,生成输出帧数据

3. 转换渲染数据并输出到磁盘

ImageWriteTask 调度和执行

UE 封装一个 ImageWriteTask 负责从渲染数据到磁盘上的图片之间所有转换工作。每一帧渲染结束后,MoviePipeline 获取这一帧的渲染数据。下一帧渲染开始前,把前一帧的渲染数据组装到 ImageWriteTask。

这个 Task 会 enqueue 到一个 ImageWriteQueue,然后再加入 UE 的任务系统。具体怎么调度的就不细说了,UE 任务这一块虽然很重要不过我也没仔细去看。只简单跟了一下,做了下面这个图示意一下。

ImageWriteTask 执行的时候就会把渲染数据转换为磁盘上的图片文件,主要做这么几件事:

  • 像素预处理
  • 使用封装的第三方库进行图像编码
  • 使用 UE 序列化模块进行存盘

像素预处理首先会做 gamma 矫正。我看了一堆资料,说什么的都有。目前我的理解就是为了提高暗部亮度的存储精度。然后就是转换颜色空间到 sRGB。

图像频库调研

写了使用方法的是我自己用过的。

其中 libpng 库可以说是 .png 格式毫无疑问的选择了,实际用的时候在性能上发现了一点问题:

在缺省情况下,在压缩图片时,libpng 会对图片的每一行都根据某种规则选择五种之一的 filter 进行压缩。

在一个调研(https://github.com/Beep6581/RawTherapee/issues/4045)中发现,在编码生成的图像质量/体积基本相同的情况下,把 filter 指定为 PNG_FILTER_PEATH,把 compression strategy 指定为 Z_RLE 算法 。

UE5 使用 libpng 的过程中没有指定具体的参数,但不知为何编码速度比较快(可能是因为测试的图像内容比较单一,也可能是对库进行了修改,没有细究)。

4. MRQ 的其他部分

到此介绍的内容有:

  • 计算 delta time 并与 engine tick 交互
  • 创建写图片任务,在任务系统中调度
  • 执行写图片任务,进行像素预处理、压缩、存盘

MRQ 真正做的事情比这些要多得多,作为高质量离线渲染解决方案,MRQ 会根据用户的高级设置(抗锯齿、时间/空间采样、超分辨率等),自行组织数据送去渲染。

我们今天只介绍了时序上如何 tick 到一个渲染帧进行渲染。对每个渲染帧,其中也会进行若干次的渲染,结构是这样的:

for_loop (tile_y)
{
    for_loop (tile_x)
    {
        for_loop (spatial_render_samples)
        {
            for_loop (render_pass)
            {
                render_pass->RenderSample_GameThread();
            }
        }
    }
}

tile 就是为了支持超分辨率,把一张图片横竖分成很多块分开渲染,再把结果拼起来。此外会对每个空间采样也进行渲染,主要是为了支持抗锯齿(文中只讲了时间采样,其实空间也会采样的)。最后会对每个 render pass 都进行渲染,这个 render pass 就是用户在 MRQ 渲染设置里配置的关于 deferred rendering 的若干选项。

补充:视频和FFmpeg

基本概念

FFmpeg 也是视频领域毫无疑问的选择了(虽然 license 我还没完全看明白怎么回事,暂且先用了)。

FFmpeg 是一个开源软件项目,由一套用于处理视频、音频和其他多媒体文件和流的库和程序组成,是相关领域最被广泛使用的第三方库。FFmpeg 的绝大多数部分使用 LGPL license,少部分使用 GPL license。

MP4、AVI、MKV、FLV 等通常说的视频格式可以理解为容器,里面装着各种具体内容。在一些视频相关的资料中,用 “文件格式” 和 “编码格式” 来区分这两个概念。

  • AVI 格式是微软在 1992 年提出的封装标准,比较古老,不兼容一些使用现代压缩技术的编码格式
  • MPEG-4 Part 14(简称 MP4)是目前最常用的容器格式,可用于存储视频、音频、字幕、静态图像等数据,主要用来封装以 MPEG-4 系列标准编码的视频文件
    • H.264/AVC 是 MPEG-4 Part 10,是迄今为止最常用的视频内容录制、压缩和分发格式
    • H.265/HEVC 是 MPEG-H Part 2,是新兴的高级编码格式,应用也比较广泛。与 AVC 相比,HEVC 在相同的视频质量水平下提高了 25% 到 50% 的数据压缩程度,也就是在相同的比特率下明显提高了视频质量

如 MP4 容器可以装 AVC/H.264 或者 HEVC/H.265 等编码格式的视频 + AAC 或者 MP3 等编码格式的音频 + 一些编码格式的字幕。不同容器能装的格式有所不同,AVI容器比较古老,装不了 264、265 等较高级格式的视频。

在视频编码时 FFmpeg 对指定的 .avi 文件会默认推断为使用编码器 AV_CODEC_ID_MPEG4;对于MP4格式,可以指定两种编码器 AV_CODEC_ID_H264AV_CODEC_ID_HEVC 分别编码 H.264/AVC 和 H.265/HEVC 的视频。

FFmpeg 使用细节记录

在指定开始帧、需要立刻输出到文件的情况下必须把 tune 设置为 “zero-latency”,其中 AVC 格式的对应为 “zerolatency”(没有 ‘-‘)。如果设置为别的选项,编码、输出会延迟,通常用于直播推流等场合。

参考 FFmpeg 官方的 muxing 示例(https://ffmpeg.org/doxygen/5.1/muxing_8c-example.html),使用比较时间戳(`av_compare_ts()`)的方式判断当前写视频是否应该结束。

主要有 -crf 、-preset 两个参数来控制编码视频的画面质量和压缩质量。在这篇文档中,对这两个参数都有所提及:https://blog.csdn.net/happydeer/article/details/52610060

crf 参数在使用上的一些共识:

  • FFmpeg 不同编码器的 crf 默认值:H.264 是 23,H.265 是 28
  • 通常认为 <=18 就是视觉无损范围
  • crf 每相差 6,视频体积约相差一倍

-preset 用来适配在编码速度和压缩率之间做的不同的权衡。编码速度越慢,压缩比率更高,文件更小。FFmpeg 所有的编码速度选项按降序排列为:ultrafast,superfast,veryfast,faster,fast,medium(缺省),slow,slower,veryslow,placebo。

很多视频软件都支持了这个配置。

视频相关概念扩展

① 转换颜色空间

视频的像素格式转换过程均为:RGBA->RGB->YUV420P。由从GPU读回的RGBA渲染数据,先舍弃A通道,然后转换为视频使用的YUV420P格式(sws_scale())。

RGB不利于压缩,YUV引入了亮度概念,认为没必要存储全部颜色信号,而把一些带宽给了人眼敏感度更高的亮度信号。Y指亮度(Luma),U和V指蓝色、红色的色度(Chroma)。

详见百科:https://en.wikipedia.org/wiki/YCbCr

② IBP 帧和 gop size

详见百科:

https://en.wikipedia.org/wiki/Group_of_pictures

除了对单张图片数据进行了像素上的压缩,视频需要在时间上也进行压缩。目前最主流的方法是使用IBP帧。

  • I 帧:intra coded 图像,可以理解为关键帧,具有全部数据,独立进行编码
  • P 帧:predictive coded 图像,不存储全部的图片数据,只存储跟其它帧(称为参考帧)的偏移
    • 在较旧的设计中,每个 P 帧只能引用一个参考帧,并且该图片在显示顺序和解码顺序上都必须先于 P 帧,并且参考帧必须是 I 或 P 帧。在 AVC 和 HEVC 已经取消了这些限制
  • B 帧:bipredictive coded 图像,包含相对于先前解码图像的运动补偿差异信息
    • 在较旧的设计中,每个 B 帧只能引用显示顺序位于 B 帧前后的 2 帧,并且参考帧必须是 I 或 P 帧。AVC 和 HEVC 已经取消了这些限制
    • 有时编解码器会使用单向 B 帧。理解为一个没有其他帧依赖于它的 P 帧,它也不使用未来帧的数据
    • B 帧的一个基本属性是它们可以被丢弃而不影响其他帧的正确解码。

一般来说,视频流的 I 帧越多,它的可编辑性就越高,也会增加视频编码所需的比特率。