首页
/ three.js DataUtils 详解:FP16 半精度浮点转换的实现原理与实战应用

three.js DataUtils 详解:FP16 半精度浮点转换的实现原理与实战应用

2026-09-06 17:57:46作者:卓艾滢Kingsley

DataUtils 是 three.js 中一个专门存放数据工具函数的类,其当前版本(v0.185.0)的核心能力是 FP16(half float,半精度浮点)与 FP32(single precision,单精度浮点)之间的相互转换。本文基于官方文档 DataUtils 与源码 src/extras/DataUtils.js,完整讲解 toHalfFloat / fromHalfFloat 两个静态方法的行为边界、底层查表转换算法,以及它们在 Float16BufferAttribute、EXR/HDR 加载器、3D Gaussian Splatting 加载器等模块中的真实调用链。读完后你将能够正确地将 FP32 数据编码为 16 位半精度数值、理解 65504 上限的来龙去脉,并在处理 HDR 纹理或压缩格式数据时复用这套转换方案。

DataUtils 的定位与导出方式

官方文档对 DataUtils 的定义是:

A class containing utility functions for data.(一个包含数据工具函数的类)

类本体非常薄,仅以静态方法的形式暴露两个转换函数,并通过 @hideconstructor 标记隐藏构造函数——它是一个纯工具类,不应被实例化。类定义见 src/extras/DataUtils.js#L183-L211

/**
 * A class containing utility functions for data.
 *
 * @hideconstructor
 */
class DataUtils {

	static toHalfFloat( val ) {

		return toHalfFloat( val );

	}

	static fromHalfFloat( val ) {

		return fromHalfFloat( val );

	}

}

export {
	toHalfFloat,
	fromHalfFloat,
	DataUtils
};

注意该文件同时导出了 toHalfFloatfromHalfFloat 两个自由函数与 DataUtils 类。这意味着除了常见的 DataUtils.toHalfFloat( val ) 调用方式外,three.js 内部也可以直接以具名函数的方式导入,例如 src/core/BufferAttribute.js#L5 中就是:

import { fromHalfFloat, toHalfFloat } from '../extras/DataUtils.js';

DataUtils 类本身则通过 src/Three.Core.js#L160export { DataUtils } from './extras/DataUtils.js'; 作为核心 API 对外导出,因此任何基于 three 包(three.core 入口)的用户都可以直接访问它。

FP16 与 FP32 的背景

理解这两个方法之前需要先明确两种格式的位结构:

项目 FP16(Half) FP32(Float)
总位数 16 bit 32 bit
符号位 1 1
指数位 5(偏置 15) 8(偏置 127)
尾数位 10 23
最大有限值 65504 ≈ 3.4 × 10³⁸
最小正规格数 2⁻¹⁴ ≈ 6.1 × 10⁻⁵ ≈ 1.2 × 10⁻³⁸
次正规数下限 ≈ 2⁻²⁴ ≈ 1.4 × 10⁻⁴⁵

FP16 在 WebGL/WebGPU、HDR 纹理(HalfFloatType)、以及 3DGS(.splat/.ksplat/.spz)等现代 3D 压缩格式中被广泛使用。但 JavaScript 的运行时数值始终是 IEEE 754 双精度(FP64),浏览器原生 Float16Array 的兼容性此前仍有问题——这一点在 src/core/BufferAttribute.js#L857-L858 的注释中得到印证:

This class automatically converts to and from FP16 via Uint16Array since Float16Array browser support is still problematic.

也就是说:在 JS 层面,一个 FP16 数值实际上是以 16 位无符号整数的位模式Uint16Array 元素)存在和传输的。DataUtils 的两个方法,正是负责「JS number ↔ 16 位位模式」这层翻译。

DataUtils.toHalfFloat( val ):FP32 → FP16

官方文档签名:

.toHalfFloat( val : number ) : number —— Returns a single precision floating point value (FP32) from the given half precision floating point value (FP16). val: A half precision floating point value. Returns: The FP16 value.

(文档原文此处对参数的描述是「A half precision floating point value」,但从源码签名与实现看,入参实际是 FP32 浮点数,返回值是表示 FP16 位模式的整数,与源码 JSDoc 一致。使用时以源码为准。)

源码实现

src/extras/DataUtils.js#L150-L161

function toHalfFloat( val ) {

	if ( Math.abs( val ) > 65504 ) warn( 'DataUtils.toHalfFloat(): Value out of range.' );

	val = clamp( val, - 65504, 65504 );

	_tables.floatView[ 0 ] = val;
	const f = _tables.uint32View[ 0 ];
	const e = ( f >> 23 ) & 0x1ff;
	return _tables.baseTable[ e ] + ( ( f & 0x007fffff ) >> _tables.shiftTable[ e ] );

}

这段代码是整个类中最关键的实现,可以拆成四步理解:

  1. 范围保护:FP16 能表示的最大有限值是 65504。若 |val| > 65504,会通过 warn 输出 DataUtils.toHalfFloat(): Value out of range. 警告,并将值钳制(clamp)到 [-65504, 65504] 区间。因此 toHalfFloat( 100000 ) 不会得到 Infinity,而是静默地转换为 65504 的编码。
  2. 位模式重解释_tables.floatView_tables.uint32View 是共享同一个 4 字节 ArrayBuffer 的两个视图(见 src/extras/DataUtils.js#L12-L14)。写入 floatView[0] 后再从 uint32View[0] 读出,就把一个 FP32 数值的 32 位 IEEE 位模式拿到了手中。
  3. 指数提取e = ( f >> 23 ) & 0x1ff 取出 FP32 的 8 位原始指数,作为查表的索引。
  4. 查表拼装baseTable[e] 给出目标 FP16 的符号位与指数位(处理了 FP32 偏置 127 到 FP16 偏置 15 的差值、以及次正规数的特殊平移),而 ( f & 0x007fffff ) >> shiftTable[e] 则把 FP32 的 23 位尾数按 shiftTable[e] 指定的位数右移后截断为 10 位 FP16 尾数。两者相加即得到最终的 16 位编码。

整套算法来自文件头注释引用的「Fast Half Float Conversions」(src/extras/DataUtils.js#L4),核心思想是用查表代替逐位运算,单次转换只需两次表访问加一次移位,避免了浮点库调用,适合在纹理数据、几何属性等热点路径上批量执行。

查找表的生成逻辑

_tables 在模块加载时由 _generateTables() 一次性生成(/*@__PURE__*/ 注解保证打包器可在编译期确定其惰性语义),见 src/extras/DataUtils.js#L8-L141。FP32→FP16 方向有两张 512 项的表,分别对指数的正负号槽位(ii | 0x100)赋值,分支逻辑如下:

FP32 指数 e(= 原始指数 − 127) 区间 语义 baseTable / shiftTable
e < -27 极小值 结果舍入为 ±0 0x0000 / 0x8000,右移 24 位
-27 ≤ e < -14 次正规数(denorm) 落入 FP16 次正规区间 0x0400 >> (-e-14),右移 -e-1
-14 ≤ e ≤ 15 正规数 正常转换 (e+15) << 10,右移 13 位
15 < e < 128 溢出 结果为 ±Infinity 0x7c00 / 0xfc00,右移 24 位
e ≥ 128 FP32 的 Inf/NaN 槽 保留为 FP16 的 ±NaN 0x7c00 / 0xfc00,右移 13 位

这里能直接读出两个设计要点:其一,FP32 中指数小于 −27 的数会下冲(underflow)为零,这是 FP16 动态范围的固有代价;其二,由于外层已经用 clamp 把输入限制在 ±65504,实际调用中走 e < 128 溢出分支得到 Infinity 的情况只发生在 NaN 输入时。

DataUtils.fromHalfFloat( val ):FP16 → FP32

官方文档签名:

.fromHalfFloat( val : number ) : number —— Returns a half precision floating point value (FP16) from the given single precision floating point value (FP32). val: A single precision floating point value. Returns: The FP32 value.

同样地,以源码为准:入参是 FP16 的 16 位位模式(整数),返回值是还原后的 FP32 数值。

源码实现

src/extras/DataUtils.js#L170-L176

function fromHalfFloat( val ) {

	const m = val >> 10;
	_tables.uint32View[ 0 ] = _tables.mantissaTable[ _tables.offsetTable[ m ] + ( val & 0x3ff ) ] + _tables.exponentTable[ m ];
	return _tables.floatView[ 0 ];

}

流程比正向转换更简洁,只有三行核心逻辑:

  1. m = val >> 10 取出 16 位编码的高 10 位——符号位 + 5 位指数,作为三张表的索引;
  2. val & 0x3ff 取出低 10 位尾数,与 m 组合后从 mantissaTable(2048 项)中查得 FP32 的完整 23 位尾数(表内完成次正规数补齐:把缺失的隐含 1 左移到出现为止,见 src/extras/DataUtils.js#L78-L96 中的 while ( ( m & 0x00800000 ) === 0 ) 循环);
  3. 加上 exponentTable[m](把 FP16 偏置 15 的指数换算为 FP32 偏置 127 的指数位,并对 ±Inf/NaN 槽位给出 0x478000000x80000000 等特判值,见 src/extras/DataUtils.js#L104-L119),写入 uint32View,再从 floatView 读出即得 FP32。

offsetTable 的作用是为「特殊区」开一个窗口:当高 10 位 m === 32(即符号位为 0 的 Inf/NaN 编码)时,offsetTable[32] 为 0(其余项均为 1024),使 mantissaTable[1024 + 低 10 位] 命中表尾那段专门为 ±Inf/NaN 预置的区域(src/extras/DataUtils.js#L98-L102)。

与正向转换不同,fromHalfFloat 没有任何范围检查或钳制——因为任意 16 位整数都是合法的 FP16 位模式(包括 Inf 与 NaN),转换总是良定义的。

单元测试对行为边界的验证

官方文档只给出了两个方法的签名,而 test/unit/src/extras/DataUtils.tests.js 则用具体数值把两者的行为边界完整钉死了。以下是测试中覆盖的全部断言:

toHalfFloat(FP32 → FP16 位模式)

assert.ok( DataUtils.toHalfFloat( 0 ) === 0, 'Passed!' );

// suppress the following console message during testing
// THREE.DataUtils.toHalfFloat(): Value out of range.

console.level = CONSOLE_LEVEL.OFF;
assert.ok( DataUtils.toHalfFloat( 100000 ) === 31743, 'Passed!' );
assert.ok( DataUtils.toHalfFloat( - 100000 ) === 64511, 'Passed!' );
console.level = CONSOLE_LEVEL.DEFAULT;

assert.ok( DataUtils.toHalfFloat( 65504 ) === 31743, 'Passed!' );
assert.ok( DataUtils.toHalfFloat( - 65504 ) === 64511, 'Passed!' );
assert.ok( DataUtils.toHalfFloat( Math.PI ) === 16968, 'Passed!' );
assert.ok( DataUtils.toHalfFloat( - Math.PI ) === 49736, 'Passed!' );

fromHalfFloat(FP16 位模式 → FP32)

assert.ok( DataUtils.fromHalfFloat( 0 ) === 0, 'Passed!' );
assert.ok( DataUtils.fromHalfFloat( 31744 ) === Infinity, 'Passed!' );
assert.ok( DataUtils.fromHalfFloat( 64512 ) === - Infinity, 'Passed!' );
assert.ok( DataUtils.fromHalfFloat( 31743 ) === 65504, 'Passed!' );
assert.ok( DataUtils.fromHalfFloat( 64511 ) === - 65504, 'Passed!' );
assert.ok( DataUtils.fromHalfFloat( 16968 ) === 3.140625, 'Passed!' );
assert.ok( DataUtils.fromHalfFloat( 49736 ) === - 3.140625, 'Passed!' );

这组断言传递了几个关键信息:

  • 65504 是精确的最大有限值317430x7bff)解码恰为 65504,而下一个位模式 317440x7c00)就是 +Infinity——正好对应前文表格中 e < 128 分支写入的 0x7c00 特殊值;
  • 越界输入被钳制而非溢出toHalfFloat( 100000 ) 的结果与 toHalfFloat( 65504 ) 相同(均为 31743),同时触发 Value out of range. 警告——测试中用 console.level = CONSOLE_LEVEL.OFF 抑制该警告,这从侧面说明警告是预期行为而非缺陷;
  • FP16 精度有损Math.PI → 16968 → 3.140625,往返后只剩约 3~4 位有效十进制数字的精度,这是 10 位尾数的固有限制,使用时需知悉。

此外,test/unit/addons/tsl/TSLPacking.tests.js#L56-L66 还验证了 TSL 的 packHalf2x16 着色器函数与 DataUtils.toHalfFloat() 产出相同的位模式,说明 JS 侧查表实现与 GPU 侧 GLSL 打包逻辑在语义上是对齐的——这是一个值得引用的跨端一致性证据。

仓库内的实际应用

DataUtils 不是孤立的工具,而是 three.js 中所有需要「手写/读取 FP16 字节」场景的统一转换层。从源码结构看,主要消费者可分为三类:

1. Float16BufferAttribute:几何属性的 FP16 存取

src/core/BufferAttribute.js#L862-L1019 定义了 Float16BufferAttribute。由于浏览器原生 Float16Array 支持仍不稳定(见该类注释),它选择用 Uint16Array 承载数据,并在每个 getter/setter 中调用转换函数:

constructor( array, itemSize, normalized ) {

	super( new Uint16Array( array ), itemSize, normalized );

	this.isFloat16BufferAttribute = true;

}

getX( index ) {

	let x = fromHalfFloat( this.array[ index * this.itemSize ] );

	if ( this.normalized ) x = denormalize( x, this.array );

	return x;

}

setX( index, x ) {

	if ( this.normalized ) x = normalize( x, this.array );

	this.array[ index * this.itemSize ] = toHalfFloat( x );

	return this;

}

getX/getY/getZ/getWsetX/setXY/setXYZ/setXYZW 全链路都经过 fromHalfFloat/toHalfFloat,并在 normalized 模式下叠加 normalize/denormalize 处理。因此任何直接操作 FP16 属性数组的代码(如 test/unit/src/core/BufferAttribute.tests.js#L413-L431 中的批量转换辅助函数)都会走同一套逻辑,保证 CPU 侧写入与 GPU 侧 HalfFloatType 缓冲的位模式一致。

2. HDR / 高动态范围格式的加载与导出

模块 方向 用途
examples/jsm/loaders/EXRLoader.js FP32→FP16 将解码出的 EXR 颜色通道压缩为半精度纹理数据(L1331、L2768、L3312-L3314 等)
examples/jsm/exporters/EXRExporter.js FP32→FP16 导出时按小端序写入 16 位字段:dv.setUint16( offset.value, DataUtils.toHalfFloat( value ), true )(L547)
examples/jsm/loaders/HDRLoader.js FP32→FP16 RGB 通道缩放后先 Math.min( ..., 65504 ) 再转换(L373-L375),显式规避越界警告
examples/jsm/loaders/UltraHDRLoader.js FP32→FP16 Ultra HDR 元数据中 ICC.20 编码值的半精度写入(L701-L735)
examples/jsm/loaders/IESLoader.js FP32→FP16 光强分布数据转为 HalfFloatType 数组(L134)

HDRLoader 的写法尤其值得注意:调用方自行先钳制到 65504,从侧面说明社区对「越界会触发 warn」这一行为是知情的,且倾向于在生产路径上避免警告噪音。

3. 3D Gaussian Splatting 与探针类数据

使用建议与注意事项

结合源码与测试,实际使用 DataUtils 时有以下几点应当留意:

  1. 返回值语义toHalfFloat 返回的是 16 位位模式(一个 0–65535 范围内的整数),应写入 Uint16Array 或小端二进制字段;fromHalfFloat 的入参同理。它不是返回一个精度降低的浮点数。
  2. 越界行为|val| > 65504 会输出 DataUtils.toHalfFloat(): Value out of range. 警告并钳制到 ±65504。若你的数据(如 HDR 亮度)可能超界,建议参照 HDRLoader 的做法在调用前自行 Math.min( val, 65504 ),以消除警告。
  3. 精度预算:FP16 的 10 位尾数在 1.0 附近只有约 0.0005 的量化步长(π 往返后为 3.140625);小于 2⁻²⁴ 的数会下冲为零;动态范围上限 65504。对法线、UV 这类 [0,1] 区间数据通常足够,但直接存储 HDR 峰值时需先缩放。
  4. 性能:转换本身是两次表查找加移位/加法的 O(1) 操作,且表在模块加载时一次性生成,因此可以放心在纹理解码、几何属性填充等批量循环中调用——仓库内 EXRLoaderCurveModifier 等正是这么用的。
  5. 与 GPU 端的一致性:如需在着色器中做等价的 FP16 打包,TSL 已提供与 toHalfFloat 位模式一致的 packHalf2x16 实现(验证见 test/unit/addons/tsl/TSLPacking.tests.js),JS 侧与 GLSL 侧可以放心混用。

小结

DataUtils 是 three.js 中体量极小但位置关键的模块:两个静态方法 toHalfFloat / fromHalfFloat 构成了 JS 运行时与 FP16 世界之间的桥梁。其实现采用查表法完成指数偏置换算、次正规数补齐与尾数截断,边界行为(65504 上限、越界警告与钳制、Inf/NaN 映射)由 test/unit/src/extras/DataUtils.tests.js 完整锁定;而 Float16BufferAttribute、EXR/HDR 加载导出链、3DGS 格式解析与 TSL 环境采样等模块,则共同印证了它是处理半精度数据时首选且语义统一的工具。

相关文件索引

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