文章

FSPlayer 基于 Apple EDR 机制实现 HDR 视频直接渲染

FSPlayer 基于 Apple EDR 机制实现 HDR 视频直接渲染

背景与演进

在本次优化之前,无论用户的显示设备是否支持 HDR,FSPlayer 均采用降级策略:通过 Tone Mapping(色调映射) 技术将 HDR 内容压缩到 SDR 范围后再输出。这导致即使用户拥有顶级的 HDR 显示器,看到的也是高亮区域被压缩、色彩丰富度受损的画面。

为了突破这一瓶颈,FSPlayer 近期重构了渲染管线,实现了 HDR 直接输出:利用 Apple 的 EDR(Extended Dynamic Range) 机制,绕过 Tone Mapping,将 HDR 像素值原样传递给系统的 EDR 图层,由系统动态映射至显示器的物理亮度。在支持 EDR 的设备(如 MacBook Pro XDR、Pro Display XDR、iPhone 15 Pro 等)上,高光区域可达到 SDR 白点的 2–8 倍,将真实的高光细节完美还给屏幕。

Apple EDR 核心基础

在实施方案前,必须明确 Apple 平台 EDR 的三要素:

1. 像素格式与色彩空间

  • SDR 模式:使用 MTLPixelFormatBGRA8Unorm(每通道 8-bit,0.0–1.0 固定的普通动态范围)。
  • HDR/EDR 模式:使用 MTLPixelFormatRGBA16Float(每通道 16-bit 浮点数,支持大于 1.0 的扩展线性值),并配合 kCGColorSpaceExtendedLinearSRGB 色彩空间。

2. CAMetalLayer 的 EDR 三属性配置

1
2
3
4
5
6
7
8
9
10
11
12
CAMetalLayer *metalLayer = (CAMetalLayer *)self.layer;

// 1. 开启扩展动态范围内容支持
metalLayer.wantsExtendedDynamicRangeContent = YES;

// 2. 设置色彩空间为扩展线性 sRGB
CGColorSpaceRef cs = CGColorSpaceCreateWithName(kCGColorSpaceExtendedLinearSRGB);
metalLayer.colorspace = cs;
CGColorSpaceRelease(cs);

// 3. 切换像素格式
metalLayer.pixelFormat = MTLPixelFormatRGBA16Float;

3. ⚖️ 严格的线程限制

  • CAMetalLayer.pixelFormat(或 MTKView.colorPixelFormat):任意线程安全
  • wantsExtendedDynamicRangeContentcolorspace必须在主线程设置。如果在渲染线程(如 Display Link 回调)中调用,会被系统静默忽略,导致 EDR 失效。

整体架构方案

FSPlayer 内部的决策与执行流程如下:

1
2
3
4
5
6
7
8
9
10
检测内容是否为 HDR (BT.2020) 并且 检测当前显示器是否支持 EDR
                         │
         ┌───────────────┴───────────────┐
         ▼ 满足                          ▼ 不满足
  【开启 EDR 直渲路径】            【退回 Tone Mapping 路径】
  - colorPixelFormat = RGBA16Float - 使用原 SDR 渲染管线
  - 主线程配置 CAMetalLayer EDR 属性  - 将 HDR 压缩至 SDR 范围
  - Shader 执行 EOTF (PQ/HLG)
  - 输出 > 1.0 的扩展线性光值

1. HDR 内容轻量化识别

FSMetalPipelineMeta 中提供静态方法,只通过 kCVImageBufferYCbCrMatrixKey 检查是否为 BT.2020 矩阵,避免盲目分配完整的 Pipeline 实例:

1
2
3
4
5
6
7
8
+ (BOOL)isHDRContentWithPixelBuffer:(CVPixelBufferRef)pixelBuffer {
    if (!pixelBuffer) return NO;
    CFStringRef colorMatrix = CVBufferGetAttachment(pixelBuffer, kCVImageBufferYCbCrMatrixKey, NULL);
    if (!colorMatrix) return NO;
    
    return CFStringCompare(colorMatrix, kCVImageBufferYCbCrMatrix_ITU_R_2020, 0) == kCFCompareEqualTo;
}

在确认是 HDR 后,通过完整实例化解析其传输函数(Transfer Function)(PQ 或 HLG),以便 Shader 选择正确的 EOTF:

1
2
3
4
5
6
7
8
9
10
11
12
if (colorMatrixType == FSYUV2RGBColorMatrixBT2020) {
    meta.hdrContent = YES;
    CFStringRef tf = CVBufferGetAttachment(pixelBuffer, kCVImageBufferTransferFunctionKey, NULL);
    if (CFStringCompare(tf, FS_TransferFunction_SMPTE_ST_2084_PQ, 0) == kCFCompareEqualTo) {
        meta.transferFunc = FSColorTransferFuncPQ;
    } else if (CFStringCompare(tf, FS_TransferFunction_ITU_R_2100_HLG, 0) == kCFCompareEqualTo) {
        meta.transferFunc = FSColorTransferFuncHLG;
    } else {
        meta.transferFunc = FSColorTransferFuncLINEAR;
    }
}

2. 硬件 EDR 能力检测与动态监听

通过各平台底层 API 获取硬件的物理扩展能力(不受当前屏幕亮度滑块的影响),并监听屏幕参数变更:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (BOOL)currentDisplaySupportsHDR {
#if TARGET_OS_OSX
    NSScreen *screen = self.window.screen ?: [NSScreen mainScreen];
    if (@available(macOS 10.15, *)) {
        return screen.maximumPotentialExtendedDynamicRangeColorComponentValue > 1.0;
    }
    return NO;
#elif TARGET_OS_TV
    return NO; // tvOS 暂按白名单或暂不开启 EDR API 处理
#else
    UIScreen *screen = self.window.screen ?: [UIScreen mainScreen];
    if (@available(iOS 16.0, *)) {
        return screen.currentEDRHeadroom > 1.0;
    }
    return NO;
#endif
}

同时注册通知以应对外接显示器拔插、分辨率切换或系统 HDR 开关切换:

1
2
3
4
5
[[NSNotificationCenter defaultCenter] addObserver:self
                                         selector:@selector(screenParametersDidChange:)
                                             name:NSApplicationDidChangeScreenParametersNotification
                                           object:nil];

3. Metal Shader 的直渲改动

Shader 的 Uniform 结构体中引入 hdrContenthdrDisplay 标志位。当两者皆为 1 时,跳过原有的 Tone Mapping 路径,通过逆变换(EOTF)直接计算出真实的扩展线性光值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct FSConvertMatrix {
    // ...
    int hdrContent;   // 1 = BT.2020 视频
    int hdrDisplay;   // 1 = 启用 EDR 直渲
    int transferFun;
    // ...
};

float4 hdr_direct(float4 yuv, FSConvertMatrix matrix) {
    // 1. YCbCr -> 线性 RGB
    float3 rgb = matrix.matrix * yuv.xyz + matrix.offset;

    // 2. 根据传输函数执行逆变换 (EOTF)
    if (matrix.transferFun == FSColorTransferFuncPQ) {
        rgb = pq_eotf(rgb);   // 恢复到 0-10000 nit 归一化线性光
    } else if (matrix.transferFun == FSColorTransferFuncHLG) {
        rgb = hlg_eotf(rgb);  // 恢复 HLG 线性光
    }

    // 3. 输出扩展值(超出 1.0 的高光交由系统 EDR 硬件渲染)
    return float4(rgb, 1.0);
}

核心时序重构与线程安全方案

HDR 直渲对渲染时序多线程并发提出了极高要求。为解决因时序倒置、线程交织导致的闪退与失效,我们做了如下两项核心重构。

1. 关键生命周期前置:攻克 Pipeline 与 Framebuffer 格式不匹配崩溃

痛点崩溃:

1
2
3
-[MTLDebugRenderCommandEncoder setRenderPipelineState:]:
For color attachment 0, the render pipeline's pixelFormat (RGBA16Float)
does not match the framebuffer's pixelFormat (BGRA8Unorm).

根因分析 —— 时序倒置。 MTKView.currentDrawable 在被访问的瞬间,会按照当时 View 的 colorPixelFormat 去创建 Framebuffer。如果我们在 drawRect: 内部才检测并改变 colorPixelFormat,那么当前帧的 Drawable 格式已经无法变更,进而与重建出的 RGBA16Float Pipeline 冲突,导致崩溃。

破局方案 —— 检测与构建逻辑彻底前置。setupPipelineIfNeed: 的检测与构建逻辑前置到 displayAttachWithTimestamp: 中(即在 [self draw] 执行前完成格式抉择),保证在请求当帧 Drawable 之前就锁定格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- (void)updateHDRDisplayModeForHDRContent:(BOOL)isHDRContent {
    BOOL supportsHDR = [self currentDisplaySupportsHDR] && isHDRContent;

    if (supportsHDR != self.hdrDisplayEnabled) {
        self.hdrDisplayEnabled = supportsHDR;

        // 步骤一:在获取 Drawable 前,优先切换像素格式并废弃旧 Pipeline
        MTLPixelFormat newFormat = supportsHDR ? MTLPixelFormatRGBA16Float : MTLPixelFormatBGRA8Unorm;
        BOOL formatChanged = (self.colorPixelFormat != newFormat);
        self.colorPixelFormat = newFormat;

        if (formatChanged) {
            self.picturePipeline = nil; // 触发下次渲染前重建
        }
    }

    // 步骤二:强制将 CALayer 属性派发至主线程
    BOOL s = supportsHDR;
    dispatch_async(dispatch_get_main_queue(), ^{
        CAMetalLayer *metalLayer = (CAMetalLayer *)self.layer;
        metalLayer.wantsExtendedDynamicRangeContent = s;
        metalLayer.colorspace = s ? CGColorSpaceCreateWithName(kCGColorSpaceExtendedLinearSRGB) : nil;
    });
}

2. 线程安全拓扑:EDR 开了,屏幕却没变亮

痛点现象: 视频播放后画面没有变亮,必须手动开关一次 HDR、或者拉伸一下窗口,高光才会瞬间亮起。

根因分析:

  1. 切集时系统重置了图层属性,但 hdrDisplayEnabled 逻辑未刷新导致提前返回(early return),跳过了重新设置。
  2. 旧代码把 CALayer 的 EDR 属性错误地放在渲染线程赋值,被系统静默忽略;改变窗口大小时触发了主线程的 layoutSubviews,才”歪打正着”重新生效。

线程互斥拓扑设计: 核心组件 picturePipeline 面临多线程竞争 —— 渲染线程高频创建/使用,主线程因屏幕参数变化或用户开关而异步重置。

防死锁方案: 引入 renderSnapshotLock 进行互斥保护;主线程修改 CALayer 属性时,必须使用 dispatch_async 而非 dispatch_sync。否则,主线程同步等待(Sync)渲染线程释放锁,而渲染线程又在等待主线程更改某些 UI 状态,就会引发致命死锁

离屏渲染与 HDR 截图技术演进

离屏渲染组件 FSMetalFBO 内部硬编码了 MTLPixelFormatBGRA8Unorm。当直渲管线变更为 RGBA16Float 后,调用截图便引发格式不匹配崩溃。

针对这一问题,我尝试了两个方案。

探索方案:32-bit 浮点 TIFF

常规的 JPG/PNG 仅支持 8-bit 色深,会强行截断大于 1.0 的高光数据。为了无损保存 HDR 的瞬间,我们曾尝试用以下三步构建 TIFF 无损链。

① 显式纠正 CIImage 颜色空间。 Metal 输出的是 ExtendedLinearSRGB(线性光)数据。直接用 imageWithCVPixelBuffer: 时,Core Image 会默认按 gamma sRGB 处理,导出时进行二次错误的 Gamma 解码,导致截图严重偏色。

1
2
3
CGColorSpaceRef cs = CGColorSpaceCreateWithName(kCGColorSpaceExtendedLinearSRGB);
NSDictionary *options = @{kCIImageColorSpace: (__bridge id)cs};
CIImage *ciImage = [CIImage imageWithCVPixelBuffer:pixelBuffer options:options];

② 构建独立的 HDR CIContext。 默认的 [CIContext contextWithOptions:NULL] 运行在 SDR 上下文中,中间 Pass 会把大于 1.0 的值直接裁剪掉。必须显式指定高精度工作空间:

1
2
3
4
5
6
7
8
static CIContext *hdrContext = nil;
static dispatch_once_t hdrOnceToken;
dispatch_once(&hdrOnceToken ^{
    hdrContext = [CIContext contextWithOptions:@{
        kCIContextWorkingColorSpace : (__bridge id)cs
        kCIContextWorkingFormat     : @(kCIFormatRGBAf) // 32-bit 浮点,无损全流程
    }];
});

③ 避免半精度截断与 Deflate 压缩。 写入 TIFF 目标时,若使用 kCIFormatRGBAh(16-bit 浮点),某些系统底层的 CGImageDestination 会误将其转为普通 16-bit 整数(把 0–65535 映射到 0.0–1.0),导致高光依旧丢失。因此最终统一采用 kCIFormatRGBAf(32-bit IEEE 浮点,SampleFormat=3)。又因为 4K 帧无压缩高达 126 MB,在 kCGImagePropertyTIFFDictionary 中开启 Deflate 压缩(Tag = 8),成功把文件体积降低了 30%–50%。

⚠️ 最终弃用原因: 系统自带的”预览.app”在诸多场景下并不支持带 HDR 的 TIFF 预览,导致费尽心血保留的高光数据无法正常呈现。

终极生产方案:临时 SDR 管线分支截图

由于 32-bit 浮点 TIFF 在系统原生生态中兼容性较差,我最终选择了更务实高效的降维方案

  • 实现机制: 不强行在当前 HDR RGBA16Float 链路上截屏,而是在截图请求触发时,临时创建一个 MTLPixelFormatBGRA8Unorm 格式的 picturePipeline,专门负责离屏截图的数据输出。
  • 收益: 既彻底绕过了格式不匹配崩溃,又向下兼容了通用的 JPG/PNG 格式,完美解决了生产环境的痛点。

小结

问题根因解法
Pipeline / Framebuffer 格式不匹配崩溃drawRect: 内才改格式,时序倒置把格式检测与 Pipeline 重建前置到取 Drawable 之前
EDR 已开但画面不亮early return 跳过设置 + EDR 属性在渲染线程赋值被忽略修正刷新逻辑,CALayer 属性 dispatch_async 到主线程
picturePipeline 多线程竞争渲染线程与主线程并发读写renderSnapshotLock 互斥 + 严禁主线程 dispatch_sync 防死锁
HDR 截图崩溃FBO 硬编码 BGRA8Unorm 与 RGBA16Float 不匹配截图时临时拉起 SDR 管线分支,兼容 JPG/PNG
本文由作者按照 CC BY 4.0 进行授权