WebGL Tutorial
and more

ArrayBuffer

撰写时间:2023-08-06

修订时间:2023-08-06

VBO中存储的数据类型为ArrayBufferView,它是ArrayBuffer的视图。在本章中,我们探索这些类的具体内容。

ArrayBuffer

ArrayBuffer是一个通用的、固定长度的二进制缓冲区。它是由多个字节的数据所组成的数组。

const buffer = new ArrayBuffer(); console.log(buffer.byteLength); // 0 bytes

ArrayBuffer有一个byteLength属性(length, counted by bytes,以字节为单位的长度),代表该对象的字节长度,即多少字节。上面的构造函数,创建了一个字节长度为0的实例。

我们可以在构造函数中传入一个字节长度,以创建一个特定长度的实例。其数据被初始化为相应的0值。

const buffer = new ArrayBuffer(2); console.log(buffer.byteLength); // 2 bytes

视图

不能直接访问ArrayBuffer的中的数据。因此,如果我们试图查看其内容,将显示undefined

const buffer = new ArrayBuffer(2); console.log(buffer[0]); // undefined console.log(buffer[1]); // undefined

在Chrome浏览器中运行以下代码:

const buffer = new ArrayBuffer(2); console.dir(buffer);

将在浏览器的Console中输出:

ArrayBuffer(2) Int8Array(2) UInt8Array(2) Int16Array(1)

可以看到,对于2个字节的ArrayBuffer,可以由2个Int8Array构成、或2个Uint8Array构成、或1个Int16Array构成。这类似于C语言中使用一个char指针指向一个int的数据类型,以便独立地访问int变量中的每个字节。

我们将上面的Int8ArrayUint8ArrayInt16Array这些类型化数组称为ArrayBuffer视图view)。也就是说,对于同一个数据,可以根据需求,使用不同的视图将其内在数据转换为不同的数据类型,并依视图的格式进行读写。

注:还有另外一种称为DataView的视图,提供了以特定字长读取或设置缓冲区数据的方法,但由于这种需求较少,故本章不涉及该类的内容。

下面,我们通过类型化数组Uint8Array来访问ArrayBuffer

const buffer = new ArrayBuffer(2); const view = new Uint8Array(buffer); console.log(view);

显示:

0: 0 1: 0 buffer: ArrayBuffer(2) byteLength: 2 byteOffset: 0 length: 2

我们取Uint8Array为视图。由于buffer的字长为2,而一个Uint8Array的字长为1,因此buffer的内容依此视图被分为元素为[0, 1]两个元素的数组。byteOffset为0,表示当前指针前向第0个数组元素;length为2,对应于view这个数组共有2个元素。

类型化数组

类型化数组 (typed array) 是一种类似于数组的视图,用于表现其所包含的二进制的数据缓冲区。

类型化数组的本质

类型化数组本质上是数组,因此可以正常访问数组中的数据。

let a = new Int8Array(5); console.log(a); // Int8Array[0, 0, 0, 0, 0] a[0] = 3; a[3] = 5; console.log(a); // Int8Array[3, 0, 0, 5, 0]

除了直接读写类型化数组的数据之外,对于普通数组的特性、方法,类型化数组也都支持。

let a = new Int8Array(5); console.log(a.length); // 5 a[3] = 15; a.forEach((element) => { console.log(element); // [0, 0, 0, 15, 0] }); let b = a.slice(0, 4); console.log(b); // [0, 0, 0, 15]

视图的含义

上面谈到,我们不能直接访问ArrayBuffer的数据,但如果我们使用类型化数组作为ArrayBuffer的视图,则可以通过类型化数组直接访问ArrayBuffer中的数据。

多视图的问题

但通过类型化数组来访问ArrayBuffer,就意味着我们即可以通过诸如8位的类型化数组来访问,也应允许通过诸如16位的类型化数组来访问。这就是视图的含义:内容相同,但观察、解释同一内容的视图可能不一样。更进一步,不同的视图,导致对相同内容可能有不同的解释。这一部分稍微有些麻烦,我们一步步来探索。

const buffer = new ArrayBuffer(2); let view1 = new Uint8Array(buffer); view1[0] = 1; view1[1] = 2; console.log(view1);

创建了一个2字节的缓冲区buffer。然后,使用Uint8Array的类型化数组作为其视图。这样,我们就可以访问及修改其数据。

接下来,将索引位置为0的元素的数值设置为0,将索引位置为1的元素的数值设置为2,然后查看ArrayBuffer的情况。显示:

Uint8Array [1, 2]

表示,对于Uint8Array数组来讲,其元素值依序分别为1, 2

而因为Uint8ArrayArrayBuffer的视图,也意味着ArrayBuffer在内存的数据也是依序分别为1, 2

现在,使用Uint16Array作为其视图。

let view2 = new Uint16Array(buffer); console.log(view2);

显示:

Uint16Array [513]

因为Uint16Array字长为2字节,因此,此视图下,ArrayBuffer只有1个字长为2字节的数组元素,其数值为513

现在,问题出现了。第一个问题,Uint16Array字长为2,Uint8Array字长为1,JavaScript引擎在内部是否将Uint8Array的第1个字节通过在前面补0的方式,扩展为2字节的存储空间?如果是这样,因为ArrayBuffer只有2字节,则Uint8Array的第2个元素的值将因为无剩余的空间存储而被丢弃掉。并且,view2的唯一一个元素的值应为1而不是513

因此,JavaScript引擎应当没有扩展第一个元素的值而抛弃第二个元素的值。那为何该值是奇怪的513?

查看二进制

前面说过,ArrayBuffer是一个通用的二进制数据的缓冲区。要解决该问题,须查看其二进制内存来寻找答案。为此,我编写了一个返回二进制值的函数:

function getBinStr(num, splitNum) { let binStr = num.toString(2); let padFieldWidth; if (binStr.length <= 8) { padFieldWidth = 8; } else { // asscend to 16 bits each time to be good alignment with 32, 64, etc padFieldWidth = 16; while(padFieldWidth < binStr.length) { padFieldWidth += 16; } } let padStr = binStr.padStart(padFieldWidth, '0'); if (splitNum >= padFieldWidth) { return padStr; } let slicedArr = []; let left = padStr; while (left.length > splitNum) { slicedArr.push(left.slice(-splitNum)); left = left.slice(0, -splitNum); } if (left) { slicedArr.push(left); } slicedArr.reverse(); return slicedArr.join(" "); }

使用方法如下:

let binStr = getBinStr(5, 4); console.log(binStr);

第一个参数num为要查看的整数,第二个参数splitNum为每隔多少个数位来插入空格。因此,上面代码的意思为,打印整数5的二进制,每4位以空格隔开。

显示:

0000 0101

表示,十进制的整数5的二进制为0000 0101

大尾小尾问题

现在,回到上面的问题。先打印view1两个字节的二进制内容:

let binStr1 = getBinStr(view1[0], 8); let binStr2 = getBinStr(view1[1], 8); console.log(binStr1 + ' ' + binStr2);

显示:

00000001 00000010

view1是以最原始的1个字节为单位来排列的,其顺序也正是ArrayBuffer内存的真实写照。这两个二进制,对应于Uint8Array中的十进制数值12

再打印view2两个字节的二进制内容:

let binStr3 = getBinStr(view2[0], 8); console.log(binStr3);

显示:

00000010 00000001

比较这两组二进制数,我们发现,第一个字节与第二个字节交换了位置。而二进制00000010 00000001正对应于十进制的513

我们也发现,JavaScript引擎并没有擅自扩展第一个字节且丢弃第二个字节。实际上,如前所述,view1的内容是ArrayBuffer内存的真实写照,JavaScript引擎根本没有改变其存储顺序。是视图解释的原因,导致了JavaScript引擎将前后两个字节00000001 00000010解释为00000010 00000001

当数据类型有2个或2个以上的字节时,哪个字节表示哪个数位,依赖于系统所采用的小尾little-endian)表示法还是大尾big-endian)表示法,是系统据以解释内存的数值的依据。小尾是指在内存存储地址中,从前到后,借十进制的表达,依次为个位、十位、百位...等数位的顺序,即先是小数位,再到大数位;而大尾是指在内存存储地址中,从前到后,借十进制的表达,依次为...百位、十位、个位等数位的顺序,即先是大数位,再到小数位。

例如,对于0x12345678这个数据,如果采用小尾表示方法,则存储顺序为0x78 0x56 0x34 0x12;如果采用大尾表示方法,则存储顺序为0x12 0x34 0x56 0x78

可见,我的系统对于存储顺序为00000001 00000010的内存数据,JavaScript引擎依据小尾原则,最终解释为00000010 00000001的值。

这就是为何我们使用Uint8Array来存储值为12的两个字节的数值,使用Uint16Array视图后,变成了513的内在原因。

当然,对于不同数据类型的值的转换,不管使用小尾还是大尾,系统都有一套衡定的算法,我们不必过于操心。本节对于像我们一样普通的程序来讲,只要清楚这一点就行了:换用不同类型的类型化数组视图,与原来视图所表示的值相比,值可能会发生较大的改变;它不是简单的数位扩展及数位补0的问题。

构造函数

类型化数组共有4种构造函数。

指定元素个数的构造函数

在构造函数中传入一个整数,以指定数组的元素个数。

let i8Arr = new Int8Array(8); // length: how many elements console.log(i8Arr.length); // 8 elements console.log(i8Arr.BYTES_PER_ELEMENT); // 1 bytes per element console.log(i8Arr.byteLength); // total 1 * 8 = 8 bytes let i16Arr = new Int16Array(8); // length: how many elements console.log(i16Arr.length); // 8 elements console.log(i16Arr.BYTES_PER_ELEMENT); // 2 bytes per element console.log(i16Arr.byteLength); // total 2 * 8 = 16 bytes

注意细微的区别。ArrayBuffer的构造器参数表示字节数,而类型化数组构造器的参数表示元素个数。这是因为ArrayBuffer没有涉及具体的数据类型,无法表示共有多少个特定类型的元素,因此只能用总共的字节数来表示。而对于每种类型化数组,其属性BYTES_PER_ELEMENT已含有每个元素多少字节的信息,因此,只要指定共有多少个元素,就能计算出总的字节长度。

上面的代码new Int8Array(8)创建了一个共有8个元素的Int8Array数组,每个元素的字长为1字节,因此其byteLength等于1 * 8 = 8个字节。

代码new Int16Array(8)创建了一个共有8个元素的Int16Array数组,每个元素的字长为2字节,因此其byteLength等于2 * 8 = 16个字节。

指定数组数据的构造函数

在构造函数中传入一个数组,以作为类型化数组的内容。

const array = new Float32Array([ 0.0, 0.5, 0.0, -0.5, -0.5, 0.0, 0.5, -0.5, 0.0 ]);

我们之前的例子使用最多的就是这种方式。

指定其他类型化数组的构造函数

在构造函数中传入另一个类型化数组。

let a = new Int8Array(4); let b = new Int16Array(a); console.log(a.length); // 4 console.log(a.BYTES_PER_ELEMENT); // 1 console.log(a.byteLength); // 4 console.log(b.length); // 4 console.log(b.BYTES_PER_ELEMENT); // 2 console.log(b.byteLength); // 8

对于这种方式,两个类型化数组的元素数量及其数据一致,但由于各自的BYTES_PER_ELEMENT不一样,导致最后的byteLength不一样。

指定ArrayBuffer的构造函数

const buffer = new ArrayBuffer(2); let view1 = new Uint8Array(buffer); let view2 = new Uint16Array(buffer);

具体详见上面多视图的问题一节。

各种类型化数组

类型化数组共有11种,详见下表。

类名值域字长
Int8Array[-128, 127]1
Uint8Array[0, 255]1
Uint8ClampedArray[0, 255]1
Int16Array[-32768, 32767]2
Uint16Array[0, 65535]2
Int32Array[-2147483648, 2147483647]4
Uint32Array[0, 4294967295]4
Float32Array[-3.4E38, 3.4E38]4
Float64Array[-1.8E308, 1.8E308]8
BigInt64Array[-263, 263-1]8
BigUint64Array[0, 264-1]8

从值域来看,WebGL应用中,对于表示顶点位置、顶点颜色等浮点数值,一般使用Float32Array即可;对于顶点索引,一般使用Uint16Array即可。

参考资源

  1. MDN Endianness
  2. MDN DataView