HDR 处理
HDR 处理是 SpinningMomo 截图能力的核心子系统之一:它把捕获阶段得到的 R16G16B16A16_FLOAT HDR 纹理,通过一条 GPU 预处理管线转换为 Ultra HDR JPEG(SDR 兼容底图 + gain map,符合 ISO 21496-1 语义),或保存为无损 JPEG XR 底片,两种输出可以在同一次截图中共存。
目的与范围
本页覆盖 HDR 编码子系统的完整机制:
features::screenshot::hdr_encoder模块的接口契约与实现单元划分(hdr_encoder.hpp及其四个.cpp实现文件)- GPU 预处理管线:直方图统计、tone-map + sRGB OETF 生成 SDR 底图、gain 计算、tile 范围归约与量化
- Gain map metadata 的浮点语义模型及其到 ISO 21496-1 分数字段的转换
- Ultra HDR JPEG 容器封装(两张 WIC JPEG + 自研 XMP/ISO/MPF 写入)
- JXR 无损路径:staging texture 读回、线程边界与后台 WIC 编码
- 两阶段 GPU 读回(
begin_*/finish_*)的并发设计动机 - 第三方依赖 libultrahdr 的引入方式与语义对齐关系
以下相关主题有意留给兄弟页面,本页只做引用不做展开:
- HDR 纹理最初是如何从桌面复制/捕获管线产生的——参见捕获主题的其他页面(如屏幕捕获)
- 录制路径中的 HDR 行为——见 docs/features/recording.md 的 HDR 章节
- 截图功能的整体入口与保存流程——见 docs/features/screenshot.md
- 用户视角的 HDR 功能说明——见 docs/features/hdr.md
概述
当 Windows 系统开启 HDR 时,捕获管线拿到的是 FP16 浮点纹理(线性 scRGB 语义)。这类数据无法直接保存为普通 JPEG——量化到 8bit sRGB 会永久丢失高光信息。本子系统要同时解决两个矛盾的需求:
- 兼容性:产物在只支持 SDR 的查看器里也应呈现合理画面 → 生成一张 tone-map 后的 SDR 底图
- 保真性:HDR 相对 SDR 的高光倍数信息不能丢 → 用一张多通道 gain map 记录每个像素"HDR 是 SDR 的多少倍",并以 ISO 21496-1 metadata 描述恢复方式
Ultra HDR JPEG 正是"SDR 底图 + gain map + metadata"的多图容器;而对完全无损存档有要求的场景,则并行输出一份 FP16 的 JPEG XR 底片。
模块的几个关键设计决策(均可在接口文件注释中找到依据):
- GPU 承担全部像素级重活:直方图、tone-map、gain 计算、tile 归约、量化都在 GPU 上完成;CPU 只对归约后的全局 min/max 做 clamp 与最小范围展开
- BGRA8888 中间格式:GPU 侧把 base 和 gain map 都按紧排 BGRA8 打包,"为了让 D3D11/UAV/WIC 直接复用现有 4-byte 像素路径"
- 错误处理统一为
std::expected<T, std::string>:所有可能失败的入口都返回 expected,调用方以 monadic 方式传播错误 - 读回与编码解耦:JXR 的像素数据在脱离 D3D 资源后才交给后台线程做 WIC 编码,避免 WIC 阻塞 D3D 线程
架构
hdr_encoder 模块位于 src/features/screenshot/,接口集中在 hdr_encoder.hpp,实现拆分在四个 .cpp 单元中。接口文件的模块注释明确说明了这一拆分原则:"实现拆在多个 .cpp 中,本接口文件只保留调用方需要的类型与入口"。
各组成部分的职责:
| 组件 | 文件 | 职责 |
|---|---|---|
| 接口契约 | hdr_encoder.hpp | 定义全部类型与函数原型,"避免实现文件里手写前向声明" |
| GPU 预处理 | hdr_encoder_gpu.cpp | 直方图提交、tone-map、gain 计算、tile 归约、量化、读回 |
| Ultra HDR 封装 | hdr_encoder_ultrahdr.cpp | WIC 编码两张 JPEG 并直接写 XMP/ISO/MPF 容器 |
| JXR 编码 | hdr_encoder_jxr.cpp | staging 读回与后台线程 WIC 无损编码 |
| 入口编排 | hdr_encoder.cpp | 对外暴露的 save_* 便捷函数 |
说明:上表对四个
.cpp的职责划分依据是接口文件的模块注释与文件命名(_gpu/_ultrahdr/_jxr),这些实现文件的逐行内容未在本文中逐一展开;本文所有行为性结论均以hdr_encoder.hpp的注释与声明为准。
libultrahdr 以源码形式被引入 third_party/libultrahdr,由 scripts/fetch-third-party.js 负责 fetch/checkout,版本固定为 v1.4.0。接口注释表明项目自己写 Ultra HDR 容器("再由项目内代码直接写 XMP/ISO/MPF 容器"),同时保持与 libultrahdr 的 XMP/ISO 语义一致,因此图中以虚线表示"语义对齐"而非直接调用。
依赖关系的另一侧是调用方:截图功能在系统开启 HDR 时调用本模块,将结果保存为 Ultra HDR JPEG,"并可选同时保存一份无损 JXR 底片"(见 docs/features/screenshot.md)。
核心数据流:Ultra HDR JPEG 生成
完整流程分为 GPU 预处理与 CPU 封装两个阶段,接口提供了两种粒度的调用方式:一次性入口 preprocess_texture_for_ultrahdr,以及拆分式 begin_ultrahdr_preprocess + finish_ultrahdr_preprocess(用于与其他 GPU 工作重叠)。
阶段 1:GPU 预处理
接口注释描述了这一阶段的工作内容:"GPU 先生成 SDR base 和 HDR half,再完成 gain 计算、tile 范围归约与量化。CPU 只对归约后的全局 min/max 做 clamp 与最小范围展开。"
预处理结束后,调用方拿到的是 UltraHdrPreparedImages——两张已经量化成紧排 BGRA8888 的图层,以及一套共享的 log2 域 min/max 范围。
阶段 2:Metadata 构造
build_gainmap_metadata 是从像素统计到容器语义的桥梁,接口注释写道:"从 GPU 统计出的 gain 范围和用户目标显示峰值构造最终 metadata 语义。"其中 target_display_peak_nits 会写入 Ultra HDR metadata,解码端据此判断 gain map 应把亮部恢复到什么上限。
阶段 3:容器封装与写盘
encode_ultrahdr_jpeg 的接口注释揭示了封装策略:"用 WIC 编出两张 JPEG,再由项目内代码直接写 XMP/ISO/MPF 容器。"即 JPEG 压缩交给平台 WIC,多图容器与元数据由项目内代码手工写入。这与 Android 生态通用的 libultrahdr 库形成互补:项目复用其语义,但容器写入自研,因而能精确控制输出字节布局。
代码:接口类型定义
下面是接口文件中最核心的两个数据结构,注释完整描述了每个字段的语义:
1struct UltraHdrEncodeOptions {
2 // 底图(SDR 兼容预览)JPEG 量化质量,0–100。
3 int base_quality = 100;
4 // 增益图(HDR 相对 SDR 的倍数信息)JPEG 量化质量,0–100。
5 int gainmap_quality = 100;
6 // 目标显示器峰值亮度(nit),会写入 Ultra HDR metadata。
7 // 解码端会结合这个字段判断 gain map 应该把亮部恢复到什么上限。
8 float target_display_peak_nits = 1000.0f;
9};
10
11// Ultra HDR 预处理的 GPU 中间状态。直方图已经提交到 immediate context,
12// 但结果暂不读回,以便调用方先完成另一条输出路径的 GPU→CPU 读回。
13struct UltraHdrPreprocessSession {
14 wil::com_ptr<ID3D11Device> device;
15 wil::com_ptr<ID3D11DeviceContext> context;
16 wil::com_ptr<ID3D11ShaderResourceView> source_srv;
17 wil::com_ptr<ID3D11Buffer> histogram_buffer;
18 std::uint32_t width = 0;
19 std::uint32_t height = 0;
20};Source: hdr_encoder.hpp
UltraHdrPreprocessSession 的存在是本模块最重要的并发设计之一:直方图任务提交后不立即等待 GPU 完成,而是把 GPU 句柄(device/context/SRV/histogram buffer)打包成会话对象返回。这样调用方可以先去驱动另一条输出路径(典型场景:JXR 的 staging 读回),让两条路径的 GPU 工作重叠执行,最后再统一读回结果。
代码:预处理产物与 metadata 语义
1struct UltraHdrPreparedImages {
2 // 已经 tone-map 并做 sRGB OETF 的 SDR 兼容底图,格式为紧排 BGRA8888。
3 std::vector<std::uint8_t> base_bgra8;
4 // 已量化好的多通道 gain map,格式为紧排 BGRA8888。
5 // GPU 侧按 BGRA 打包是为了让 D3D11/UAV/WIC 直接复用现有 4-byte 像素路径。
6 std::vector<std::uint8_t> gainmap_bgra8;
7 // metadata 仍然只写一套共享的 min/max 范围。
8 // 这里存的是那套共享范围在 log2 域里的值,和旧 libultrahdr 的 XMP/ISO 语义一致。
9 float min_gain_log2 = 0.0f;
10 float max_gain_log2 = 0.0f;
11 std::uint32_t width = 0;
12 std::uint32_t height = 0;
13};
14
15struct GainMapMetadata {
16 // 这些字段对应的是"浮点语义模型"。真正写进 JPEG 前,
17 // 还会被转换成 ISO 21496-1 需要的分数字段。
18 float min_content_boost = 1.0f;
19 float max_content_boost = 1.0f;
20 float gamma = 1.0f;
21 float offset_sdr = 1e-7f;
22 float offset_hdr = 1e-7f;
23 float hdr_capacity_min = 1.0f;
24 float hdr_capacity_max = 1.0f;
25 bool use_base_cg = true;
26};Source: hdr_encoder.hpp
几个值得注意的设计细节:
- log2 域共享范围:metadata 只存一套 min/max,对应多通道 gain map 的共同量化范围。选择 log2 域是为了与 libultrahdr 既有 XMP/ISO 语义对齐,解码端无需区分处理
- 浮点语义模型:
GainMapMetadata内部字段是浮点模型(offset_sdr = 1e-7f这类典型值),仅在写 JPEG 前才转换为 ISO 21496-1 的分数字段——把"人类可读的语义"与"容器字节格式"解耦 - BGRA 打包理由被显式注释:这不是随意选择,而是为了"让 D3D11/UAV/WIC 直接复用现有 4-byte 像素路径",避免为 HDR 单独维护一套像素布局
JXR 无损路径与线程模型
JPEG XR 是截图功能的可选第二输出:当用户需要无损保存 HDR 数据时,原始 FP16 纹理被读回并以 WIC 编码为 JXR 文件。这条路径同样被拆成三个函数,注释清楚说明了线程边界。
三步 API 的职责划分
1// 把 HDR 纹理复制到 staging texture;调用方可在 GPU 继续处理期间延后 Map。
2auto begin_jxr_readback(ID3D11Texture2D* texture) -> std::expected<JxrReadbackSession, std::string>;
3// 在 D3D 线程上完成 Map,并复制成不依赖 staging texture 的紧排像素。
4auto read_jxr_pixels(JxrReadbackSession session) -> std::expected<JxrPixelData, std::string>;
5// 在不接触 D3D 的线程上用 WIC 编码并保存 JXR。
6auto save_jxr_pixels(const JxrPixelData& pixels, const std::wstring& file_path)
7 -> std::expected<void, std::string>;Source: hdr_encoder.hpp
对应的数据结构:
1// JXR 的 GPU 读回状态。staging texture 只在 D3D 线程上使用,Map 后即释放。
2struct JxrReadbackSession {
3 wil::com_ptr<ID3D11DeviceContext> context;
4 wil::com_ptr<ID3D11Texture2D> staging_texture;
5 std::uint32_t width = 0;
6 std::uint32_t height = 0;
7};
8
9// 已经脱离 D3D 资源的紧排 FP16 像素,可安全交给后台线程做 WIC 编码。
10struct JxrPixelData {
11 std::vector<std::uint8_t> pixels;
12 std::uint32_t width = 0;
13 std::uint32_t height = 0;
14 std::uint32_t row_pitch = 0;
15};Source: hdr_encoder.hpp
设计意图:为什么是三步而不是一个函数
把"复制到 staging"、"Map 读回"、"编码写盘"拆成三个函数,本质上是把线程边界显式化在类型系统里:
begin_jxr_readback返回的会话只持有 staging texture,Map 之前它可以在 D3D 线程间安全传递;GPU 可以在这段时间继续处理read_jxr_pixels被明确限定"在 D3D 线程上完成 Map",且 Map 后立即释放 staging texture——staging 资源生命周期最短化,避免长期占用JxrPixelData是纯内存 POD,"已经脱离 D3D 资源",因此save_jxr_pixels被允许"在不接触 D3D 的线程上用 WIC 编码"——WIC 编码耗时较长,放到后台线程就不会阻塞渲染/捕获线程
这种设计与 Ultra HDR 路径的 begin/finish 拆分是同一个动机的两面:让 GPU 等待与 CPU 编码都不阻塞 D3D 线程。当用户同时勾选两种输出时,两条路径的 GPU 任务可以重叠,读回阶段交错进行,编码阶段全部在后台完成。
配置选项
模块对外的全部可配置项集中在 UltraHdrEncodeOptions:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
base_quality | int | 100 | 底图(SDR 兼容预览)JPEG 量化质量,0–100 |
gainmap_quality | int | 100 | 增益图(HDR 相对 SDR 倍数信息)JPEG 量化质量,0–100 |
target_display_peak_nits | float | 1000.0f | 目标显示器峰值亮度,写入 Ultra HDR metadata;解码端结合该字段判断 gain map 应把亮部恢复到什么上限 |
默认值的选择反映了模块定位:base_quality 与 gainmap_quality 均默认 100(最高量化质量),说明产物优先保真而非体积;target_display_peak_nits 默认 1000 nit 对应常见 HDR 显示器的典型峰值。来源:hdr_encoder.hpp。
API 参考
所有入口统一返回 std::expected<T, std::string>,错误以字符串描述传播。函数原型集中放在接口单元,"避免实现文件里手写前向声明"。
Ultra HDR 路径
preprocess_texture_for_ultrahdr(texture: ID3D11Texture2D*) -> std::expected<UltraHdrPreparedImages, std::string>
一次性预处理入口。GPU 生成 SDR base 与 HDR half,完成 gain 计算、tile 范围归约与量化;CPU 只对归约后的全局 min/max 做 clamp 与最小范围展开。
begin_ultrahdr_preprocess(texture: ID3D11Texture2D*) -> std::expected<UltraHdrPreprocessSession, std::string>
提交 Ultra HDR 直方图 GPU 任务,但不阻塞读取结果。返回的会话持有 device/context/SRV/histogram buffer。
finish_ultrahdr_preprocess(session: UltraHdrPreprocessSession&) -> std::expected<UltraHdrPreparedImages, std::string>
读取直方图并继续完成剩余 GPU 预处理和输出图层读回。与 begin_ultrahdr_preprocess 配对使用。
build_gainmap_metadata(images: const UltraHdrPreparedImages&, options: const UltraHdrEncodeOptions&) -> GainMapMetadata
从 GPU 统计出的 gain 范围与用户目标显示峰值构造最终 metadata 语义。是纯 CPU 函数,不触碰 GPU。
encode_ultrahdr_jpeg(images: const UltraHdrPreparedImages&, options: const UltraHdrEncodeOptions&) -> std::expected<std::vector<std::uint8_t>, std::string>
用 WIC 编出两张 JPEG,再由项目内代码直接写 XMP/ISO/MPF 容器,返回完整文件字节流。
save_prepared_images_as_ultrahdr_jpeg(images, file_path, options = {}) -> std::expected<void, std::string>
将已经完成 GPU 预处理的图层编码并写入 Ultra HDR JPEG,适合已经持有 UltraHdrPreparedImages 的调用方。
save_texture_as_ultrahdr_jpeg(texture: ID3D11Texture2D*, file_path: const std::wstring&, options = {}) -> std::expected<void, std::string>
生成 SDR 底图 + gain map → 编码为 Ultra HDR JPEG 并写入 path 的最高层便捷入口,内部串联预处理与封装。
JXR 路径
begin_jxr_readback(texture: ID3D11Texture2D*) -> std::expected<JxrReadbackSession, std::string>
把 HDR 纹理复制到 staging texture;调用方可在 GPU 继续处理期间延后 Map。
read_jxr_pixels(session: JxrReadbackSession) -> std::expected<JxrPixelData, std::string>
在 D3D 线程上完成 Map,并复制成不依赖 staging texture 的紧排像素。注意参数按值传递,调用后 session 失效。
save_jxr_pixels(pixels: const JxrPixelData&, file_path: const std::wstring&) -> std::expected<void, std::string>
在不接触 D3D 的线程上用 WIC 编码并保存 JXR。
通用
write_file(file_path: const std::wstring&, data: const std::vector<std::uint8_t>&) -> std::expected<void, std::string>
将字节流写入文件,供本模块的不同实现单元复用。
失败模式、边界与并发
错误传播模型
整个模块没有使用异常,所有可失败入口统一返回 std::expected<T, std::string>。选择 std::string 而非专用错误枚举,意味着调用方拿到的是可直接呈现给用户的描述性文本;这也解释了为什么接口文件把 write_file 也作为模块内复用入口暴露——写盘失败与 GPU 失败走同一条错误传播链。
GPU 同步点与两阶段读回
Ultra HDR 路径的核心边界在直方图读回处:
begin_ultrahdr_preprocess提交任务后立即返回,不做任何隐式 GPU 等待——这正是注释强调"直方图已经提交到 immediate context,但结果暂不读回"的原因- 潜在的空转窗口存在于
finish_ultrahdr_preprocess读回直方图时(GPU 未完成则需等待);通过让两条输出路径先重叠提交、后统一读回,把这个同步点的代价摊薄 - JXR 路径的
begin_jxr_readback同理:复制到 staging 后即可返回,Map 时机由调用方决定
线程边界(重要约束)
| 资源 / 操作 | 允许的线程 | 依据 |
|---|---|---|
UltraHdrPreprocessSession 中的 D3D 对象 | D3D 线程(immediate context 语义) | 提交到 immediate context |
JxrReadbackSession::staging_texture | 仅 D3D 线程,Map 后即释放 | 注释明确"staging texture 只在 D3D 线程上使用" |
JxrPixelData(脱离 D3D) | 任意线程 | 注释"已经脱离 D3D 资源" |
save_jxr_pixels 的 WIC 编码 | 不接触 D3D 的后台线程 | 注释"在不接触 D3D 的线程上用 WIC 编码" |
build_gainmap_metadata | 任意线程(纯 CPU) | 不涉及 GPU 资源 |
数据精度边界
- 量化范围共享:
UltraHdrPreparedImages只存一套min_gain_log2/max_gain_log2,多通道 gain map 共用这一量化范围。极端内容(某通道 gain 远高于其他通道)会拉宽共享范围,从而略微损失其他通道的量化精度——这是为兼容 libultrahdr XMP/ISO 语义而接受的折衷 - 范围下限:
GainMapMetadata的min_content_boost/hdr_capacity_min默认1.0f,offset_sdr/offset_hdr默认1e-7f,保证纯 SDR 内容(无任何高光增强)时 gain map 退化为恒等映射 - BGRA8 量化:base 与 gain map 均为 8bit BGRA,Ultra HDR 的信息量上限由此决定;无损需求由 JXR FP16 路径补充
与 libultrahdr 的关系
scripts/fetch-third-party.js 负责将 google/libultrahdr 以 --depth 1 --branch v1.4.0 方式克隆到 third_party/libultrahdr,并支持已存在时的版本同步(fetch tag + checkout,存在非 git 目录时警告并跳过)。从接口注释看,项目对 libultrahdr 的依赖是语义参照而非运行时调用:min/max 范围"和旧 libultrahdr 的 XMP/ISO 语义一致",而容器写入是项目内代码直接完成的。这保证了产物可被标准 Ultra HDR 解码器(包括 libultrahdr)正确解读。
性能与运行注意事项
- GPU/CPU 分工:像素级计算全部在 GPU(直方图、tone-map、gain、tile 归约、量化),CPU 侧仅处理归约后的标量。这使 4K/8K 分辨率下 CPU 不成为瓶颈
- tile 归约:gain 范围统计按 tile 进行后再归约为全局 min/max,避免单次全图原子操作的争用
- WIC 编码移出关键路径:JXR 的 WIC 编码被显式放到"不接触 D3D 的线程",即使编码耗时也不会阻塞捕获循环
- 两路输出的重叠:
begin/finish拆分允许 Ultra HDR 直方图任务与 JXR staging 复制的 GPU 时间重叠,这是模块提供的最主要性能杠杆 - 质量默认顶格:两条 JPEG 的量化质量默认 100,追求保真时体积会明显大于常规截图;体积敏感场景可调低
base_quality/gainmap_quality
扩展点
- 新增输出格式:模块的实现按"每种格式一个
.cpp"组织(_ultrahdr/_jxr),新增格式(如 AVIF HDR)可遵循同样模式:在hdr_encoder.hpp增加类型与原型,新增对应实现单元,无需改动既有路径 - 复用现有预处理产物:
UltraHdrPreparedImages是脱管中间产物,save_prepared_images_as_ultrahdr_jpeg允许已有预处理的调用方跳过 GPU 阶段直接封装——也是缓存/复用预处理结果的天然挂点 - 元数据语义调整:
GainMapMetadata使用浮点语义模型,写盘前才转 ISO 21496-1 分数。未来若需支持新版本语义,只需调整转换层,浮点模型保持稳定 - 4 字节像素路径复用:BGRA8 中间格式使任何后续处理(水印、裁剪、其他容器)都能复用现有 D3D11/UAV/WIC 路径
相关链接
- hdr_encoder.hpp — 本模块全部类型与函数原型的接口契约
- hdr_encoder.cpp / hdr_encoder_gpu.cpp / hdr_encoder_ultrahdr.cpp / hdr_encoder_jxr.cpp — 四个实现单元
- hdr.hpp / hdr_convert.hpp — 图形与媒体工具层的 HDR 辅助
- fetch-third-party.js — libultrahdr v1.4.0 第三方源码获取脚本
- docs/features/hdr.md — 用户视角的 HDR 功能文档
- docs/features/screenshot.md — 截图功能总览(HDR 章节)
- docs/features/recording.md — 录制功能中的 HDR 说明