HDR 视频早已不是什么新鲜格式,但“在桌面 UI 框架里正确显示 HDR”至今仍然是一件麻烦事。问题通常不在解码器,也不在 GPU。FFmpeg 能解 PQ、HLG,shader 能做色域转换,显卡和显示器也能处理 10 bit 或 fp16;真正容易断掉的,是 UI framework 到窗口系统之间那一段。
一个窗口想显示 HDR,至少要同时满足三件事:render target 能保存超出 0 到 1 的值;compositor 知道这些数值采用什么 primaries 和 transfer function;应用写进去的内容与 surface 声明完全一致。只做到其中一两项,得到的往往是整体发灰、过暗、偏色,或者高光直接被截成白色。
本文记录的是一次给 AvaloniaUI 补上 WCG/HDR 输出链路的实现。重点放在 Wayland,因为这里既要和 EGL driver 协商 buffer,又要和 compositor 协商 color description。Windows 和 Web 的原理相同,只是平台接口更短,会放在最后说明。
桌面 UI 的 HDR 困境
传统桌面 UI 的基本假设是 8 bit sRGB。WPF 的常规窗口内容最终仍是 SDR 合成路径;即使系统已经打开 Windows HDR,普通控件也不会因此获得一个可以写入 HDR 的 fp16 surface。WinUI 和 Windows Composition 的底层能力要强得多,DWM 本身就在 scRGB 空间工作,也能合成 R16G16B16A16_FLOAT surface,但标准 UI 绘制 API 并没有提供一个简单的“这个窗口改用 HDR”开关。要真正利用它,通常仍要进入 DirectComposition、Windows Composition 或 DXGI swapchain 的 interop 层。
Linux 的历史包袱更重。X11 下的 ICC 工作流主要解决 profile conversion,不存在一个被桌面普遍采用的 per-surface HDR contract。应用可以创建 10 bit visual,却很难向 compositor 清楚表达“这些像素是 BT.2020 PQ,参考白是多少”。Wayland 的 color-management-v1 终于把这件事定义成协议,但 compositor、toolkit 和 driver 的支持仍在逐步接通。GTK、Qt 以及基于它们的应用即使某个后端已经能创建高精度 buffer,也不代表普通 widget tree 会自动变成 HDR。
这也解释了为什么很多跨平台 UI framework 的 HDR 支持看起来总差最后一步:应用可以在 UI 中嵌一块自行管理的 DirectX/Vulkan 视频区域,但让视频、字幕、动画和普通控件共同画在一个正确的 HDR surface 上,仍然缺少完整的 framework contract。
Avalonia 的情况反而很有意思
Avalonia 默认同样使用 8 bit、non-color-managed 的 sRGB surface,从 platform backend 到 Skia 都没有传递 surface color space。于是 Skia 创建 SKSurface 时拿到的是 Rgba8888 和空的 SKColorSpace,所有超出 SDR 范围的值自然在到达 compositor 之前就消失了。
但 Avalonia 的主渲染器是 Skia,而 Skia 本身早已完整支持:
- 10 bit、fp16 和 fp32 render target;
- sRGB、Display P3、Rec.2020、linear scRGB、PQ 等 color space;
- 不同 color space 之间的自动转换;
- 在 RuntimeEffect 中处理 PQ/HLG、tone mapping 和 gamut mapping。
换句话说,最难写的颜色数学已经存在。Avalonia 缺少的主要是 plumbing:platform backend 必须报告自己实际创建了什么 surface,Skia backend 必须保留同样的 color type 和 color space,所有 intermediate layer 也必须继承它,最后还要让 custom drawing code 能查询这个结果。
先定义一份贯穿全栈的 surface 描述
实现的第一步是在 platform 层引入与具体窗口系统无关的描述:
public readonly record struct PlatformSurfaceColorFormat(
PlatformPixelEncoding Encoding,
PlatformColorSpace ColorSpace)
{
public static PlatformSurfaceColorFormat Unmanaged => default;
public bool IsColorManaged =>
ColorSpace != PlatformColorSpace.Unmanaged;
public bool IsExtendedRange =>
ColorSpace is PlatformColorSpace.ScRgbLinear
or PlatformColorSpace.ExtendedSrgb;
}
Encoding 描述像素怎样存储,例如 8 bit、RGB10A2 或 RGBA fp16;ColorSpace 描述这些数值的含义,例如 Display P3 gamma 2.2、Rec.2020 PQ 或 linear scRGB。两者不能混为一谈。fp16 只说明 buffer 不会立刻量化,并不自动说明 1.0 是多少 nits,也不说明负值代表哪一侧的 out-of-gamut color。
默认值特意保留为 Unmanaged。这样没有改动的 backend、第三方 render target 和旧应用仍走原来的 8 bit sRGB 路径,不会因为新增接口而改变显示结果。随后,这个值沿着 IGlPlatformSurfaceRenderTarget、rendering session、ISkiaGpuRenderTarget 和 ISkiaGpuRenderSession 一路传到 DrawingContextImpl。
这份描述必须是“实际协商结果”,不能只是 application preference。应用要求 fp16,不等于 EGL 一定给了 fp16;compositor 宣布支持 color management,也不等于它会接受应用构造的 image description。上层只能相信 backend 最终报告的结果。
Wayland:先问 compositor,再问 EGL
Wayland 路径的核心顺序如下:
- 绑定
wp_color_manager_v1,收集 compositor 支持的 feature、primaries、transfer function 和 render intent; - 根据能力选择目标 color space;
- 把对应的高精度格式作为候选交给
eglChooseConfig; - 读取 EGL 实际选中的 config,再为它创建完全匹配的 Wayland image description;
- 为每个
wl_surface创建wp_color_management_surface_v1并设置该 description; - 把最终格式向上报告给 Skia。
这里最重要的设计原则只有一句:
应用写入 buffer 的颜色含义,必须与 compositor 收到的 surface description 完全相同。
例如,应用按 Display P3 编码像素,却没有成功给 wl_surface 加上 description。compositor 会按默认 sRGB 解释它,整个窗口都会偏色。反过来,surface 被标成 P3,但 Skia 仍写 sRGB 数值,同样错误。因此 image description 创建失败时,正确的 fallback 不是继续“尽量显示”,而是把 Skia 端也降回 unmanaged sRGB。EGL 已经分配的高 bit-depth buffer 可以保留,因为把普通 sRGB 写进高精度 buffer 是安全的。
能力选择
这组修改提供两个 Wayland mode:
WideColorGamut:优先 Display P3,必要时使用 Rec.2020;采用 gamma 2.2,buffer 优先 RGB10A2,其次 fp16。这是 bounded WCG,能表示 sRGB 之外的颜色,但不能表示高于 white 的亮度。ExtendedLinear:使用 fp16 linear scRGB。sRGB primaries 并不等于只能覆盖 sRGB gamut;在线性浮点表示中,负 channel 和大于 1 的 channel 都有意义,因此它既能携带 WCG,也能携带 HDR 高光。
对于 HDR 视频,真正需要的是 ExtendedLinear。如果 compositor 支持 parametric image description、ext_linear transfer function 和 sRGB primaries,就可以精确描述这块 surface;否则再考虑协议提供的 create_windows_scrgb。
实际测试中,create_windows_scrgb 有一个不太显眼的差异:它把 signal 1.0 固定在 80 cd/m²,而不是 reference white。结果是普通 SDR white 也会明显变暗。使用 parametric description 并保留协议的默认 sRGB luminance,能让 1.0 对齐 reference white,与 EGL_EXT_gl_colorspace_scrgb_linear 的语义一致,因此应当优先选择 parametric 路径。
EGL config 不是一个 color space
eglChooseConfig 只解决 buffer layout。fp16 config 需要 EGL_EXT_pixel_format_float,候选属性大致是:
var formats = new[]
{
EglColorBufferFormat.Float16(PlatformColorSpace.ScRgbLinear),
EglColorBufferFormat.Standard,
};
// Float candidate additionally requests:
// EGL_COLOR_COMPONENT_TYPE_EXT,
// EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT
候选列表必须以 standard 8 bit 结尾。driver 没有 fp16 config 时,窗口仍应正常创建,只是 backend 最终报告 Unmanaged,播放器改做 SDR tone mapping。让高级颜色成为可失败的 negotiation,而不是启动条件,实际应用才敢默认请求它。
给每个 wl_surface 加标签
image description 在全局协商完成后创建一次,每个窗口连接时调用 get_surface,随后设置同一份 description。销毁时必须先销毁 wp_color_management_surface_v1,再销毁对应的 wl_surface。
让 Skia 创建同一种 surface
平台协商完成后,Skia 侧的改动很直接。OpenGL render target 不再固定使用 Rgba8888,而是根据实际格式创建:
var colorFormat = glSession.ColorFormat;
var colorType = colorFormat.ToSkColorType(SKColorType.Rgba8888);
var colorSpace = colorFormat.ToSkColorSpace();
var surface = SKSurface.Create(
grContext,
backendRenderTarget,
origin,
colorType,
colorSpace,
surfaceProperties);
linear scRGB 对应 SKColorType.RgbaF16 和 SKColorSpace.CreateSrgbLinear()。这一步完成后,Skia 才知道普通 Avalonia 控件的 sRGB color 应该先转换到 linear surface,而 custom draw 写出的 2.0 不能被截断。
只改最终 swapchain 还不够。Avalonia 的 opacity layer、effect layer、离屏 render target 都可能成为中间站。如果它们仍是 8 bit sRGB,HDR highlight 会先在那里被 clamp,最后再复制到 fp16 surface 已经无济于事。因此 DrawingContextImpl 创建 intermediate surface 时也要继承 PlatformSurfaceColorFormat。
最后,ISkiaSharpApiLease 暴露 ColorFormat 和 SkColorSpace。custom draw 不应根据操作系统名称猜测输出能力,而应查询当前 lease。窗口可能被移动到另一块显示器,backend 也可能 fallback;“此刻正在画入什么 surface”才是可靠答案。
在应用中请求 HDR surface
启用 Wayland HDR 输出只需要在启动时请求 extended linear mode:
app.With(new WaylandPlatformOptions
{
ColorMode = WaylandColorMode.ExtendedLinear,
}).UseWayland();
这只是 preference。完整实现会在 compositor 不支持 color-management-v1、缺少 ext-linear description、driver 没有 fp16 config 或 description 被拒绝时静默退回 standard surface。应用不能因为发出了这个请求就假定 HDR 已启用,仍要在 draw 时检查 lease。
视频怎样写入 WCG/HDR buffer
FFmpeg 保留 frame 的 color metadata,解码结果可以是 CPU 上的 P010/YUV plane,也可以是 VAAPI 或 D3D11 texture。渲染时通过 Avalonia custom draw 取得 Skia lease:
using ISkiaSharpApiLease lease = feature.Lease();
VideoOutputColorSpace target =
lease.ColorFormat.ColorSpace == PlatformColorSpace.ScRgbLinear
? VideoOutputColorSpace.ScRgbLinear
: VideoOutputColorSpace.Srgb;
SKColorSpace? targetColorSpace = lease.SkColorSpace;
之后的处理不依赖 OpenGL、D3D 或软件 rasterizer。各个 Y、Cb、Cr plane 作为 raw image shader 交给 SKRuntimeEffect,同一个 pass 完成 YUV 到 RGB、PQ/HLG EOTF、BT.2020 到目标 primaries 的矩阵变换,以及必要的 tone mapping。
真正把 HDR 写进 scRGB buffer 的核心并不长:
float3 processHdr(float3 signal, bool extended) {
float3 nits = float3(
pqToNits(signal.r),
pqToNits(signal.g),
pqToNits(signal.b));
if (extended) {
// BT.2020 -> linear sRGB primaries;
// reference white (203 nits) becomes 1.0.
return transformGamut(nits) / 203.0;
}
float luminance = dot(sourceLuma, nits);
float mapped = bt2390(luminance);
nits *= luminance > 1e-6 ? mapped / luminance : 0.0;
return compressGamut(transformGamut(nits) / sdrWhiteNits);
}
PQ 首先还原成绝对亮度 nits。HDR passthrough 时不执行 BT.2390,不压回 sRGB gamut,只把 203 nits reference white 缩放为 1.0。这样 1000 nits 大约写成 4.93;BT.2020 转到 linear sRGB primaries 后出现的负数也保留下来。fp16 surface 能容纳这些值,Wayland compositor 再根据输出显示器的能力做最终映射。
如果 lease 报告的只是普通 sRGB surface,同一个 shader 会走另一条分支:按照配置的 SDR peak 进行 BT.2390 tone mapping,做 luminance-preserving gamut compression,再编码成 sRGB,并在量化到 8 bit 前加入随 frame 变化的 TPDF dither。也就是说,fallback 不是交给 GPU 随便 clamp,而是应用主动生成可控的 SDR 图像。
绘制动作本身仍然只是给 paint 设置 shader:
using var paint = new SKPaint
{
IsAntialias = false,
BlendMode = SKBlendMode.Src,
Shader = videoShader,
};
canvas.DrawRect(videoRect, paint);
这里有一个容易忽略的 Skia 行为:SKPaint.SetColor 和 constant color shader 会把 color clamp 到 0 到 1。测试 HDR 输出时,不能简单设置 new SKColorF(4, 4, 4) 然后期待读回 4.0。可以使用保留 float stop 的 gradient shader,或者像视频渲染一样使用 RuntimeEffect 输出超范围值。
缩放也可能偷偷吃掉 HDR
直接对 YUV plane 做 bilinear sampling 没有问题,但 Mitchell、Catmull-Rom 或 mipmap 需要先把各 plane 合成一张 RGB intermediate。这个 intermediate 同样必须是 RgbaF16。项目中使用如下形式创建:
var info = new SKImageInfo(
frame.Width,
frame.Height,
SKColorType.RgbaF16,
SKAlphaType.Opaque,
extended ? targetColorSpace : null);
using SKSurface? converted = SKSurface.Create(grContext, true, info);
Skia 的 cubic resampler 会 saturate 输入,因此实现还会先把 extended linear value 除以 64,缩放完成后再乘回来。64 足以覆盖 PQ 的 10000 nits 相对 203 nits reference white 的范围,而且是 2 的幂,不会给 fp16 增加额外精度损失。这类中间步骤很容易让“最终 surface 明明是 fp16”却仍然丢掉高光。
怎样确认它不是看起来像 HDR
仅凭肉眼很难区分真正的 passthrough、系统 tone mapping 和普通的高饱和 SDR。测试程序应同时报告:
- Avalonia 协商到的
PlatformSurfaceColorFormat; - Skia surface 的
SKColorType和SKColorSpace; - surface native encoding 下的 float readback;
- sRGB、Display P3、Rec.2020 primaries 和 1x/2x/4x white 的实际数值。
在正确的 extended linear surface 上,4x white 的 native readback 应接近 4.0,而不是 1.0;out-of-gamut color 可能包含负 channel。standard surface 则应明确报告 unmanaged/RGBA8,并且所有值限制在 0 到 1。还应在 compositor 拒绝 image description、driver 不支持 float config 等负面条件下测试 fallback,确认窗口颜色没有因为“只成功了一半”而改变。
Windows:DWM 已经在 scRGB 等着了
Windows 路径比 Wayland 短,因为 DWM 对 fp16 composition surface 的解释已经确定为 scRGB。ANGLE 创建 EGL display 时请求 fp16 config;WinUI Composition 或 DirectComposition 随后把 drawing surface 从 B8G8R8A8_UNORM 改成 R16G16B16A16_FLOAT。两边都成功时,向 Skia 报告 RgbaF16/ScRgbLinear,否则保持原来的 unmanaged sRGB。
app.With(new Win32PlatformOptions
{
ColorMode = Win32ColorMode.ExtendedLinear,
});
这条路径要求 ANGLE EGL 配合 WinUI Composition 或 DirectComposition。软件渲染、传统 redirection surface 或其他 graphics backend 不会凭这个选项自动获得 HDR。与 Wayland 一样,应用应查询实际 render session,而不是只检查启动参数。
Web:drawingBufferColorSpace 与 float drawing buffer
Web 的 WCG 已有相对清晰的入口:把 WebGL 的 drawingBufferColorSpace 设为 display-p3,读回仍是 display-p3 后,才能告诉 Skia 目标是 Display P3 + sRGB transfer。浏览器可能抛异常,也可能忽略赋值,因此不能只写不读。
HDR 更特殊。Web platform 没有普遍可用的 extended linear scRGB drawing buffer;实现使用 drawingBufferStorage(RGBA16F, ...) 建立不 clamp 的 float buffer,并把它视为 extended sRGB。它保留 sRGB transfer,超出 gamut 的颜色依靠负 channel,超过 SDR white 的亮度依靠大于 1 的 channel。Chromium 系浏览器可以走这条路径;不支持 float drawing buffer 时先退到 bounded Display P3,再退到 standard sRGB。
Web 的能力探测尤其不能省略。API 存在、extension 可取到、设置后 format 读回正确,这三项都应成立,之后才把相应 color format 交给 Skia。不过,由于测试后发现 Web 环境对 HDR 的兼容性过差,我最终并未将 HDR 输出列入 Web 的功能考虑范畴,仅将其作为一个实验品。
还有哪些边界
这套实现解决的是 render surface 与 color pipeline,不等于所有 HDR policy 都由 framework 自动决定。应用仍要处理 content metadata、reference white、HDR passthrough 开关、SDR display peak、tone mapping 算法以及截图如何降到 SDR。HDR10+、Dolby Vision 之类的动态 metadata 也不会因为窗口变成 scRGB 就自动生效。
Wayland software framebuffer fallback 仍是 8 bit sRGB。如果一个已经带 color description 的 surface 临时落到 software path,标签与像素可能再次不匹配;完善实现应在 backend 切换时撤销或重设 description。多显示器场景也值得继续做 per-output policy,目前最可靠的原则仍是每次 draw 都相信 lease 报告的实际 surface。
不过,关键链路已经打通:平台协商高精度 buffer 和 color description,结果自下而上进入 Avalonia render target,Skia 用匹配的 color type/color space 创建所有 surface,应用则通过 lease 决定写入 extended linear HDR 还是先做 SDR tone mapping。
本文所做的并非从零开始构建 HDR renderer——更准确地说,是终于把 Skia 已经具备的能力接到了窗口系统上。真正困难的部分也并非某个 PQ 公式,而是始终守住那个简单却苛刻的不变量:buffer 里写的是什么,compositor 就必须被告知它是什么。