首页
/ three.js EXRExporter 深度指南:导出 OpenEXR 高动态范围图像(浮点纹理与 PMREM 渲染目标实战)

three.js EXRExporter 深度指南:导出 OpenEXR 高动态范围图像(浮点纹理与 PMREM 渲染目标实战)

2026-09-06 18:27:54作者:秋泉律Samson

摘要导读

本指南以 three.js 仓库内 EXRExporter(EXR 导出器)为核心,讲解如何将 WebGL/WebGPU 渲染结果或 DataTexture 导出为电影工业标准的高动态范围 OpenEXR(.exr)图像。你将掌握 EXRExporter 的导入方式、parse() 两种调用变体、typecompression 选项的取值与默认行为,并通过官方示例 examples/misc_exporter_exr.html 和源码实现了解 RGBA 像素如何被重组、压缩并封装为符合规范的 EXR 文件,最终可自行实现「HDR 结果一键落盘」的完整工具链。

misc_exporter_exr 官方演示场景(导出 EXR 的 HDR 环境场景)

本文默认你已具备 three.js 基础。所有结论均来自仓库内文档、源码与官方示例;运行相关示例前请先在仓库根目录启动静态文件服务器(如 python3 -m http.server),浏览器访问对应页面。

EXR 是什么:为什么 3D 渲染要用它

EXR(Extended Dynamic Range)是一种面向电影工业的开放图像格式规范(由 AcademySoftwareFoundation 维护),其设计目标是精确且高效地表示高动态范围(HDR)的 scene-linear(场景线性)图像数据以及相关元数据。原文档明确指出:在精确度要求苛刻的宿主软件中(照片级真实感渲染、纹理访问、图像合成、深度合成、DI 调色)该格式被广泛使用。

与普通 8-bit LDR 图像不同,EXR 的核心价值在于:

  • 半浮点(half float)/ 单精度浮点像素:可存储远超 0–1 的动态范围,避免光照计算结果的截断与条带化;
  • Scene-linear 色彩空间:与渲染器内部的线性工作流天然对齐,可无损保留 lighting / compositing 所需的真实物理量;
  • 支持任意附加通道与元数据、多种压缩算法:兼顾精度、文件体积与读取速度。

在 three.js 生态里,EXR 的读取侧由 examples/jsm/loaders/EXRLoader.js 承担(其源码注释声明支持 uncompressed、ZIP(S)、RLE、PIZ、B44/A、DWA/B 等压缩变体),而写出侧就是本文主角 EXRExporter——它能让「渲染到浮点渲染目标 → 导出成 .exr 文件 → 交给合成软件或再次用 EXRLoader 读回」的完整闭环在本仓库内直接打通。

导入与实例化

EXRExporter 位于 three/examples/jsm 体系,属于需要显式导入的 addon(不随 three 核心模块自动加载)。官方用法:

import { EXRExporter } from 'three/addons/exporters/EXRExporter.js';

同时,该模块还会导出三个压缩常量,供配置 Options 使用:

import { EXRExporter, ZIP_COMPRESSION, ZIPS_COMPRESSION, NO_COMPRESSION } from 'three/addons/exporters/EXRExporter.js';

从源码来看,这些常量与类位于 examples/jsm/exporters/EXRExporter.js(常量的实际数值定义见该文件第 11–13 行:NO_COMPRESSION = 0ZIPS_COMPRESSION = 2ZIP_COMPRESSION = 3),并经由 examples/jsm/Addons.jsexport * 统一转发到 three/addons/ 命名空间。因此两条导入路径都可用,但以 three/addons/ 为推荐写法。

构造十分轻量,无任何参数:

const exporter = new EXRExporter();

parse() 方法:两种调用变体

EXRExporter 的核心方法只有 parse(),且是 async 方法。它依据第一个参数的类型自动分流(源码实现见 examples/jsm/exporters/EXRExporter.js):

parse( arg1, arg2, arg3 ) : Promise<Uint8Array>
变体 arg1 arg2 arg3 适用场景
变体 A:导出 DataTexture DataTexture Options(可选) 导出程序化生成的浮点纹理数据,如法线/数据纹理
变体 B:导出渲染目标 WebGLRendererWebGPURenderer RenderTarget(如 PMREM 渲染目标) Options(可选) 导出离屏渲染结果、环境贴图(PMREM)等

对应官方示例里的两段真实调用:

// 变体 A:导出数据纹理(RGBAFormat / FloatType 的 DataTexture)
result = await exporter.parse( dataTexture, { type: exportType, compression: exportCompression } );

// 变体 B:导出渲染目标(例如 PMREMGenerator 生成的渲染目标)
result = await exporter.parse( renderer, renderTarget, { type: exportType, compression: exportCompression } );

返回值Promise,resolve 为一个 Uint8Array,即完整的、可直接写盘的 EXR 二进制内容。官方示例在拿到 result 后,用 Blob(MIME 类型 image/x-exr)+ 临时 <a download> 触发浏览器下载:

const blob = new Blob( [ result ], { type: 'image/x-exr' } );
const link = document.createElement( 'a' );
link.href = URL.createObjectURL( blob );
link.download = 'output.exr';
link.click();

值得注意的错误提示分支(源码第 49–51 行):若 arg1 既不是 WebGLRenderer、WebGPURenderer 也不是 DataTexture,parse 会直接抛出 EXRExporter.parse: Unsupported first parameter...。此外,当传入的渲染目标与纹理不满足输入约束时,同样会抛错(见下节)。

输入约束:能被导出的数据长什么样

EXRExporter 并非“什么都能导”,它对输入有严格的格式限定。这两组校验分别对应源码中的 supportedRTT()(第 85–111 行,针对渲染目标)与 supportedDT()(第 113–145 行,针对 DataTexture):

渲染目标(RenderTarget)约束:

  • 第二参数必须是 WebGLRenderTarget 实例;
  • 不支持 Cube / 3D / Array 等非平面渲染目标(会抛出 Unsupported render target type);
  • .texture.type 必须为 FloatTypeHalfFloatType
  • .texture.format 必须为 RGBAFormat

DataTexture 约束:

  • .type 必须为 FloatTypeHalfFloatType
  • .format 必须为 RGBAFormat
  • .image.data 必须存在;
  • 类型与底层数组必须严格匹配:FloatTypeFloat32ArrayHalfFloatTypeUint16Array(这正是 half float 的底层存储形式)。

也就是说:导出通道固定为 RGBA 四通道,官方示例(见下)中以 Float32Array 构造的 800×800 法线数据纹理即满足 FloatType + RGBAFormat 条件。

实操提示:若你的渲染目标使用 RGB 或默认格式,请先通过 WebGLRenderTarget 构造参数显式指定 RGBAFormat 与浮点 type;纹理类(如 DataTexture)同理,构造时传 ( data, width, height, THREE.RGBAFormat, THREE.FloatType ) 并设置 needsUpdate = true

Options 选项:输出类型与压缩算法

parse 的第三个(或第二个)可选参数是导出选项对象,其结构与默认值定义见 examples/jsm/exporters/EXRExporter.js@typedef,官方类型定义页面 docs/pages/EXRExporter.html.md 亦有完整罗列:

选项键 取值 默认值 作用
type HalfFloatType | FloatType HalfFloatType 决定 EXR 文件内像素的存储类型
compression NO_COMPRESSION | ZIP_COMPRESSION | ZIPS_COMPRESSION ZIP_COMPRESSION 决定采用的压缩算法

其中压缩常量对应源码顶部的数值:NO_COMPRESSION = 0ZIPS_COMPRESSION = 2ZIP_COMPRESSION = 3(注意取值顺序并不连续,3 与 2 的含义见下)。

type:half 与 float 的取舍

type: HalfFloatType 时,导出器把像素量化为 16-bit half float(每个通道 2 字节);当 type: FloatType 时为 32-bit 单精度浮点(每通道 4 字节)。从源码的 buildInfoRTT/buildInfoDT(第 147–213 行)可看到具体换算逻辑:

OUT_TYPE = ( EXPORTER_TYPE === FloatType ) ? 2 : 1,
dataSize = 2 * OUT_TYPE,   // HalfFloat → 2 字节/通道,Float → 4 字节/通道

对应到 EXR 文件头部的 channels 属性(info.dataType 被写入每个通道像素类型字段,见 fillHeader 第 440–462 行)。因此在选择时:

  • 追求文件体积小、多数合成需求够用HalfFloatType(每像素 8 字节);
  • 追求更高的精度余量、避免 deep shadow / 特殊通道量化损失FloatType(每像素 16 字节),文件体积与带宽同步翻倍。

compression:三种压缩算法的真实差异

从源码第 147–153 行的 compressionSizes 映射可以明确三种算法的本质区别在于每个压缩 block 所包含的扫描行数

const compressionSizes = {
    0: 1,   // NO_COMPRESSION
    2: 1,   // ZIPS_COMPRESSION
    3: 16   // ZIP_COMPRESSION
};
  • NO_COMPRESSION:数据不做任何压缩(compressNONE 直接原样返回),blockLines = 1,文件最大但零 CPU 开销;
  • ZIPS_COMPRESSION:每个 block 只有 1 条扫描线,进行 ZIP(deflate)压缩——压缩率偏低但便于流式/局部读取,适合超大图需要随机访问单行的场景;
  • ZIP_COMPRESSION:每个 block 含 16 条扫描线(blockLines = 16),一并压缩——压缩率更高,是官方默认选项,也是通用 EXR 工作流里最常用的选择。

numBlocks = Math.ceil( height / blockLines )(第 174/208 行)进一步说明文件会被切成 numBlocks 个压缩块,每块在 offset table 中单独登记偏移,这也是 EXR 支持块级随机访问的结构基础。

实战:官方演示逐行拆解

仓库提供了开箱即用的完整演示 examples/misc_exporter_exr.html,它同时覆盖了上述两种 parse 变体,非常适合当作模版。

其核心逻辑(关键行见上文件第 185–208 行)可以概括为三条分支:

// 1. 把 GUI 字符串映射为 three.js 常量
if ( params.type == 'HalfFloatType' ) exportType = THREE.HalfFloatType;
else exportType = THREE.FloatType;

// 2. 把压缩选项映射为模块导出的常量
if ( params.compression == 'ZIP' )      exportCompression = ZIP_COMPRESSION;
else if ( params.compression == 'ZIPS' ) exportCompression = ZIPS_COMPRESSION;
else exportCompression = NO_COMPRESSION;

// 3. 按输入类型调用对应的 parse 变体
if ( params.target == 'pmrem' )
    result = await exporter.parse( renderer, renderTarget, { type: exportType, compression: exportCompression } );
else
    result = await exporter.parse( dataTexture, { type: exportType, compression: exportCompression } );

演示场景准备了两种输入来源:

  1. PMREM 渲染目标(pmrem 分支):用 HDRLoader 加载一张 .hdr 高清环境图(textures/equirectangular/san_giuseppe_bridge_2k.hdr),标记 EquirectangularReflectionMapping 后交给 PMREMGenerator.fromEquirectangular() 生成 renderTarget 并用作 scene.background——这正是将 HDR 环境光照结果导出给下游合成软件使用的典型流程。上文配图即该演示的场景画面。
  2. DataTexture(data-texture 分支):以 Float32Array 程序化生成一张 800×800 的球形法线纹理(RGBAFormat + FloatType,A 通道为 1),作为验证导出链路的最小输入。

页面上的 lil-gui 面板可实时切换 Input(pmrem / data-texture)、Output Options(FloatType / HalfFloatType;ZIP / ZIPS / NONE),点击 Export EXR 即可触发下载。对接到你自己的项目时,把第 3 步的结果数组交给上文的 Blob 下载代码即可。

源码纵深:像素从 buffer 到 .exr 文件的四步流水线

若把 parse() 展开(源码第 47–81 行),无论哪种变体,底层都遵循同一条流水线,这正是理解导出器行为的关键:

buildInfo( ) → getPixelData( ) → reorganizeDataBuffer( ) → compressData( ) → fillData( )

第 1 步:信息收集(buildInfoRTT / buildInfoDT,L147–213):汇总宽高、像素类型、通道数、压缩方式,并计算 blockLinesnumBlocksdataTypedataSize 等后续步骤依赖的元数据。

第 2 步:读回像素(getPixelData,L215–241):WebGL 渲染器路径会先按 FloatType/HalfFloatType 预分配 Float32ArrayUint16Array,再调用 renderer.readRenderTargetPixelsAsync( rtt, 0, 0, w, h, dataBuffer ) 异步读回;WebGPU 渲染器路径直接复用同一 API 由后端返回 buffer。DataTexture 路径则跳过读回,直接取 texture.image.data

第 3 步:数据重组(reorganizeDataBuffer,L243–288):这是最关键的一步,做了三件事:

  • 从交错的 RGBA 平面排列转为 EXR 的分通道平面排列,且通道顺序按 A、B、G、R 写入(见第 271–280 行的四个 setValue 调用);
  • 做 Y 轴翻转line = ( h - y - 1 ) * ...,第 266 行),使第 0 行对应图像顶部,符合 EXR 自底向上的 scanline 语义;
  • 执行类型转换:输入若为 HalfFloatTypeUint16Array 的 half 位型),先经 decodeFloat16(第 581–596 行)解码为真实浮点值;输出若为 half,则经 DataUtils.toHalfFloat()(第 545–551 行)重新编码为 16-bit 写入。

第 4 步:压缩(compressData + compressZIP,L290–382):按 numBlocks 逐个 block 压缩。ZIP 压缩并非简单的 deflate,而是严格按 OpenEXR 规范实现的:

  1. 像素重排:偶数位字节依次填入前一半、奇数位字节填入后一半(第 348–362 行);
  2. 差值预测:对重排后的字节序列做增量编码 d = byte[i] - byte[i-1] + 384(第 368–376 行),把平滑区域的数值压缩到窄分布以提升压缩率;
  3. 最后交给 fflate 的 zlibSync(第 378 行)做标准的 deflate 输出。

NO_COMPRESSION 则直接透传原始字节。

第 5 步:文件封装(fillData / fillHeader,L384–508):把前面产出的块写成合法 EXR 容器,其中包括:

  • 文件魔数 20000630 与版本字段(第 389–390 行);
  • 头部属性:compressionscreenWindowCenter(v2f)screenWindowWidth(写入 1.0)、pixelAspectRatio(写入 1.0)、lineOrder(写入 0,即 increasing Y)、dataWindow/displayWindow(均为 box2i,坐标为 (0,0)-(w-1,h-1))、channels(chlist,按 A B G R 顺序声明,含各通道的像素类型与采样率 1×1);
  • 块偏移表:为每个 block 写 8 字节的 64 位起始偏移(第 471–479 行);
  • 每个数据块前再写该块起始行号(y 坐标)与压缩后字节数(第 496–501 行)。

所有多字节数值均以小端序写入(dv.setUint32( ..., true ))。理解这条流水线后,你会明白为何 FloatType 输入却能输出 HalfFloatType 文件(类型转换发生在第 3 步),以及为何同一份数据配合不同 compression 会得到体积差异悬殊的文件。

闭环验证:用 EXRLoader 读回导出结果

导出的 .exr 文件是否合法?最好的验证方式是用仓库自己的 EXRLoader 读回EXRLoaderexamples/jsm/loaders/EXRLoader.js)支持 uncompressed、ZIP(S)、RLE、PIZ、B44/A、DWA/B 压缩,自然覆盖 EXRExporter 能产出的全部三种模式。一个简单的自校验片段(参照 examples/webgl_loader_texture_exr.html 的加载姿势):

new EXRLoader().load( 'output.exr', ( texture ) => {
    texture.mapping = THREE.EquirectangularReflectionMapping; // 若为环境图
    scene.environment = texture;
} );

若你的导出来自 PMREM 渲染目标,用 EXRLoader 将其作为 scene.environment 读回,即可肉眼比对导出前后间接光照是否一致——这正是环境贴图导出最常见的验收手段。

常见问题与注意事项

  • 输入必须是 RGBA + 浮点:RGBFormat、UnsignedByteType 等输入会直接抛错。若源数据是 LDR 纹理,先在 CPU/GPU 侧转为浮点 RGBA 再交给导出器。
  • Cube/3D/Array 渲染目标不支持:如需导出 cube 环境贴图,请逐面导出 6 张 2D EXR 或在读回后自行拼接。
  • half 量化误差type: HalfFloatType 时小于约 6e-5、大于 65504 的数值会超出 half 表示范围,若渲染结果含极亮区域且后续需要大幅提亮,建议 type: FloatType
  • 体积与性能权衡ZIPSZIP 压缩率低(单行 block 破坏长程冗余),但文件更利于局部随机读取;NONE 体积最大,一般仅在调试或追求零编码开销时选用。
  • 类型严格匹配:DataTexture 为 FloatType 时其 .image.data 必须是 Float32ArrayHalfFloatType 时必须是 Uint16ArrayUint16Array 中存的是 half 位型,这是 three.js half float 纹理的标准布局),否则导出器抛错。

参考资料(仓库内)

至此,你已经具备了在 three.js 项目中把 HDR 渲染结果完整导出为规范 OpenEXR 文件所需的全部知识与可复制的代码路径。

登录后查看全文
热门项目推荐
相关项目推荐