GPU Gems 1 · Chapter 26. The OpenEXR Image File Format
用 OpenEXR 的 HDR、half、多通道和线性像素值保存 GPU 图像,在处理与显示之间建立可验证的格式契约。
学习目标
- 能解释为什么 GPU 图像不能在处理前过早量化到显示器的零到一范围
- 能拆解 OpenEXR 的 half、header、独立通道和无损压缩契约
- 能在 linear color space 中完成合成或模糊,并把 gamma correction 留在显示边界
- 能设计一条读取、处理、写回和显示 HDR 图像的可测试代码路径
GPU 生成的图像通常只在一帧里短暂存在,但值得保存的帧可能包含比显示器白点亮很多的高光。GPU Gems 1 第 26 章把问题拆成两段:先用能保留信息的格式保存图像,再在显示时把宽动态范围映射到设备能呈现的范围。
↡由 Industrial Light & Magic 开发、面向计算机成像的高动态范围图像文件格式 的核心价值是把“保存”与“显示”解耦。文件可以保留超过 1 的亮度和额外数据通道,应用则在需要时读取、处理、合成,再针对目标显示器做转换。
1. HDR 不是更亮的滤镜,而是更大的信息预算
典型显示设备的动态范围大约是 500 比 1,而常见显示格式把黑和白编码为 0.0 到 1.0。只要任务是直接显示,这个范围足够;但环境贴图、全局光照、色彩校正、景深和运动模糊都可能需要保留超出显示白点的像素。
用低动态范围文件保存高光,等价于在后续算法之前执行不可逆的 clamp。比如一个原始值为 4.0 的高光和另一个原始值为 1.0 的白点,都会被保存成 1.0;之后把整张图变暗,算法已经无法知道两者原本不同。OpenEXR 的设计让量化延后到显示端,因此高光可以参与正确的扩散、合成和曝光调整。
↡允许像素精度和亮度范围超出典型显示设备、以便继续处理或用于环境照明的图像表示 不是某一种视觉风格,而是数据表示的选择。发布检查应问“这一步之后还要不要处理”,而不是只问“当前屏幕是否看起来正常”。
2. half:用指数覆盖亮度范围
OpenEXR 直接支持 ↡OpenEXR 使用的 16 位浮点类型,由 1 位 sign、5 位 exponent 和 10 位 mantissa 组成。它的布局类似 IEEE 754 单精度和双精度浮点数,但标准本身没有定义 16 位格式,所以 half 需要由格式和实现共同约定。
原章给出的可表示范围约为 5.96 × 10⁻⁸ 到 65504.0,动态范围达到约一万亿比一;实际常见的百万比一场景可以舒适地容纳。每个 stop 还有 1024 个不同值,所以 half 不只是 HDR 的容器,也能为正常动态范围图像提供比 8-bit gamma 编码更细的颜色分辨率。
Imath::half hdrPixel(3.5f);
float workingValue = hdrPixel + std::sqrt(static_cast<float>(hdrPixel));
hdrPixel += workingValue;工程上要把这种类型信息一路保留下来:CPU image buffer、GPU texture internal format、上传的数据类型和写回文件的 channel type 必须互相匹配。half 支持与 float、double 混合运算,不代表所有中间步骤都可以无检查地降精度;范围边界、NaN 和无穷值仍应进入测试。
3. header 是读取方必须遵守的格式契约
一个 OpenEXR 文件由 header 和 pixels 两大部分组成。header 的 attribute 描述像素如何解释,其中 displayWindow 描述图像分辨率,dataWindow 描述 crop 与 offset,channels 描述通道,compression 描述像素压缩方式,lineOrder 描述扫描线顺序;像素宽高比和屏幕窗口属性也属于跨程序读取时的重要信息。
↡描述 OpenEXR 像素有效区域、裁剪范围和偏移的 header attribute 值得单独检查:把 buffer 当成从零开始的紧密矩形,可能会忽略负坐标或 crop offset。读取时应先从 data window 计算维度,再用窗口的最小坐标设置 frame buffer 的偏移,最后按窗口范围读取像素。
Imath::Box2i window = input.dataWindow();
const int width = window.max.x - window.min.x + 1;
const int height = window.max.y - window.min.y + 1;
buffer.resize(static_cast<size_t>(width) * height);
input.setFrameBuffer(buffer.data() - window.min.x - window.min.y * width, 1, width);
input.readPixels(window.min.y, window.max.y);像素按独立通道保存。RGBA 可以使用 half,而 z-depth 或其他需要更大精度的通道可以使用 32 位 float;通道集合不必固定为四个。额外 attribute 还能保存计算历史、色彩配置、相机位置和观察方向,让文件成为可追溯的图像资产,而不是只有一块没有上下文的像素数组。
4. 压缩必须与数据和 I/O 目标一起选择
OpenEXR 的 PIZ、ZIP、RLE 三种方案都是无损的:压缩和解压不会改变像素数据。它们的差别在速度、压缩比和图像内容适配性,而不是“是否会损失颜色”。PIZ 对摄影图像通常压缩比最好;ZIP 解压较快但压缩较慢;RLE 很快并适合大面积平坦区域。快速文件系统上也可以选择不压缩,以换取更快的读写。
↡OpenEXR 的无损压缩策略,压缩和解压不改变像素值,但不同实现会在速度和文件大小之间取不同折中 的工程契约是“字节布局可以变化,像素语义不能变化”。发布前要用同一组输入验证压缩前后的像素、通道列表、窗口属性和读写耗时,不要只看文件大小。
Imf::RgbaOutputFile output(fileName, displayWindow, dataWindow, Imf::WRITE_RGBA);
output.setFrameBuffer(pixels - offsetX - offsetY * width, 1, width);
output.writePixels(height);5. linear 像素值让合成和滤波表达光量
原章把 linear 定义为:像素数值与所代表的相对场景亮度成比例。数值加倍就表示光加倍;除以二就是暗一个 stop。标准 over 合成、coverage alpha、抗锯齿和软件渲染都依赖这个关系。
↡数值与相对场景亮度成比例的颜色空间,适合进行光量相关的合成、抗锯齿和滤波 中,前景和背景的 alpha 混合才是在混合光量。显示器则通常以幂律响应输出光,因此需要在最后一步做 gamma correction;这不是把整个处理链改成 gamma 空间的理由。
float3 overLinear(float3 foreground, float alpha, float3 background) {
return foreground * alpha + background * (1.0 - alpha);
}
float3 toDisplay(float3 linearValue, float monitorGamma) {
return pow(max(linearValue, 0.0), 1.0 / monitorGamma);
}如果图像确实要落到有限范围的显示设备,可以先做 tone mapping,再做显示编码。关键顺序是:OpenEXR 保留 HDR,算法在 linear 数据上运行,显示适配放在边界;否则曝光、景深和合成都会把量化误差放大成可见伪影。
6. 读写路径:让 GPU buffer 与文件契约对齐
读取 OpenEXR 时,库接口会把像素写入内部 buffer;异常应在 catch 路径中处理,文件生命周期交给 RAII。写回时,先确认 display window、data window 和 channel layout,再把合成结果写入输出文件。旧原章示例使用 OpenGL 的 RGBA half texture 传输 GPU buffer;今天的 API 名称可能不同,但“不 clamp、类型匹配、窗口一致”的验证点仍然适用。
try {
Imf::RgbaInputFile input(fileName);
readIntoBuffer(input, pixels, window);
uploadHalfTexture(pixels, width, height);
renderAndCompositeInLinearSpace();
writeOpenExr(resultFile, resultPixels, displayWindow, dataWindow);
} catch (const Iex::BaseExc& error) {
reportImageIoFailure(error.what());
}OpenEXR Lab
先预测:高光在处理前被夹到 1,会怎样影响 blur?
预测:切换到 8-bit 后,提高曝光会增加 clipped;OpenEXR 保留高光,blur 才能把亮点自然扩散。
实验中先切换格式,再改变曝光并选择 blur 或 composite。预期是 8-bit 路径的 clipped 在高光超过 1 时上升,而 OpenEXR 路径仍保留场景峰值;这不是显示器真的变成 HDR,而是说明处理阶段拿到的数据仍有信息。
小结:把保存、处理和显示分成三个边界
- OpenEXR 保存 HDR 像素、额外通道和描述像素的 header,使后续处理不必接受提前 clamp。
- half 用 sign、exponent 和 mantissa 在 16 位预算下覆盖宽动态范围;RGBA 与 z-depth 等通道可以选择不同类型。
- dataWindow、channels、compression 和 lineOrder 是读取方必须尊重的格式契约;PIZ、ZIP、RLE 都是无损方案。
- linear color space 适合 over、抗锯齿、景深和运动模糊;gamma correction 或 tone mapping 应留在显示边界。
- 发布前用异常路径、窗口偏移、通道类型、压缩往返、超一高光和目标显示器回归整条读写链。
练习
问题 1|格式判断 场景里有一个值为 4.0 的高光。分别说明把它写入 8-bit display file 与 OpenEXR half 后,再执行整体变暗和景深 blur,会出现什么差异?
问题 2|header 与通道 dataWindow 的最小坐标是 (-8, 4),最大坐标是 (119, 67)。写出 buffer 的宽高,并说明为什么 RGBA 使用 half、Z 使用 float 是合理的。
问题 3|设计题 为一个 GPU 景深管线设计 OpenEXR 回归测试。至少覆盖数据范围、linear 合成、窗口、通道、压缩和显示转换六个方面,并说明每项的通过条件。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- OpenEXR
- 面向计算机成像的高动态范围图像文件格式。
- high-dynamic-range image
- 允许像素范围和精度超出典型显示设备的图像表示。
- half
- 由 sign、exponent 和 mantissa 组成的 16 位浮点类型。
- dataWindow
- 描述像素有效区域、裁剪范围和偏移的 header attribute。
- lossless compression
- 不改变像素语义、只在速度和文件大小间折中的压缩策略。
- linear color space
- 数值与相对场景亮度成比例、适合光量运算的颜色空间。