Repository Wiki
ChanIok/SpinningMomo

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 的引入方式与语义对齐关系

以下相关主题有意留给兄弟页面,本页只做引用不做展开:

概述

当 Windows 系统开启 HDR 时,捕获管线拿到的是 FP16 浮点纹理(线性 scRGB 语义)。这类数据无法直接保存为普通 JPEG——量化到 8bit sRGB 会永久丢失高光信息。本子系统要同时解决两个矛盾的需求:

  1. 兼容性:产物在只支持 SDR 的查看器里也应呈现合理画面 → 生成一张 tone-map 后的 SDR 底图
  2. 保真性: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 中,本接口文件只保留调用方需要的类型与入口"。

Loading diagram...

各组成部分的职责:

组件文件职责
接口契约hdr_encoder.hpp定义全部类型与函数原型,"避免实现文件里手写前向声明"
GPU 预处理hdr_encoder_gpu.cpp直方图提交、tone-map、gain 计算、tile 归约、量化、读回
Ultra HDR 封装hdr_encoder_ultrahdr.cppWIC 编码两张 JPEG 并直接写 XMP/ISO/MPF 容器
JXR 编码hdr_encoder_jxr.cppstaging 读回与后台线程 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 工作重叠)。

Loading diagram...

阶段 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 库形成互补:项目复用其语义,但容器写入自研,因而能精确控制输出字节布局。

代码:接口类型定义

下面是接口文件中最核心的两个数据结构,注释完整描述了每个字段的语义:

cpp
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 语义

cpp
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 文件。这条路径同样被拆成三个函数,注释清楚说明了线程边界。

Loading diagram...

三步 API 的职责划分

cpp
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

对应的数据结构:

cpp
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 读回"、"编码写盘"拆成三个函数,本质上是把线程边界显式化在类型系统里:

  1. begin_jxr_readback 返回的会话只持有 staging texture,Map 之前它可以在 D3D 线程间安全传递;GPU 可以在这段时间继续处理
  2. read_jxr_pixels 被明确限定"在 D3D 线程上完成 Map",且 Map 后立即释放 staging texture——staging 资源生命周期最短化,避免长期占用
  3. JxrPixelData 是纯内存 POD,"已经脱离 D3D 资源",因此 save_jxr_pixels 被允许"在不接触 D3D 的线程上用 WIC 编码"——WIC 编码耗时较长,放到后台线程就不会阻塞渲染/捕获线程

这种设计与 Ultra HDR 路径的 begin/finish 拆分是同一个动机的两面:让 GPU 等待与 CPU 编码都不阻塞 D3D 线程。当用户同时勾选两种输出时,两条路径的 GPU 任务可以重叠,读回阶段交错进行,编码阶段全部在后台完成。

配置选项

模块对外的全部可配置项集中在 UltraHdrEncodeOptions:

选项类型默认值说明
base_qualityint100底图(SDR 兼容预览)JPEG 量化质量,0–100
gainmap_qualityint100增益图(HDR 相对 SDR 倍数信息)JPEG 量化质量,0–100
target_display_peak_nitsfloat1000.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 路径

相关链接

Sources

(1 files)