类型化数组 - 浏览器中的二进制数据

Ilmari Heikkinen

简介

类型化数组是浏览器最近增加的功能,出于对 WebGL 中二进制数据处理的高效处理需求。类型化数组是内存块,其中包含一个类型化视图,类似于数组在 C 语言中的工作方式。由于类型化数组由原始内存提供支持,因此 JavaScript 引擎可以直接将内存传递给原生库,而无需费心将数据转换为原生表示法。因此,在将数据传递给 WebGL 和其他处理二进制数据的 API 时,类型化数组的性能要比 JavaScript 数组高得多。

类型化数组视图对 ArrayBuffer 的某个片段的行为类似于单类型数组。所有常见数字类型都有视图,这些视图具有自描述性名称,例如 Float32Array、Float64Array、Int32Array 和 Uint8Array。还有一种特殊的视图取代了 Canvas 的 ImageData 中的像素数组类型:Uint8ClampedArray。

DataView 是第二种类型的视图,用于处理异构数据。DataView 对象提供的是 get/set API,而不是数组类似的 API,可用于在任意字节偏移处读取和写入任意数据类型。DataView 非常适合读取和写入文件头以及其他类似结构体的数据。

使用类型化数组的基础知识

类型化数组视图

如需使用类型化数组,您需要创建一个 ArrayBuffer 和对应的视图。最简单的方法是创建所需大小和类型的类型化数组视图。

// Typed array views work pretty much like normal arrays.
var f64a = new Float64Array(8);
f64a[0] = 10;
f64a[1] = 20;
f64a[2] = f64a[0] + f64a[1];

类型化数组视图有多种不同的类型。它们都使用相同的 API,因此只要您知道如何使用其中一种,就几乎知道如何使用所有这些 API。在下一个示例中,我将创建目前存在的每种类型的数组视图各一个。

// Floating point arrays.
var f64 = new Float64Array(8);
var f32 = new Float32Array(16);

// Signed integer arrays.
var i32 = new Int32Array(16);
var i16 = new Int16Array(32);
var i8 = new Int8Array(64);

// Unsigned integer arrays.
var u32 = new Uint32Array(16);
var u16 = new Uint16Array(32);
var u8 = new Uint8Array(64);
var pixels = new Uint8ClampedArray(64);

最后一个有点特殊,它将输入值限制在 0 到 255 之间。这对于 Canvas 图片处理算法尤其有用,因为现在您不必手动对图片处理数学运算进行限制,以免溢出 8 位范围。

例如,下面展示了如何将伽玛系数应用于存储在 Uint8Array 中的图片。不太美观:

u8[i] = Math.min(255, Math.max(0, u8[i] * gamma));

使用 Uint8ClampedArray 时,您可以跳过手动限制:

pixels[i] *= gamma;

创建类型化数组视图的另一种方法是,先创建 ArrayBuffer,然后创建指向它的视图。用于获取外部数据的 API 通常处理 ArrayBuffer,因此您可以通过这种方式获取对这些数据的类型化数组视图。

var ab = new ArrayBuffer(256); // 256-byte ArrayBuffer.
var faFull = new Uint8Array(ab);
var faFirstHalf = new Uint8Array(ab, 0, 128);
var faThirdQuarter = new Uint8Array(ab, 128, 64);
var faRest = new Uint8Array(ab, 192);

您也可以为同一 ArrayBuffer 拥有多个视图。

var fa = new Float32Array(64);
var ba = new Uint8Array(fa.buffer, 0, Float32Array.BYTES_PER_ELEMENT); // First float of fa.

如需将类型化数组复制到另一个类型化数组,最快的方法是使用类型化数组 set 方法。如需使用类似 memcpy 的用法,请在视图的缓冲区中创建 Uint8Array,然后使用 set 来复制数据。

function memcpy(dst, dstOffset, src, srcOffset, length) {
  var dstU8 = new Uint8Array(dst, dstOffset, length);
  var srcU8 = new Uint8Array(src, srcOffset, length);
  dstU8.set(srcU8);
};

DataView

如需使用包含异构类型数据的 ArrayBuffer,最简单的方法是将 DataView 用于缓冲区。假设我们有一种文件格式,它的标头包含一个 8 位无符号整数,后跟两个 16 位整数,再跟一个 32 位浮点数的有效负载数组。虽然可以使用类型化数组视图读回此数据,但会比较麻烦。借助 DataView,我们可以读取标头,并对浮点数数组使用类型化数组视图。

var dv = new DataView(buffer);
var vector_length = dv.getUint8(0);
var width = dv.getUint16(1); // 0+uint8 = 1 bytes offset
var height = dv.getUint16(3); // 0+uint8+uint16 = 3 bytes offset
var vectors = new Float32Array(width*height*vector_length);
for (var i=0, off=5; i<vectors.length; i++, off+=4) {
  vectors[i] = dv.getFloat32(off);
}

在上面的示例中,我读取的所有值都是大端序。如果缓冲区中的值是小端格式,您可以将可选的 littleEndian 参数传递给 getter:

...
var width = dv.getUint16(1, true);
var height = dv.getUint16(3, true);
...
vectors[i] = dv.getFloat32(off, true);
...

请注意,类型化数组视图始终采用原生字节顺序。这是为了加快速度。您应该使用 DataView 来读取和写入会出现字节顺序问题的数据。

DataView 还提供用于将值写入缓冲区的方法。这些 setter 的命名方式与 getter 相同,即“set”后跟数据类型。

dv.setInt32(0, 25, false); // set big-endian int32 at byte offset 0 to 25
dv.setInt32(4, 25); // set big-endian int32 at byte offset 4 to 25
dv.setFloat32(8, 2.5, true); // set little-endian float32 at byte offset 8 to 2.5

字节序讨论

字节序(也称为字节顺序)是指多字节数值在计算机内存中的存储顺序。大端字节序一词描述的是先存储最高有效字节的 CPU 架构;小端字节序则是先存储最低有效字节的架构。给定 CPU 架构中使用哪种字节序完全是任意的;选择任一字节序都有充分的理由。事实上,一些 CPU 可以配置为同时支持大端序和小端字节序数据。

为什么需要关注字节序?原因很简单。从磁盘或网络读取或写入数据时,必须指定数据的字节序。这样可确保无论处理数据的 CPU 是哪种字节序,数据都能正确解读。在日益网络化的时代,必须妥善支持所有可能需要处理来自服务器或网络上其他对等方的二进制数据的各种设备(无论是大端序还是小端序)。

DataView 接口专门用于在文件和网络之间读取和写入数据。DataView 对具有指定字节序的数据进行操作。必须在每次访问每个值时指定字节序(大端还是小端),以确保无论浏览器所运行 CPU 的字节序如何,您在读取或写入二进制数据时都能获得一致且正确的结果。

通常,当您的应用从服务器读取二进制数据时,您需要扫描一次这些数据,以便将其转换为您的应用在内部使用的数据结构。在此阶段应使用 DataView。不建议将多字节类型数组视图(Int16Array、Uint16Array 等)直接与通过 XMLHttpRequest、FileReader 或任何其他输入/输出 API 提取的数据搭配使用,因为类型数组视图使用的是 CPU 的原生字节序。稍后我们会详细介绍这部分内容。

我们来看几个简单的示例。在 Windows 的早期,Windows BMP 文件格式曾是存储图片的标准格式。上面链接的文档明确指出,该文件中的所有整数值均以小端格式存储。以下代码段使用本文随附的 DataStream.js 库解析 BMP 标头的开头部分:

function parseBMP(arrayBuffer) {
  var stream = new DataStream(arrayBuffer, 0,
    DataStream.LITTLE_ENDIAN);
  var header = stream.readUint8Array(2);
  var fileSize = stream.readUint32();
  // Skip the next two 16-bit integers
  stream.readUint16();
  stream.readUint16();
  var pixelOffset = stream.readUint32();
  // Now parse the DIB header
  var dibHeaderSize = stream.readUint32();
  var imageWidth = stream.readInt32();
  var imageHeight = stream.readInt32();
  // ...
}

下面是另一个示例,此示例来自 WebGL 示例项目中的高动态范围渲染演示。此演示会下载表示高动态范围纹理的原始小端浮点数据,并需要将其上传到 WebGL。以下代码段可正确解释所有 CPU 架构上的浮点值。假设变量“arrayBuffer”是刚刚通过 XMLHttpRequest 从服务器下载的 ArrayBuffer:

var arrayBuffer = ...;
var data = new DataView(arrayBuffer);
var tempArray = new Float32Array(
  data.byteLength / Float32Array.BYTES_PER_ELEMENT);
var len = tempArray.length;
// Incoming data is raw floating point values
// with little-endian byte ordering.
for (var jj = 0; jj < len; ++jj) {
  tempArray[jj] =
    data.getFloat32(jj * Float32Array.BYTES_PER_ELEMENT, true);
}
gl.texImage2D(...other arguments...,
  gl.RGB, gl.FLOAT, tempArray);

经验法则是:从网络服务器接收二进制数据后,使用 DataView 对其进行一次遍历。读取各个数值并将其存储在一些其他数据结构中,可以是 JavaScript 对象(适用于少量结构化数据)或类型化数组视图(适用于大数据块)。这将确保您的代码在所有类型的 CPU 上正常运行。此外,还可以使用 DataView 将数据写入文件或网络,并确保为各种 set 方法适当地指定 littleEndian 参数,以生成您要创建或使用的文件格式。

请注意,通过网络传输的所有数据都隐式具有格式和字节序(至少对于任何多字节值而言)。请务必明确定义并记录应用通过网络发送的所有数据的格式。

使用类型化数组的浏览器 API

我将简要介绍目前正在使用类型化数组的不同浏览器 API。目前的选段包括 WebGL、Canvas、Web Audio API、XMLHttpRequest、WebSocket、Web Worker、Media Source API 和 File API。从 API 列表中,您可以看到,类型化数组非常适合对性能敏感的多媒体工作以及高效传递数据。

WebGL

类型化数组的首次使用是在 WebGL 中,在 WebGL 中,它用于传递缓冲区数据和图片数据。要设置 WebGL 缓冲区对象的内容,可以对类型化数组使用 gl.bufferData() 调用。

var floatArray = new Float32Array([1,2,3,4,5,6,7,8]);
gl.bufferData(gl.ARRAY_BUFFER, floatArray);

类型化数组还用于传递纹理数据。下面是一个使用类型化数组传入纹理内容的基本示例。

var pixels = new Uint8Array(16*16*4); // 16x16 RGBA image
gl.texImage2D(
  gl.TEXTURE_2D, // target
  0, // mip level
  gl.RGBA, // internal format
  16, 16, // width and height
  0, // border
  gl.RGBA, //format
  gl.UNSIGNED_BYTE, // type
  pixels // texture data
);

您还需要使用类型化数组从 WebGL 上下文读取像素。

var pixels = new Uint8Array(320*240*4); // 320x240 RGBA image
gl.readPixels(0, 0, 320, 240, gl.RGBA, gl.UNSIGNED_BYTE, pixels);

Canvas 2D

Canvas ImageData 对象最近进行了改进,可与类型化数组规范搭配使用。现在,您可以获取 Canvas 元素上像素的类型化数组表示法。这非常有用,因为现在您还可以创建和修改画布像素数组,而无需摆弄画布元素。

var imageData = ctx.getImageData(0,0, 200, 100);
var typedArray = imageData.data // data is a Uint8ClampedArray

XMLHttpRequest2

XMLHttpRequest 获得了类型化数组增强功能,现在您可以接收类型化数组响应,而无需将 JavaScript 字符串解析为类型化数组。这非常适用于直接将提取的数据传递给多媒体 API,以及解析从网络提取的二进制文件。

您只需将 XMLHttpRequest 对象的 responseType 设置为“arraybuffer”。

xhr.responseType = 'arraybuffer';

请注意,从网络下载数据时,您必须注意字节序问题!请参阅上文中关于字节序的部分。

文件 API

FileReader 可以将文件内容读取为 ArrayBuffer。然后,您可以将类型化数组视图和 DataView 附加到缓冲区,以操纵其内容。

reader.readAsArrayBuffer(file);

您还应注意字节序。如需了解详情,请参阅“字节序”部分。

可转移的对象

postMessage 中的可传输对象可大大加快将二进制数据传递给其他窗口和 Web Worker 的速度。将对象作为可传输对象发送给 Worker 后,发送线程中将无法访问该对象,并且接收 Worker 会获得该对象的所有权。这样可以实现高度优化的实现,其中不会复制发送的数据,只会将类型化数组的所有权转移给接收器。

若要将 Transferable 对象与 Web Worker 搭配使用,您需要在 worker 上使用 webkitPostMessage 方法。webkitPostMessage 方法的运作方式与 postMessage 相同,但它接受两个参数,而不是一个。添加的第二个参数是您希望传输到 worker 的对象数组。

worker.webkitPostMessage(oneGBTypedArray, [oneGBTypedArray]);

如需从工作器中取回对象,工作器可以以相同的方式将其传回主线程。

webkitPostMessage({results: grand, youCanHaveThisBack: oneGBTypedArray}, [oneGBTypedArray]);

零副本,太棒了!

Media Source API

最近,媒体元素还获得了一些 Media Source API 形式的类型化数组优势。您可以使用 webkitSourceAppend 直接将包含视频数据的类型化数组传递给视频元素。这样,视频元素就会将视频数据附加到现有视频之后。SourceAppend 非常适合制作插页广告、播放列表、在线播放等内容,在这些内容中,您可能希望使用单个视频元素播放多个视频。

video.webkitSourceAppend(uint8Array);

二进制 WebSocket

您还可以将类型化数组与 WebSocket 搭配使用,而不必对所有数据进行字符串化处理。非常适合编写高效协议和最大限度减少网络流量。

socket.binaryType = 'arraybuffer';

呼!至此,API 审核已完成。接下来,我们来看看用于处理类型化数组的第三方库。

第三方库

jDataView

jDataView 为所有浏览器实现了 DataView 补丁。DataView 以前仅适用于 WebKit,但现在大多数其他浏览器都支持它。Mozilla 开发者团队也在发布补丁,以便在 Firefox 上启用 DataView。

Chrome 开发者关系团队的 Eric Bidelman 编写了一个使用 jDataView 的小型 MP3 ID3 标签阅读器示例。以下是该博文中的用法示例:

var dv = new jDataView(arraybuffer);

// "TAG" starts at byte -128 from EOF.
// See http://en.wikipedia.org/wiki/ID3
if (dv.getString(3, dv.byteLength - 128) == 'TAG') {
  var title = dv.getString(30, dv.tell());
  var artist = dv.getString(30, dv.tell());
  var album = dv.getString(30, dv.tell());
  var year = dv.getString(4, dv.tell());
} else {
  // no ID3v1 data found.
}

stringencoding

目前,在类型化数组中处理字符串有点麻烦,但有 stringencoding 库可以帮助您解决此问题。Stringencoding 实现了提议的类型化数组字符串编码规范,因此也是了解即将推出的功能的好方法。

以下是字符串编码的基本用法示例:

var uint8array = new TextEncoder(encoding).encode(string);
var string = new TextDecoder(encoding).decode(uint8array);

BitView.js

我为类型化数组编写了一个名为 BitView.js 的小型操作库。顾名思义,它与 DataView 非常相似,只不过它使用的是位。借助 BitView,您可以获取和设置 ArrayBuffer 中给定位偏移量处的位值。BitView 还提供了在任意位偏移处存储和加载 6 位和 12 位整数的方法。

12 位整数非常适合处理屏幕坐标,因为显示屏的长度通常不超过 4096 像素。通过使用 12 位整数而不是 32 位整数,大小可以缩减 62%。举个更极端的例子,我使用的是使用 64 位浮点数作为坐标的 Shapefile,但我不需要这种精度,因为模型只会以屏幕大小显示。改用 12 位基准坐标和 6 位增量来编码与上一个坐标的变化,使文件大小缩减到了原来的十分之一。您可以点击此处查看演示。

下面是一个使用 BitView.js 的示例:

var bv = new BitView(arrayBuffer);
bv.setBit(4, 1); // Set fourth bit of arrayBuffer to 1.
bv.getBit(17); // Get 17th bit of arrayBuffer.

bv.getBit(50*8 + 3); // Get third bit of 50th byte in arrayBuffer.

bv.setInt6(3, 18); // Write 18 as a 6-bit int to bit position 3 in arrayBuffer.
bv.getInt12(9); // Read a 12-bit int from bit position 9 in arrayBuffer.

DataStream.js

类型化数组最令人兴奋的一点是,它们可以让您更轻松地在 JavaScript 中处理二进制文件。现在,您可以使用 XMLHttpRequest 获取 ArrayBuffer 并直接使用 DataView 对其进行处理,而无需逐一解析字符串并手动将字符转换为二进制数。这样,您就可以轻松加载 MP3 文件并读取元数据标记,以便在音频播放器中使用。或者加载 Shapefile 并将其转换为 WebGL 模型。或者读取 JPEG 格式的 EXIF 标签,并将其显示在幻灯片应用中。

ArrayBuffer XHR 的问题在于,从缓冲区读取结构体类数据有点麻烦。DataView 适用于以大小端安全的方式一次读取多个数字,类型化数组视图适用于读取元素大小对齐的原生大小端数字数组。我们缺少的是一种以方便且有大小端安全的方式读取数据数组和结构体的方法。输入 DataStream.js。

DataStream.js 是一个类型化数组库,可以文件方式读取和写入 ArrayBuffer 中的数据标量、字符串、数组和结构体。

从 ArrayBuffer 读取浮点数数组的示例:

// without DataStream.js
var dv = new DataView(buffer);
var f32 = new Float32Array(buffer.byteLength / 4);
var littleEndian = true;
for (var i = 0; i<f32.length; i++) {
  f32[i] = dv.getFloat32(i*4, littleEndian);
}

// with DataStream.js
var ds = new DataStream(buffer);
ds.endianness = DataStream.LITTLE_ENDIAN;
var f32 = ds.readFloat32Array(ds.byteLength / 4);

DataStream.js 最实用的用途是读取更复杂的数据。假设您有一个用于读取 JPEG 标记的方法:

// without DataStream.js
var dv = new DataView(buffer);
var objs = [];
for (var i=0; i<buffer.byteLength;) {
  var obj = {};
  obj.tag = dv.getUint16(i);
  i += 2;
  obj.length = dv.getUint16(i);
  i += 2;
  obj.data = new Uint8Array(obj.length - 2);
  for (var j=0; j<obj.data.length; j++,i++) {
    obj.data[j] = dv.getUint8(i);
  }
  objs.push(obj);
}

// with DataStream.js
var ds = new DataStream(buffer);
ds.endianness = ds.BIG_ENDIAN;
var objs = [];
while (!ds.isEof()) {
  var obj = {};
  obj.tag = ds.readUint16();
  obj.length = ds.readUint16();
  obj.data = ds.readUint8Array(obj.length - 2);
  objs.push(obj);
}

或者,使用 DataStream.readStruct 方法读取数据结构。readStruct 方法接受一个结构体定义数组,其中包含结构体成员的类型。它包含用于处理复杂类型的回调函数,还可以处理数据数组和嵌套结构体:

// with DataStream.readStruct
ds.readStruct([
  'objs', ['[]', [ // objs: array of tag,length,data structs
    'tag', 'uint16',
    'length', 'uint16',
    'data', ['[]', 'uint8', function(s,ds){ return s.length - 2; }], // get length with a function
  '*'] // read in as many struct as there are
]);

如您所见,结构体定义是 [name, type] 对的平面数组。嵌套结构体是通过为类型提供数组来实现的。数组是使用三个元素的数组进行定义的,其中第二个元素是数组元素类型,第三个元素是数组长度(作为数字、对之前读取的字段的引用或作为回调函数)。数组定义的第一个元素未使用。

类型的可能值如下:

Number types

Unsuffixed number types use DataStream endianness.
To explicitly specify endianness, suffix the type with
'le' for little-endian or 'be' for big-endian,
e.g. 'int32be' for big-endian int32.

  'uint8' -- 8-bit unsigned int
  'uint16' -- 16-bit unsigned int
  'uint32' -- 32-bit unsigned int
  'int8' -- 8-bit int
  'int16' -- 16-bit int
  'int32' -- 32-bit int
  'float32' -- 32-bit float
  'float64' -- 64-bit float

String types

  'cstring' -- ASCII string terminated by a zero byte.
  'string:N' -- ASCII string of length N.
  'string,CHARSET:N' -- String of byteLength N encoded with given CHARSET.
  'u16string:N' -- UCS-2 string of length N in DataStream endianness.
  'u16stringle:N' -- UCS-2 string of length N in little-endian.
  'u16stringbe:N' -- UCS-2 string of length N in big-endian.

Complex types

  [name, type, name_2, type_2, ..., name_N, type_N] -- Struct

  function(dataStream, struct) {} -- Callback function to read and return data.

  {get: function(dataStream, struct) {}, set: function(dataStream, struct) {}}
  -- Getter/setter functions to reading and writing data. Handy for using the
     same struct definition for both reading and writing.

  ['', type, length] -- Array of given type and length. The length can be either
                        a number, a string that references a previously-read
                        field, or a callback function(struct, dataStream, type){}.
                        If length is set to '*', elements are read from the
                        DataStream until a read fails.

您可以点击此处查看读取 JPEG 元数据的实例。该演示使用 DataStream.js 读取 JPEG 文件的标记级结构(以及一些 EXIF 解析),并使用 jpg.js 在 JavaScript 中解码和显示 JPEG 图片。

类型化数组的历史

在 WebGL 的早期实现阶段,我们发现将 JavaScript 数组传递给图形驱动程序会导致性能问题,因此开始使用类型化数组。使用 JavaScript 数组时,WebGL 绑定必须分配原生数组,并通过遍历 JavaScript 数组填充该数组,并将该数组中的每个 JavaScript 对象转换为所需的原生类型。

为了解决数据转换瓶颈问题,Mozilla 的 Vladimir Vukicevic 编写了 CanvasFloatArray:一个具有 JavaScript 接口的 C 风格浮点数组。现在,您可以在 JavaScript 中修改 CanvasFloatArray 并将其直接传递给 WebGL,而不必在绑定中进行任何额外的工作。在后续迭代中,CanvasFloatArray 重命名为 WebGLFloatArray,后者又重命名为 Float32Array,并拆分为后备 ArrayBuffer 和类型化 Float32Array 视图以访问缓冲区。我们还针对其他整数和浮点大小以及有符号/无符号变体添加了类型。

设计考虑事项

从一开始,类型化数组的设计就旨在高效地将二进制数据传递给原生库。因此,类型化数组视图会对主机 CPU 的原生字节序中的对齐数据进行操作。这些决策使 JavaScript 能够在向显卡发送顶点数据等操作期间达到最高性能。

DataView 专为文件和网络 I/O 而设计,其中数据始终具有指定的字节序,并且可能未对齐以实现最佳性能。

我们有意识地在内存中数据汇编(使用类型化数组视图)和 I/O(使用 DataView)之间进行了设计分离。现代 JavaScript 引擎对类型化数组视图进行了大幅优化,并通过这些视图实现较高的数值运算性能。正是这一设计决策,才让类型化数组视图能够达到当前的性能水平。

参考