three.js DataUtils 详解:FP16 半精度浮点转换的实现原理与实战应用
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
};
注意该文件同时导出了 toHalfFloat、fromHalfFloat 两个自由函数与 DataUtils 类。这意味着除了常见的 DataUtils.toHalfFloat( val ) 调用方式外,three.js 内部也可以直接以具名函数的方式导入,例如 src/core/BufferAttribute.js#L5 中就是:
import { fromHalfFloat, toHalfFloat } from '../extras/DataUtils.js';
DataUtils 类本身则通过 src/Three.Core.js#L160 的 export { 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
Uint16ArraysinceFloat16Arraybrowser 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 ] );
}
这段代码是整个类中最关键的实现,可以拆成四步理解:
- 范围保护:FP16 能表示的最大有限值是 65504。若
|val| > 65504,会通过warn输出DataUtils.toHalfFloat(): Value out of range.警告,并将值钳制(clamp)到[-65504, 65504]区间。因此toHalfFloat( 100000 )不会得到Infinity,而是静默地转换为 65504 的编码。 - 位模式重解释:
_tables.floatView与_tables.uint32View是共享同一个 4 字节ArrayBuffer的两个视图(见 src/extras/DataUtils.js#L12-L14)。写入floatView[0]后再从uint32View[0]读出,就把一个 FP32 数值的 32 位 IEEE 位模式拿到了手中。 - 指数提取:
e = ( f >> 23 ) & 0x1ff取出 FP32 的 8 位原始指数,作为查表的索引。 - 查表拼装:
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 项的表,分别对指数的正负号槽位(i 与 i | 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 ];
}
流程比正向转换更简洁,只有三行核心逻辑:
m = val >> 10取出 16 位编码的高 10 位——符号位 + 5 位指数,作为三张表的索引;val & 0x3ff取出低 10 位尾数,与m组合后从mantissaTable(2048 项)中查得 FP32 的完整 23 位尾数(表内完成次正规数补齐:把缺失的隐含 1 左移到出现为止,见 src/extras/DataUtils.js#L78-L96 中的while ( ( m & 0x00800000 ) === 0 )循环);- 加上
exponentTable[m](把 FP16 偏置 15 的指数换算为 FP32 偏置 127 的指数位,并对 ±Inf/NaN 槽位给出0x47800000、0x80000000等特判值,见 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 是精确的最大有限值:
31743(0x7bff)解码恰为 65504,而下一个位模式31744(0x7c00)就是 +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/getW 与 setX/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 与探针类数据
- examples/jsm/loaders/KSPLATLoader.js#L439:从字节流中
view.getUint16( offset, true )读出小端 16 位整数后,return DataUtils.fromHalfFloat( ... )还原坐标分量——这是典型的「二进制格式解析 → 位模式 → JS number」三段式读取模板; - examples/jsm/loaders/SPZLoader.js#L483-L485:手动拼接字节
bytes[ rowOffset ] | ( bytes[ rowOffset + 1 ] << 8 )后再fromHalfFloat,处理 splat 点云中心坐标; - examples/jsm/lights/LightProbeGenerator.js#L226-L228:解码 SH 系数的 R/G/B 三通道;
- examples/jsm/lights/RectAreaLightTexturesLib.js#L61-L69:RectAreaLight 的 LTC 查找表以半精度存储;
- examples/jsm/modifiers/CurveModifier.js#L88-L91(及 GPU 版 CurveModifierGPU.js):将曲线变形中的顶点变换向量以
vec4<FP16>形式写入属性数据; - examples/jsm/tsl/display/ImportanceSampledEnvironment.js#L74-L219:TSL 环境采样表中方向与 PDF 数据的 FP16 编解码。
使用建议与注意事项
结合源码与测试,实际使用 DataUtils 时有以下几点应当留意:
- 返回值语义:
toHalfFloat返回的是 16 位位模式(一个 0–65535 范围内的整数),应写入Uint16Array或小端二进制字段;fromHalfFloat的入参同理。它不是返回一个精度降低的浮点数。 - 越界行为:
|val| > 65504会输出DataUtils.toHalfFloat(): Value out of range.警告并钳制到 ±65504。若你的数据(如 HDR 亮度)可能超界,建议参照HDRLoader的做法在调用前自行Math.min( val, 65504 ),以消除警告。 - 精度预算:FP16 的 10 位尾数在 1.0 附近只有约 0.0005 的量化步长(
π往返后为 3.140625);小于 2⁻²⁴ 的数会下冲为零;动态范围上限 65504。对法线、UV 这类[0,1]区间数据通常足够,但直接存储 HDR 峰值时需先缩放。 - 性能:转换本身是两次表查找加移位/加法的 O(1) 操作,且表在模块加载时一次性生成,因此可以放心在纹理解码、几何属性填充等批量循环中调用——仓库内
EXRLoader、CurveModifier等正是这么用的。 - 与 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 环境采样等模块,则共同印证了它是处理半精度数据时首选且语义统一的工具。
相关文件索引
- API 文档:docs/pages/DataUtils.html.md
- 核心实现:src/extras/DataUtils.js(表生成 L8-L141、
toHalfFloatL150-L161、fromHalfFloatL170-L176、类定义 L183-L211) - 核心导出:src/Three.Core.js#L160
- 属性集成:src/core/BufferAttribute.js(
Float16BufferAttributeL862-L1019) - 单元测试:test/unit/src/extras/DataUtils.tests.js、test/unit/addons/tsl/TSLPacking.tests.js
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00