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

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.

要将类型化数组复制到另一个类型化数组,最快的方法是使用类型化数组设置方法。如需使用类似 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

要使用包含异构类型数据的 ArrayBuffers,最简单的方法是对缓冲区使用 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 的速度。当您将对象作为 Transferable 发送给 worker 时,该对象在发送线程中将无法访问,并且接收 Worker 会获得该对象的所有权。这可实现高度优化的实现,其中不复制发送的数据,只将类型化数组的所有权转移给接收器。

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

worker.webkitPostMessage(oneGBTypedArray, [oneGBTypedArray]);

为了从 worker 获取对象, worker 可以以相同的方式将它们传回主线程。

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

没有副本,哇!

Media Source API

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

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 实现了提议的类型化数组字符串编码规范,因此也是了解即将推出的功能的好方法。

下面是 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-view 以访问缓冲区。我们还针对其他整数和浮点大小以及有符号/无符号变体添加了类型。

设计考虑事项

从一开始,类型化数组的设计是由于需要高效地将二进制数据传递到原生库。因此,类型化数组视图会针对主机 CPU 的原生字节序中对齐的数据执行操作。这些决策使得 JavaScript 在操作期间(例如将顶点数据发送到显卡)达到最高性能。

DataView 专为文件和网络 I/O 而设计,在这种模式下,数据始终具有指定字节序,并且可能不一致,无法实现最佳性能。

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

参考