拿到一部杜比视界的片子,播放器要做对它,要回答三个问题:片子里有什么(容器与元数据)、你面前是什么屏、中间这条路怎么走(解码帧怎么变成像素)。三个问题交叉起来,是十几种情况:HDR10、HDR10+、杜比视界 P5/P8、HLG,乘上 SDR 屏、真 HDR 屏、还有「数值上像 HDR 的屏」。
这篇拆开讲泛音(Panvox)的做法。它由引擎 avox 驱动,下面所有结论都能在代码里找到。
HDR直通的截图放出来颜色全失真了,就不放相应截图了,映射后截图比较正常。

先立规矩:两条通道,两种屏
片源侧的常识先对齐:HDR 信号的亮度曲线是 PQ(SMPTE ST 2084)——码值直接对应 0 到 10000 尼特的绝对亮度,不同于 SDR 的相对亮度;HDR10 的静态元数据(ST.2086)整部片子一份;HDR10+ 的动态元数据走 T.35 SEI;杜比视界最常见的两种封装里,P5 是「裸」的——画面数据本身不带 HDR10 兼容层,不解析它的 RPU 就会偏色;P8 自带兼容层,老播放器能当 HDR10 播个大概。动态元数据的共同点是逐帧(或逐场景)携带亮度目标,这决定了处理粒度也应该是逐帧。
屏这一侧,不是所有「报告自己是 HDR」的屏都真的亮。Apple 的 EDR 机制里,内置非 XDR 屏的 headroom 恒报 2.0——数值上像 HDR,亮度上不是。我把 macOS 判「真 HDR 屏」的门槛定为 headroom 严格大于 2.0,不达标一律按 SDR 走,否则播出来的 HDR 片又灰又闷,用户只会怪播放器。
通道侧是我最重要的一个架构决定:车道按输出方式分,不按解码方式分——走 HDR 直通输出时,硬解帧直接交平台原生渲染,全链不做任何图像处理;而一切需要处理的场合(比如 HDR 片在普通屏上做色调映射),硬解帧和软解帧都会进 Vulkan 管线,增强、滤镜、超分也只存在于 Vulkan。两条通道全平台同构。
为什么直通要绕开 Vulkan
Vulkan 管线承担所有需要处理的场合:普通屏上的色调映射、滤镜、字幕都在这里做,硬解与软解帧同链处理。HDR 直通之所以绕开它,是为了不让高位深信号穿过处理链。原因有三个。
一是互操作成本。硬解输出的 10bit(P010)帧要进 Vulkan,得先经过平台渲染器转成 8bit 的对接面——HDR 的高位深在第一步就损失掉了。早期踩的坑就出在这一步:VK 的 HDR 处理对硬解帧不生效,根因是输入早被平台渲染器转成了 8bit。所以 HDR 呈现这条路上,硬解帧必须直接走原生。
二是载荷不可侦测。色调映射完的 SDR 帧和 PQ 码流,在字节层面都是 0 到 1 之间的数,从数据上无法区分一条链送的是什么。所以渲染车道只能由宿主显式声明(引擎里叫 HdrMode),禁止渲染层自己猜——猜错的代价是发灰,而且无从排查。
三是逐帧处理的位置。画质增强、特效、超分这些可选项只存在于 Vulkan;而直通是呈现必需,应该走最短路——像素从解码器到屏幕,中间不带任何「顺手处理一下」。
HDR 屏:直通输出(Windows / iOS / macOS)
Windows 上,D3D11VA 硬解出 P010,经过 compute shader 处理后写成 R10G10B10A2 格式的 PQ 码;交换链用 SetColorSpace1 声明 PQ 色彩空间,再用 SetHDRMetaData 把 ST.2086 静态元数据交给系统。显示器能力用 IDXGIOutput6 探测。实现上有一个安排:「意愿」与「实翻」分离——UI 线程只设置一个意愿位,真正翻转交换链 HDR 状态的是渲染线程在下一帧执行,失败即回滚。这个结构后来帮我挡住了好几类竞态,后面换屏一节会讲到。
Apple 一侧,VideoToolbox 硬解的 CVPixelBuffer 直接进 Metal shader,不落 8bit 中间格式;呈现层是开了 EDR 的 CAMetalLayer(扩展线性 ITU-R 2020 色彩空间),shader 直接输出线性值,1.0 定义为 SDR 白(100 尼特),高光可以大于 1.0,由系统合成器按这块屏的 headroom 顶出真实亮度。iOS 上用 UIScreen 的 potentialEDRHeadroom 探测,门槛是大于 1。
Android 这端,泛音目前只做色调映射,没有直通输出。系统本身是支持的——HDR 呈现路径存在,生态里走硬件路径的 SurfaceView;缺的是我渲染桥这一层:EGL 还没有 HDR 呈现面的实现,以后补上。
直通分支常被问到一个细节:字幕怎么办?字幕跟着车道走——直通时,字幕画布一起切到平台原生渲染腿(DX11 / Metal),以透明叠加合成,不参与、也不打断视频信号的直通;处理链里,字幕就在 Vulkan 里与画面一同合成。「全链不做任何图像处理」约束的是视频信号,叠加层走的是各车道自己的呈现腿。
SDR 屏:逐帧色调映射
多数人看片用的是 SDR 屏,这条链决定观感。核心是把「压到多亮」的决策做成逐帧的:峰值亮度优先取杜比 L1 的逐帧值,没有就退到 HDR10 的 maxCLL,再退到 maxLuminance,最后兜底 1000 尼特。算法是 PQ EOTF 进线性域,锚点软压(近似线性透传、超锚按内容峰值软压的 Reinhard 扩展),再 BT.2020 收到 BT.709,套 OETF 出画面。峰值经 UBO 逐帧喂给 shader——「逐帧消费」在实现里的样子,就是每一帧的膝点由那一帧自己的元数据决定,明暗切换快的场景不吃亏。
杜比视界:P5、P8 与 RPU
RPU 是杜比视界每帧携带的一小段「改色指令」(整形曲线加矩阵),P5/P8 都靠它。P5 必须自己整形回标准色彩,整形语义是对着 libplacebo 的处理链做的:pivots、多项式、MMR,加 ycc_to_rgb 矩阵,在 shader 里完成 reshape 再进色彩转换。
工程上有个坎:FFmpeg 不导出杜比 RPU 的解析符号。我的做法是把相关源码 vendor 进仓自己编;元数据提取做成解码器无关的旁路(喂包处自提 NAL 62 的 RPU 和 SEI 137⁄144),硬解腿和软解腿同权——这个设计让后面所有动态元数据(HDR10+ 也是)都走同一条路。
踩过一个典型坑:P5 的容器不带色彩标签,第一版按容器默认处理,结果整片欠色调映射,又暗又饱和。修法是把杜比链的输出恒按 PQ 消费,不看容器标签。
怎么验证「逐帧消费真的生效」?靠肉眼不可靠。我造了「同像素、只变 L1」的判别性素材:A 场的 L1 峰值等于内置静态元数据(作为对照,应当不产生可见差异),B 场把 L1 压到 250 尼特(必须压到位)。判定用逐帧亮度均值差过门限,而不是看图。两条硬解腿(Windows DX11VA 与 macOS VideoToolbox)实测端到端逐位一致。
HDR10+,以及还没做的部分
HDR10+ 的动态元数据走 T.35 SEI(ST 2094-10),我按位语法解析后,消费其中的 L1 峰值/均值,与杜比 L1 同路进色调映射。还有没做的:ST 2094 的逐窗裁剪与饱和度语义;杜比 P7(FEL)与 VVC 不支持.
播放中换屏、换道与「意愿位」
HDR 是运行时状态,不是安装时配置。播放中把窗口挪到另一台显示器、拔掉 HDR 屏、系统里关掉 HDR,都要能应对。机制上前面的结构已经铺好:宿主挂意愿位,渲染线程过能力闸后实翻;被拒、早退、重建,任何一条路都必须把意愿位清零;配一个三秒自愈重发。在 Vulkan 与 Metal 两条渲染车道之间切换用「拆面换道」:拆 surface、改道、复挂,实测约 120 毫秒,播放不断。
三个真实战损,都比想象中离奇:
其一,HDR 片直通播放中一缩放窗口,画面发灰,彩条从 255 静默掉到 191。真因不在显示器:重建交换链(ResizeBuffers)时,10bit 格式、PQ 色彩空间、HDR 元数据被驱动静默重置。修法是 resize 之后立即重挂全套 HDR 交换链状态。
其二,HDR 屏挪到 SDR 屏,闪一下正常之后画面恒定「品红洗白」。事故链:设备重建清了实态、却留下了意愿位;自愈重发被能力闸拒绝;被拒的路径不清意愿位;下一轮重放把 PQ 交换链架到了 SDR 屏上——SetColorSpace1 只是一句声明,SDR 屏并不拦它。终态是 PQ 面套色调映射:亮度压了,色域没压,于是品红。这里的教训是:宣言成功不等于屏幕真的听了——意愿位必须随被拒一并清零。
其三,iPhone 冷启动播 HDR 片整屏黑,转一次屏幕就自愈。真因:MoltenVK 拆 Vulkan 交换链时,把宿主 CAMetalLayer 的 drawableSize 写成了 1×1,而 Metal 腿从不在这个 layer 上建链,没人把它改回来——3840×2160 的帧画进 1×1 的 drawable。修法是换道收尾时催宿主立刻重跑布局。定位它靠的是日志里一行 target:1x1。
SDR 片在 HDR 屏
默认当 SDR 呈现,不做 SDR 到 HDR 的上变换(逆色调映射)。这是明确的取舍而不是遗漏:引擎现状零实现,收益存疑的画质冒险不如不做。
写在最后
HDR 播放的复杂度不在「支持不支持」,而在元数据的逐帧消费、平台呈现口的差异、以及运行时状态的竞态。我把直通输出与处理链拆成两条同构通道,把「意愿」与「实态」分开管理,再用判别性素材把「真的逐帧」变成可测断言——四端的 HDR,靠的就是这几件事。有兴趣请看具体与AI对接的实现文档 avox/doc/plan/gpu/多平台HDR统一重构方案.md at main · xxxzhou/avox
引擎 avox 在 GitHub 开源,本文说的管线、shader、交换链管理都在里面。系列其他篇:产品介绍从开源引擎 avox 到成品:全平台播放器「泛音」。