D0251235d5b1bfaa439b2598edbf951f
Metal for Pro Apps

Metal for Pro Apps

WWDC 2019 Session 608 Metal for Pro Apps 介绍了如何使用 Metal 发挥出硬件的全部能力,更好的为专业 App 服务。本文是对该 Session 的归纳总结。阅读本文需要对 Metal 和图形渲染有一定的理解。

那么,如何定义专业 App 呢?苹果将为专业内容创作者服务的多媒体制作应用定义为专业 App,包括视频剪辑、图像编辑、游戏和 3D 动画、印刷媒体、音频制作等。

专业 App 通常需要操作大量的资源与数据,进行大量的运算,并让所有操作结果能够实时预览的同时保持内容不失真。这类 App 对设备的性能通常有着不低的要求,因此本文会更关注在 macOS 上的应用,但其中的思想与 iOS 是相通的。

视频剪辑是最有典型性的专业 App,也是演示的绝佳例子。因此本文也将围绕着 Metal 和硬件在视频剪辑上的应用展开介绍,具体包括四个方面:

  • 优化 8K 视频剪辑
  • 支持高动态范围成像(HDR)
  • 充分利用全部机能
  • 实现高效的数据传输

优化 8K 视频剪辑

在使用 Metal 优化之前,即便是全新的 Mac Pro 剪辑 8K 视频也无法达到我们实时流畅预览的要求。
这是因为剪辑如此高分辨率的素材通常要使用一个叫代理剪辑(Proxy)的流程:原始的 8K 相机镜头数据导入之后,会降采样转码为 4K 以让用户流畅的剪辑,剪辑完成之后 App 会将用户的操作重新应用在 8K 素材上,重新渲染 8K 视频可能会耗费数个小时。
经过 Metal 的优化后,用户将可以流畅剪辑 8K 视频!Amazing!

视频编辑流水线(Video Editing Pipeline)

一个标准的视频剪辑流水线大概长这个样子:

Video Editing Pipeline

Video Editing Pipeline

我们会关注其中的解码(Decode)、像素处理(Pixel Processing)、显示(Display) 和编码(Encode)部分。至于 Import 和 Export,苹果鼓励开发者查看 AVFoundation 中的 AVAssetReaderAVAssetWriter示例代码

年久失修的 AVFoundation Programming Guide 中有更多关于使用 AVAssetReader 和 AVAssetWriter 的示例代码。

使用 VideoToolbox 解码

让我们先从解码开始。使用 VideoToolBox 框架可以高效地处理视频,它支持大量的视频格式并能充分利用所有硬件,包括 CPU,GPU 和 Apple Afterburner。VideoToolBox 支持 iOS、macOS 和 tvOS。

Apple Afterburner 是一款专为视频剪辑而生的加速卡,可同时播放三条 8K ProRes RAW 视频流。在 WWDC 2019 Keynote 作为新款 Mac Pro 一部分推出。

使用 VideoToolBox 中的 VTCompressionSession 接口,可以用于解码,示例代码如下:

// 启用硬解码。
// 注意,`kVTVideoDecoderSpecification_EnableHardwareAcceleratedVideoDecoder` 是 macOS 限定。
CFDictionarySetValue(decoderSpec,
                     ideoDecoderSpecification_EnableHardwareAcceleratedVideoDecoder,
                     kCFBooleanTrue);

// 创建 VTDecompressionSession
VTDecompressionSessionCreate(NULL,
                             videoFormatDescription, // 输入视频格式
                             decoderSpec,
                             destinationImageBufferAttributes,
                             &callBackRecord, // `didDecompress` 会在每一帧被处理完成后回调
                             &session);
// ...

// 开启异步解码
VTDecodeFrameFlags decodeFlags = kVTDecodeFrame_EnableAsynchronousDecompression;
VTDecompressionSessionDecodeFrame(session,
                                  sampleBuffer,
                                  decodeFlags,
                                  &outputPixelBuffer,
                                  &flagOut);
// ...

// 使用结束之后,清理 session
VTDecompressionSessionInvalidate(session);
Metal 与 Core Video

我们的 Mac 中可能有多个硬件支持解码,为了确保我们正在使用同一份物理内存(避免拷贝发生),我们需要使用 IOSurface 作为后备存储(Backing Store)。IOSurface 是一个共享的支持硬件加速的 Image Buffer,通过使用 CPU 驻留追踪(GPU residency tracking),它还支持跨进程、跨框架间的访问。

苹果提供给开发者 CVMetalTextureCache 接口,它内部使用了 IOSurface,通过它我们可以直接访问解码后的 Buffer。

CVMetalTextureCacheRef sessionMetalCache; 

CVMetalTextureCacheCreate(..., metalDevice, ..., &sessionMetalCache); 

// 在解码后的 pixelBuffer 回调中
CVMetalTextureRef textureOut;

// 如果此时 Pixel Buffer 由 IOSurface 后备存储,可以零成本创建一个映射向 Pixel Buffer 的 Metal Texture。
CVMetalTextureCacheCreateTextureFromImage(..., 
    sessionMetalCache, 
    pixelBuffer, 
    metalFormat, 
    CVPixelBufferGetWidthOfPlane(pixelBuffer, 0), 
    CVPixelBufferGetHeightOfPlane(pixelBuffer, 0), 
    0, &textureOut); 
id<MTLTexture> texture = CVMetalTextureGetTexture(textureOut); 

// ...
CFRelease(textureOut); // 在渲染结束之后释放掉 texture 

// ...
CVBufferRelease(pixelBuffer); // 回收 pixelBuffer

用 Metal 处理像素

我们有多种方式处理像素,一种是实现自己的 Metal Shaders(使用基于 C++ 的 Metal Shading Language),同时苹果鼓励使用 MPS (Metal Performance Shaders) 提供的深度优化过的常见 Filter 实现。
下面将举例如何使用 MPS 提供的 blur filter:

func myBlurTextureInPlace(inTexture: MTLTexture, blurRadius: Float, queue: MTLCommandQueue)
{
    // 获取 Metal Device
    let device = queue.device
    guard let buffer = queue.makeCommandBuffer() else {
        return
    }

    // 创建一个 MPS filter
    let blur = MPSImageGaussianBlur(device: device, sigma: blurRadius)

    // 尝试直接对 texture 进行操作
    let inPlaceTexture = UnsafeMutablePointer<MTLTexture>.allocate(capacity: 1)
    inPlaceTexture.initialize(to: inTexture)

    blur.encode(commandBuffer: buffer, inPlaceTexture: inPlaceTexture, fallbackCopyAllocator: myAllocator)

    // 提交 command Buffer
    buffer.commit()
}

使用 VideoToolbox 编码

我们将再次使用 VideoToolbox,进行编码。我们的 Mac 可能有多个 GPU,每个 GPU 有多个编码引擎。在大多数情况下,我们需要明确指定其中的一个硬件来减少设备间的内存复制。使用 CVPixelBufferPool 可以有效的回收内存。

VTCopyVideoEncoderList(…); // 获取所有可用编码器列表

// 启用硬件加速
CFDictionarySetValue(encoderSpec,
                     kVTVideoEncoderSpecification_EnableHardwareAcceleratedVideoEncoder,
                     kCFBooleanTrue);

// 指定编码设备,很重要!
CFDictionarySetValue(encoderSpec,
                     kVTVideoEncoderSpecification_PreferredEncoderGPURegistryID,
                     requiredGPU);

CVPixelBufferPoolRef pixelBufferPool; // 指定格式的 Pool
pixelBufferPool = VTCompressionSessionGetPixelBufferPool(session); 

// ...
CVPixelBufferRef buffer; 
CVPixelBufferPoolCreatePixelBuffer(…, pixelBufferPool, &buffer); 
CVMetalTextureCacheCreateTextureFromImage(...); 

// ...
CVBufferRelease(buffer); // 回收 Buffer

我们已经了解了解码、处理像素和编码,显示部分在后文独立介绍。

管理超大尺寸资源

大部分人都知道 8K 视频很大,但具体有多大呢?一张 8K 图片的分辨率是 7680x4320,而一张 UHD(Ultra High Definition) 图片的分辨率是 3840x2160,HD 图片的分辨率是 1920x1080,8K 图片的分辨率几乎是 HD 图片的 16 倍大!在未经压缩的情况下,8K 视频的每一帧需要 270 MB,那么在 30 FPS 的情况下,仅仅一秒的视频几乎需要 9GB,几乎达到了 PCIe 的物理带宽极限。即便是使用了 ProRes 4444 压缩,一段十分钟的视频仍需要 1TB 的空间。这对实时播放是个巨大的挑战!

Apple Prores 格式是苹果开发的视频有损压缩格式。ProRes 4444 是其中的一种。

虚拟内存占用

让我们来用 Xcode Instrument 中的 System Trace 看看播放 8K 视频的时候发生了什么。

Sytem Tracing

Sytem Tracing

  1. 在 Virtual Memory Actitivy,可以看到发生了大量的 Page Fault(图中各个线程行里大量的密集蓝色胶囊表示 Page Fault)。
  2. 对应的 Zero Fill 也花费很多资源。

这解释了为什么所有解码线程都卡住了。

我们需要更细致地管理内存。在播放视频时,操作系统并不会立即将整个文件加载进内存中。当多个解码线程访问这些内存页时,我们需要等待系统将这些新内存页映射完成。想解决这个问题也很简单,在播放开始之前,预加载 CPU 缓冲,确保所有的内存页已经存在即可。

如果你对 System Trace 很陌生,建议查看过往的 session System Trace in Depth.

内存申请最佳实践

苹果也给出了还有一些关于内存申请的 Tips:

  • 提前分配内存,减少流程中分配内存
  • 使用 Buffer Pools 重用内存 (如上面的例子)
  • 对于临时对象,使用 Metal Heaps
管理临时对象内存分配

使用 Metal Heap,我们可以高效的管理所有的临时对象。在 Metal Heap 中申请对象无需进行 kernel call,因为整个 Metal Heap 在创建时已经申请好了。在系统看来,Heap 是一个巨大完整的资源,内容能够更紧凑的布局。使用 Heap,我们甚至可对同一个 Command Bufer 的内容进行回收复用(Alias)。

let heap = device.newHeapWithDescriptor()

// 为 blur 核矩阵(kernel)设置 uniform 参数
let blurUniforms = heap.makeBuffer(length1, options1, offset1)
executeBlur(input, output1, blurUniforms)

// 为调色设置 uniforms 参数 
let colorgradeUniforms = heap.makeBuffer(length2, options2, offset2)
executeColorgrade(input, output2, colorgradeUniforms)

// ...

// 告诉 Heap 我们以后不再需要这些 uniforms,Heap 可以回收这些 Buffer
blurUniforms.makeAliasable()
colorgradeUniforms.makeAliasable()

// ...

// 创建一个临时缓冲用于结合两个效果
let intermediateBuffer = heap.makeBuffer(length3, options3, offset3)
executeCombinedOutput(output1, output2, intermediateBuffer, output3)

让帧率更平稳

Uneven Frame Pacing

Uneven Frame Pacing

图中的 GPU 使用了双缓冲机制。其中的 VBL 是 Vertical Blank,指画面刷新的间隔。

在上图中,每一帧显示的时间长短不同,用户显然会感受到跳帧。

CPU 在完成第三个 Frame 的内存准备之后就阻塞住了,因为 GPU 还在处理第一帧,无法再提供空间让 CPU 绘制。为此,Core Video 提供了 CVDisplayLink,一个高精度的底层计时器,与屏幕刷新率同步。

相较于我们更熟悉的 CADisplayLinkCVDisplayLink 运行在一个高优先级的独立线程中。

CVDisplayLinkRef displayLink;
CVDisplayLinkCreateWithCGDisplay(display, &displayLink);

// 设置回调,并在合适的时机让 CPU 绘制画面
__block int lastFrameIdx = -1;
CVDisplayLinkSetOutputHandler(displayLink, ^(…, inNow, inOutput, …)
{

    int outFrameIdx = floor(inOutputSec * frameDesiredFrequency);
    if (outFrameIdx > lastFrameIdx && !presentQueue.empty())
    {
        lastFrameIdx = outFrameIdx;
        id<MTLDrawable> toShow = presentQueue.pop();
        present(toShow);
    }
    return kCVReturnSuccess;

});

这样,每一帧停留的时间相同,帧率更稳定:

Predictable Frame Pacing with CVDisplayLink

Predictable Frame Pacing with CVDisplayLink

CVDisplayLink 有效的调节了 CPU 和 GPU 之间的不平衡关系,但如果内容刷新率与显示器刷新率不能匹配(如 24 Hz 和 60 Hz),还是会出现卡顿的情况。(这个问题如何解决呢?只需要购支持多刷新率的显示屏,比如苹果的全新 Pro Display XDR 显示屏,这个问题就迎刃而解啦!)

经过这一系列的优化,8K 视频的流畅剪辑终于实现!🎉

支持高动态范围成像

动态范围是我们可以测量的最高值和最低值间的范围。图像中的动态范围则特指亮度,以指数表示。更高的动态范围意味着更好的亮部和暗部的细节。

相较于普通图片,HDR 图片拥有更好的对比度、更丰富的色彩、更高的亮度,更适合还原真实世界的情况。当然,显示 HDR 内容需要一台支持 HDR 的显示器,比如苹果的全新 Pro Display XDR 显示屏。

EDR (Extened Dynamic Range)

苹果利用显示器的亮度净空高度(headroom)来显示 HDR 图片的高光和阴影部分。什么是净空高度呢?显示器的亮度设定往往与使用者的观看环境和周边环境有关,例如在一个比较阴暗的环境中,200 尼特的亮度就可以让我们舒适的查看设备上的 SDR UI 内容了。如果此时显示器支持最高 1000 尼特的亮度,那么这 800 尼特的差值就是所谓的亮度净空高度(headroom)。而在照明良好的区域,我们需要的亮度会高一些(如 500 尼特),相应的亮度净空高度会低一些。

Headroom

Headroom

一如既往,苹果将它们独特实现的 HDR 称之为 EDR。

在渲染时,EDR 会将SDR 像素根据利用当前的亮度净空高度映射成 HDR 像素。为了实现这种效果,HDR 像素会根据 SDR 亮度对应放大。在上面的阴暗环境的例子中,最大支持亮度为当前亮度的五倍,这个倍数被称之为 maximum EDR。同时,负数的倍数用于表示 HDR 图片的暗部。

用 Metal 渲染 HDR 图片

HDR Rendering Options

HDR Rendering Options

在 macOS 和 iOS 中,我们可以使用多种方式播放视频。比较简单的是使用 AVFoundation 播放视频,它支持自动的色调映射和颜色管理。如果想更加细致的控制播放,我们可以直接使用 CAMetalLayer

让我们看看如何在 CAMetalLayer 中开启 EDR 支持:

```objc
// 检测显示器的 EDR 支持
NSScreen * screen = view.window.screen;
CGFloat edrSupport = screen.maximumPotentialExtendedDynamicRangeColorComponentValue;

// 设置颜色空间和转换函数
// 其中 BT2020 是颜色空间,PQ_EOTF 是转换函数。
const CFStringRef name = kCGColorSpaceDisplayBT2020_PQ_EOTF;

top Created with Sketch.