Windows 上硬解帧的两条去处

从零做一个全平台播放器·约 1713 字

硬解帧出来是一张平面的 NV12 纹理,窗口和 Vulkan 都直接吃不了,所以先去平台的像素处理层转成 RGBA。转完的产物有两个消费者:一个把它导进 Vulkan 管线,滤镜、字幕、色调映射都在那边做;另一个不进 Vulkan,由窗口自己的交换链把它当一张全屏四边形贴上去。两条路靠一个开关分岔,呈现那一步只有一个人做。这篇写 Windows 这两条路各自的关口,以及跨 API 那一下到底谁等谁。

分岔之前:先转一次 RGBA,两条路共用

一次转换,两个消费者:共享纹理交给 Vulkan 输入层,也交给窗口的交换链;中间那道栅栏只有一边在等

解码器给出来的是一张平面纹理,8bit 片源是 NV12,10bit 是 P010。窗口认 RGBA,Vulkan 那一侧的对接面也是 RGBA,这张纹理得先转一次。

转换在平台的像素处理层里做,Windows 上是一条计算着色器:平面纹理进,RGBA8 出,产物不回内存,而是一张自己建的共享纹理。这张共享纹理同时就是对外交出的 GPU 上下文,两个消费者都从它身上取东西,取法不同。

分岔点在它之后:一张已经转好的 RGBA 共享纹理,交给 Vulkan,还是交给窗口。

去处一:导进 Vulkan 管线

开关开着时,窗口渲染那一层把这张共享纹理交给 VK 那一路。VK 的输入层拿到的是一个 NT 句柄。

句柄在创建那张共享纹理的时候就准备好了:创建标志里带共享与 NT 句柄两项,建完把句柄导出。VK 这边先加载取句柄的两个入口,开一组外部内存相关的设备扩展;接着建图,建的时候声明这是一张外部图、句柄类型是 D3D11 纹理;再查这块内存的属性,按句柄把内存导进来,绑到图上。到这里,同一块显存有了两个 API 各自的视图。

VK 管线自己也有一台 D3D11 设备,用来把这个句柄打开成自己设备上的一张纹理与视图;输入层每帧把这张导入的图拷进管线自己的图,之后怎么做处理、怎么呈现,是渲染分层那一篇讲过的环节。

这一步不是无条件做的。只有当前这一帧确实是硬解帧(缓冲类型不是 cpu)时,才把平台像素层的上下文交出去。软解帧由 VK 自己上传平面,不需要它;而轨道跨媒体复用的时候,那个上下文可能还挂着上一场硬解留下的旧纹理,无条件喂进去,新场的输入槽就被翻成旧尺寸、旧格式,管线协商失败,整场黑屏。硬解切软解的时候真撞到过。

跨 API 的等法:读方在 CPU 上等一手

纹理能访问,不等于能读。GPU 上的写入是异步的,写的一方(计算着色器那一步)出图之后给共享栅栏发一个信号,信号值单调递增。

读的一方是 VK 输入层,它在导入之后等这一手。等的是写方最后一次提交的那个值:已经追上就直接往下走,不引入停顿;没追上才真等,而且这个等待发生在 CPU 上——把栅栏和一个事件挂上,然后等那个事件,带一个超时兜底,免得设备异常时把渲染线程卡死。

这一篇里的两个去处,只有这一条在等。

去处二:不进 Vulkan,由窗口那条链呈现

开关关掉时,窗口换成了另一个类:它自己建 D3D11 设备与交换链,把那张共享纹理当一张全屏四边形贴到后缓冲上,再提交呈现。

窗口这一路一帧的六步;共享纹理还没下来时它直接早退,不呈现空白画面

它拿纹理的方式和 VK 那边同源:先把共享纹理的互操作设备设成自己这一台,再把句柄在自己设备上打开,得到一张本地纹理和它的视图。之后每帧的动作很短,顺序见配图;视口按画面的真实比例算,只有全屏时才铺满窗口。呈现那一句带垂直同步。

这条路留着,是为了少一个会失败的环节:VK 那条要建面、建链、协商格式,窗口尺寸一变整条链还要重建;窗口这条只有取纹理、画四边形、呈现三步。

它不等写方那道栅栏。节奏靠提交时的垂直同步与交换链轮转错开,取纹理之前不做等待。

窗口尺寸变了只动交换链:换掉后缓冲就行,设备不重建。设备一重建,解码那个实现复用的那台设备就分家了(见硬解接入那一篇)。

第一帧之前还有一处判断:共享纹理还没下来时,窗口这一帧直接早退,不呈现空白画面。

软解帧也走这条路。窗口那条路对两种帧都收:硬解帧经平台像素层转出那张共享纹理,软解帧就地让平台渲染器吃掉、把转好的那张纹理下发窗口。少了后半句,切到软解之后窗口会因为手里没有共享纹理而一直早退,屏幕就是黑的。

换片与换分辨率:残块的来源

两张图尺寸不一样的时候,GPU 上的整图拷贝不报错,它只覆盖左上角那一块。换片或者换分辨率时若直接拷,屏幕上就是新画面缩在左上角、其余地方留着上一帧。

VK 输出那一侧的处理是分两路:尺寸对得上才直拷,对不上改走带缩放的那条路,把画面铺满目标。输入那一侧的对应动作更靠前:每帧读一次外部纹理的尺寸,跟上一层比,变了就重建整张图——老写法不重检,新场进来的还是旧尺寸的输入槽,画面从第一帧起就是错的。窗口这一路则是上一节那句后缓冲重建。

互操作能力也是先探一次

共享这条路上的格式是有限的一小撮:计算着色器的输入侧认 NV12、P010 与 YUV422P10;跨过 API 那道面对接的恒是 RGBA8,只在与 HDR 直通相关的那一档才走 RGBA10。这份互操作能力本身要探:启动时用一张 RGBA8 探针纹理试一次「按 D3D11 纹理的句柄类型导入」能不能成,不成就不做这条导入,落到软解帧那条路上。

探在启动时而不是等到某一帧,是为了让格式协商的失败发生在一个能被看见的时刻,而不是播放中途某张片子突然黑掉。

到这里两条路都通了:一条把硬解帧送进 Vulkan 的统一处理管线,一条绕开 VK 直接贴窗口。共用的是一次 RGBA 转换和那张共享纹理,不同的地方是呈现那一步由谁做。

Android 与 Apple 上,这两条路各自换一套显存对象——一边是 OES 纹理与 AHardwareBuffer,一边是 CVPixelBuffer 与 IOSurface,下面两章各写一遍。

引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,共享纹理、那道栅栏与窗口这一路都在里面。

音视频开发 播放器 硬件解码 Vulkan D3D11 零拷贝 C++