简介
类型化数组是浏览器中相对较新的功能,它诞生于需要在 WebGL 中高效处理二进制数据的需求。类型化数组是一块内存,其中包含类型化的视图,这与 C 语言中数组的工作方式非常相似。由于类型化数组由原始内存支持,因此 JavaScript 引擎可以将内存直接传递给原生库,而无需费力地将数据转换为原生表示形式。因此,对于将数据传递到 WebGL 和其他处理二进制数据的 API,类型化数组的性能比 JavaScript 数组好得多。
类型化数组视图的作用类似于 ArrayBuffer 片段的单类型数组。所有常用的数字类型都有视图,名称具有自描述性,例如 Float32Array、Float64Array、Int32Array 和 Uint8Array。还有一种特殊的视图,它取代了 Canvas 的 ImageData 中的像素数组类型:Uint8ClampedArray。
DataView 是第二种类型的视图,它旨在处理异构数据。DataView 对象没有类似数组的 API,而是为您提供了一个 get/set 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,因此一旦您知道如何使用一种,您几乎就了解了如何使用所有类型化数组视图。在下一个示例中,我将创建当前存在的每种类型化数组视图中的一种。
// 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 BMP 文件格式曾经是 Windows 早期存储图像的标准格式。上面链接的文档清楚地表明,文件中所有的整数值都以小端序格式存储。以下是使用 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”是一个 ArrayBuffer,它刚刚通过 XMLHttpRequest 从服务器下载
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);
经验法则是:从 Web 服务器接收二进制数据后,使用 DataView 对其进行一次遍历。读取单个数值并将它们存储在其他数据结构中,可以是 JavaScript 对象(用于少量结构化数据)或类型化数组视图(用于大块数据)。这将确保您的代码在所有类型的 CPU 上都能正常工作。还要使用 DataView 将数据写入文件或网络,并确保适当地指定各种 set 方法的 littleEndian 参数,以便生成您正在创建或使用的文件格式。
请记住,通过网络传输的所有数据都隐式地具有格式和字节序(至少对于任何多字节值而言)。确保清楚地定义和记录您的应用程序通过网络发送的所有数据的格式。
使用类型化数组的浏览器 API
我将简要概述当前正在使用类型化数组的不同浏览器 API。当前包含 WebGL、Canvas、Web Audio API、XMLHttpRequests、WebSockets、Web Workers、Media Source API 和 File API。从 API 列表中可以看出,类型化数组非常适合性能敏感的多媒体工作以及以高效方式传递数据。
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 元素上像素的类型化数组表示形式。这很有帮助,因为现在您也可以创建和编辑 canvas 像素数组,而无需摆弄 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 获得该对象的所有权。这允许高度优化的实现,其中不复制发送的数据,而只是将类型化数组的所有权转移给接收者。
要将可转移对象与 Web Worker 一起使用,您需要在 worker 上使用 webkitPostMessage 方法。webkitPostMessage 方法的工作方式与 postMessage 完全相同,但它采用两个参数而不是一个。添加的第二个参数是您希望转移到 worker 的对象数组。
worker.webkitPostMessage(oneGBTypedArray, [oneGBTypedArray]);
要从 worker 取回对象,worker 可以以相同的方式将它们传递回主线程。
webkitPostMessage({results: grand, youCanHaveThisBack: oneGBTypedArray}, [oneGBTypedArray]);
零拷贝,哇!
媒体源 API
最近,媒体元素还在 媒体源 API 的形式中获得了一些类型化数组的优点。您可以使用 webkitSourceAppend 将包含视频数据的类型化数组直接传递到视频元素。这使得视频元素在现有视频之后附加视频数据。SourceAppend 非常适合执行插播广告、播放列表、流媒体以及您可能希望使用单个视频元素播放多个视频的其他用途。
video.webkitSourceAppend(uint8Array);
二进制 WebSockets
您还可以将类型化数组与 WebSockets 一起使用,以避免对所有数据进行字符串化。非常适合编写高效的协议并最大限度地减少网络流量。
socket.binaryType = 'arraybuffer';
唷!API 审查到此结束。让我们继续查看用于处理类型化数组的第三方库。
第三方库
jDataView
jDataView 为所有浏览器实现了 DataView shim。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% 的尺寸缩减。对于一个更极端的例子,我正在处理 Shapefile,它使用 64 位浮点数作为坐标,但我不需要精度,因为模型只会在屏幕尺寸上显示。切换到 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
]);
如您所见,结构定义是一个 [名称、类型] 对的平面数组。嵌套结构是通过为类型使用数组来完成的。数组通过使用一个三元素数组来定义,其中第二个元素是数组元素类型,第三个元素是数组长度(可以是数字、对先前读取字段的引用或回调函数)。数组定义的第一个元素未使用。
类型的可能值如下
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 中,数据始终具有指定的字节序,并且可能未对齐以实现最佳性能。
内存中数据组装(使用类型化数组视图)和 I/O(使用 DataView)之间的设计拆分是有意识的。现代 JavaScript 引擎对类型化数组视图进行了大量优化,并在使用它们进行数值运算时实现了高性能。类型化数组视图的当前性能水平是通过此设计决策实现的。