播放器的渲染分层与 Vulkan 出图

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

帧躺在视频轨的队列里,要画到屏幕上,先得决定这套渲染怎么分——分法不是照功能切的,是照着会变的地方切的。分层定下来,一帧有两条去处。这篇先讲清三个角色各管什么、一帧和节拍为什么往相反方向走、两条出图路各落到哪条链上,再跟着一帧走完走 Vulkan 那条:循环的节拍、上屏的几步、创面建链,以及窗口变了怎么重建。

三处会变的地方:窗口、帧、处理

一个播放器要在四个平台上出图。这三处变的原因各不相同,合在一起处理就是这个分层的起点。

目的地会变。Windows 上是窗口句柄,安卓上是一个原生窗口,苹果上是一个 Metal 层,Linux 上是一套自己的 surface 接口。

拿到手的帧会变。硬解出来的帧长在平台自己的图形 API 上,是 DirectX 纹理还是 GL 外部纹理还是 Metal 像素缓冲,取决于在用哪个平台;软解出来的帧则在内存里,是一堆 YUV 平面。

想加的处理也会变,而且是被上层随时加的:缩放、超分、水印、查找表、亮度对比度饱和度锐度。这类东西跟平台没关系,只跟画面有关系。

这三处挤在一层里,后果很具体:每加一个处理,四个平台各改一遍;每接一个新平台,整套处理链再抄一遍。分三层就是把它们拆开——处理链收成一条公共链,平台差异沉到最底下,最上面那层循环不必知道今天的窗口句柄是哪种类型。

三个角色:协调者、像素处理、呈现面

三个角色与继承链:最上面是窗口渲染,往下依次是平台渲染对接、Vulkan 计算管线,收在一个统一的渲染接口上

最上面是窗口渲染 WindowRender。它管协调:手里拿着窗口对象和两路渲染器——一路是平台渲染器,一路是 Vulkan 渲染器——还带着一条渲染循环。这一帧走哪条路、窗口尺寸变没变、呈现模式改没改,都由它分流和下发。

中间是像素处理 VideoRender。各平台硬解拿到的原生 GPU 数据在这里转成 RGBA8。它自己不带循环,由窗口那边推动着走。这条线往上还接了一层 Vulkan 计算管线 SurfaceRenderVk——图像处理那些接口(缩放、超分、水印、查找表、亮度对比度饱和度锐度)挂在那里,离屏输出也归它。

最下面是呈现面 Window。原生窗口句柄由它持有,交换链和层这些呈现用的对象也在它手里。屏幕多大、能不能缩、当前是不是 HDR 直通呈现,这一层最清楚。

三层的继承关系是自下而上叠上去的:窗口渲染继承平台渲染对接,平台渲染对接继承 Vulkan 计算管线,最顶上收在一个统一的渲染接口上。写成继承而不是把三个组件拼起来,是为了让一帧能同时下发到两条路上——上层直接往下发,不必在层与层之间再写一遍转发。

数据往下流,循环线程在最上层

一帧的方向是往下的:帧源(视频轨)把帧交给最上面那层,那一层再交给像素处理,处理完再交给呈现面。节拍的方向相反:像素处理这一层自己不安排节奏,节奏由最上面那条循环定。所以三层里只有最上面那层带循环线程,往下两层都是被调用的,自己不会动——「什么时候画」和「画成什么样」由此分开,一层定节拍,另一层被节拍推着走,

那条循环跑在一条独立线程上,入口是个 while,每帧基准时间从当前时间起算。先处理开机时要应付的两个系统小毛病:Windows 默认的定时器粒度太粗,对高帧率不够用,所以进循环先把它调细;暂停时也不能真的什么都不做——循环照跑,隔一小会儿醒一次,顺手看一眼有没有截图请求,所以暂停时也能截到当前画面。

正常帧里依次做四件事。算这一帧该隔多久:按目标帧率算一个间隔,再除以播放倍速;倍速超过 4 倍时不再缩短间隔,直接用原始帧间隔(再快也没意义)。查窗口尺寸有没有变,变了就通知下去(交换链要跟着重建,后面说)。向帧源要帧——它去帧队列里取、做音画同步判定,取到就往这套渲染上回调。最后推动窗口刷新:正常或略微跟不上时,让窗口走一次「预备刷新 → 刷新」。

丢帧也有反馈。当同步判定是「慢了」而且没在变速,记一笔丢帧;一段时间里丢帧明显偏多、目标帧率又还不到素材帧率的两倍,就把这条循环自己的目标帧率往上提一档(最多提到素材帧率的两倍)——用更密的节拍去追帧,不硬扛。节奏用的是绝对时间对齐:下一帧时间加上间隔,睡到点;要是偏差大得离谱(超两倍间隔或已经过点),就把基准重置到当下,避免一错再错地追。Windows 上还在睡醒前提前两毫秒起来,尾段用自旋补精度。

出图的两条路:一个开关决定走哪条

一帧从帧队列到屏幕:两条路,一个开关

像素处理做完之后,一帧有两条去处。一条交给 Vulkan:进计算图处理,处理完把结果拷进 Vulkan 自己建的那条交换链,再请求呈现。另一条是原生窗口直呈:平台渲染器把结果直接交给原生窗口,由窗口手上那条呈现链上屏——Windows 上是交换链的 Present,安卓上是交换缓冲,苹果上是把可绘制对象提交给层;这条路不经过 Vulkan。

两条路互斥,由同一个开关决定谁上场:开关打开时,像素处理把 RGBA 交给 Vulkan 接手;关掉时,像素处理的结果直接进原生窗口。要分清互斥的是哪一步——互斥的是呈现这一步。平台像素层在两条路下都要做那一次 RGBA 转换:开着开关时它的产出正是 Vulkan 那一路的输入,只是它自己那一份不再上屏。

留着不走 Vulkan 的那条,是因为它省掉一跳。硬解帧本来就长在平台的图形 API 上,交给 Vulkan 意味着跨一次图形 API 把纹理导过去;原生直呈那条路不跨这一跳,画面既不落内存也不进别人的管线。代价是公共图像处理链用不上了。所以这个选择实际是在「要处理」和「要保真」之间选,跟方案先进与否无关。

还有一处细节:窗口指针是两路都下发的,每帧都发。因为窗口的 HDR 直通状态会变——换屏、改呈现模式都会让它翻;谁在用只由那个状态决定。

输出格式跟的是窗口的实态,上层发什么命令都改不动它。命令可能被拒(屏幕不支持、格式拿不到),这时候输出要自然回到普通域;要是照命令走,命令和实态就会漂。实现上是每帧比一次实态,跟缓存的不一样就置一个重建标志,下一帧把输出端按实态重建。窗口指针的存活归协调层管,像素处理那层只借不持有。

走 Vulkan 那条:一帧怎么上屏

开着开关时,一帧走五步。

第一步,渲染回调拿到一帧,交给平台像素层做 NV12 到 RGBA8 的转换,再把它连同平台侧产出的 GPU 上下文交给 Vulkan 计算管线。

第二步,Vulkan 管线里跑图。输入层把已经转换好的纹理导进来,整张计算图跑一遍,产出这一帧的结果。

第三步,推动窗口刷新。窗口的刷新回调里,把窗口指针同时下发给两路渲染器(平台像素层和 Vulkan 管线)——谁在用由开关注定。

第四步,Vulkan 那路把计算结果搬上交换链的图像:切布局、把图拷过去、再切到「可呈现」的布局。这里要分清搬的是哪条链:换的是 Vulkan 窗口自己建的那条交换链的图;原生窗口手上那条不动。计算图还没就绪时(首帧常见)只做布局转换、先不拷贝,免得触发校验层的报错。

第五步,呈现:拿到屏幕图、提交命令、请求呈现,画面就上去了。

Vulkan 窗口对象:建面、建链、呈现

Vulkan 的窗口对象管三件事:创面、建链、呈现。

创面:入口按平台分,Windows 用实例句柄加窗口句柄建 Win32 面;安卓用 ANativeWindow 建;苹果用 CAMetalLayer 建;Linux 分两路,X11 和 Wayland 各建各的。编译期按平台只留一个重载,上层拿到的是一个通用窗口指针。重挂的时候有个规矩:换一段视频要重挂面,旧的交换链必须连同旧的面先拆干净,再建新的。

建链:四处取值都有取舍,取值和它的理由都在图里。这四处管的事不一样:呈现模式管同步行为,图像数量管轮转时要不要等,队列共享模式管两个队列能不能同时碰同一张图,面的尺寸最被动——决定权在设备手上。

Vulkan 那条链上交换链的四处取值:呈现模式、图像数量、队列共享模式、面的尺寸,以及每处为什么这么定

呈现:提交命令、请求呈现,拿回来的结果决定下一帧怎么走——过期或尺寸不符就重建链,

窗口变了,链就重建

窗口尺寸变化时,链条是这样传的:窗口这层发现宽高变了,触发一次「尺寸变化」;Vulkan 窗口覆写这个动作,等设备空闲,重建交换链。

重建时不把旧的直接销毁、从零来过——旧链作为「旧交换链」传给新链的创建信息,Vulkan 能借它复用一部分底层资源。建链万一失败(比如面正在最小化、尺寸是 0),不硬往上走:把交换链指针恢复成旧链,返回失败;等下一帧再试。这么做是为了不让一个空句柄继续往下走——空句柄一路用到呈现,就是崩溃。成功之后才把旧链的资源(图像视图、帧缓冲)和旧链本身销毁。尺寸是 0 或者拿不到图像时,这一轮直接跳过,等面重新布局好再自愈。

引擎 avox 在 GitHub 开源:github.com/xxxzhou/avox,这三个角色、两路渲染器、那个开关,以及 Vulkan 这条链的创面、建链与呈现都在里面。

音视频开发 播放器 渲染 Vulkan C++