型別陣列 - 瀏覽器中的二進位資料

Ilmari Heikkinen

瀏覽器最近新增了型別陣列,這是因為需要在 WebGL 中以有效率的方式處理二進位資料。類型陣列是記憶體的片段,其中包含類型檢視畫面,類似於 C 中的陣列運作方式。由於 Typed Array 是由原始記憶體支援,JavaScript 引擎可以直接將記憶體傳遞至原生程式庫,而不必費心將資料轉換為原生表示法。因此,如果要將資料傳遞至 WebGL 和其他處理二進位資料的 API,型別陣列的效能會比 JavaScript 陣列好上許多。

類型陣列檢視項會像單一類型的陣列,對應至 ArrayBuffer 的某個區段。我們提供所有常見的數值類型檢視畫面,名稱會自行說明,例如 Float32Array、Float64Array、Int32Array 和 Uint8Array。還有一個特殊的檢視區塊,取代了 Canvas 的 ImageData 中的像素陣列類型:Uint8ClampedArray。

DataView 是第二種檢視畫面,用於處理異質資料。DataView 物件不提供陣列類型的 API,而是提供 get/set API,可讀取及寫入任意位元組偏移量下的任意資料類型。DataView 非常適合用於讀取和寫入檔案標頭,以及其他類結構類的資料。

使用型別陣列的基本概念

型別陣列檢視

如要使用 Typed Arrays,請建立 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,因此只要瞭解如何使用其中一個 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,然後再建立指向該 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 的用法,請為檢視畫面的緩衝區建立 Uint8Arrays,然後使用 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 讀取及寫入可能會發生 endianness 問題的資料。

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

endianness 討論

結束程度 (位元組順序) 是指多位元組數字儲存在電腦記憶體中的順序。「big-endian」一詞是指先儲存最高有效位元組的 CPU 架構;「little-endian」則是先儲存最低有效位元組的架構。在指定 CPU 架構中使用哪一個序位是完全任意的,因此選用任一結構都有充分理由。事實上,部分 CPU 可設定為同時支援大端序和小端序資料。

為何需要關心字節順序?原因很簡單。從磁碟或網路讀取或寫入資料時,必須指定資料的字節序。這樣一來,無論 CPU 的字節序為何,都能確保資料正確解讀。在網路日益普及的時代,我們必須妥善支援所有類型的裝置 (大端序或小端序),這些裝置可能需要處理來自伺服器或網路上其他同儕的二進位資料。

DataView 介面專門用於讀取及寫入檔案和網路的資料。DataView 會以指定的字節序運作資料。無論瀏覽器執行的 CPU endianness 為何,都必須在每次存取每個值時指定 big endian 或 little endian,確保讀取或寫入二進位資料時,都能獲得一致且正確的結果。

一般來說,當應用程式從伺服器讀取二進位資料時,您必須掃描一次,才能將資料轉換為應用程式在內部使用的資料結構。您應在此階段使用 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」是剛從伺服器透過 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

我會先簡單介紹目前使用 Typed Arrays 的瀏覽器 API。目前的裁剪範圍包括 WebGL、Canvas、Web Audio API、XMLHttpRequest、WebSocket、Web Workers、Media Source API 和 File API。從 API 清單中,您可以看到 Typed Array 非常適合用於成效敏感的多媒體工作,以及以有效率的方式傳遞資料。

WebGL

第一次使用 Typed Arrays 是在 WebGL 中傳遞緩衝區資料和圖片資料。如要設定 WebGL 緩衝區物件的內容,請使用 Typed Array 使用 gl.bufferData() 呼叫。

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

類型陣列也用於傳遞紋理資料。以下是使用 Typed Array 傳入紋理內容的基本範例。

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 物件已可搭配 Typed Arrays 規格運作。現在,您可以取得畫布元素上像素的 Typed Arrays 表示法。這樣一來,您就可以建立及編輯畫布像素陣列,省去多餘空間使用畫布元素,因此非常實用。

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

XMLHttpRequest2

XMLHttpRequest 獲得了 Typed Array 的提升,現在您可以接收 Typed Array 回應,而不需要將 JavaScript 字串解析為 Typed Array。這對於將擷取的資料直接傳遞至多媒體 API,以及剖析從網路擷取的二進位檔案,實在非常方便。

只要將 XMLHttpRequest 物件的 responseType 設為「arraybuffer」即可。

xhr.responseType = 'arraybuffer';

請注意,從網路下載資料時,您必須留意字節順序問題!請參閱以上有關結尾程度的部分。

File API

FileReader 可將檔案內容讀取為 ArrayBuffer。接著,您可以將類型陣列檢視畫面和 DataView 附加至緩衝區,以便操作其內容。

reader.readAsArrayBuffer(file);

此外,您也應該留意這一點。詳情請參閱「endianness」部分。

可轉移物件

postMessage 中的可轉移物件可讓將二進位資料傳送至其他視窗和網路工作處理程序,大幅加快傳送二進位資料的速度。當您將物件以可轉移的形式傳送至 Worker 時,該物件就會在傳送執行緒中變得無法存取,而接收的 Worker 會取得該物件的擁有權。這樣一來,就能進行高度最佳化,不複製傳送的資料,只需將 Typed Array 的擁有權轉移給接收器。

如要在 Web Workers 中使用可轉移物件,您必須在 worker 上使用 webkitPostMessage 方法。webkitPostMessage 方法的運作方式與 postMessage 相同,但它會使用兩個引數,而不是一個。新增的第二個引數是您要傳送至 worker 的物件陣列。

worker.webkitPostMessage(oneGBTypedArray, [oneGBTypedArray]);

如要從 worker 取得物件,worker 可以以相同方式將物件傳回至主執行緒。

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

零副本,耶!

Media Source API

最近,媒體元素也具備 Media Source API 形式的「型別陣列」優勢。您可以使用 webkitSourceAttach 直接將包含影片資料的 Typed Array 傳遞到影片元素。這樣一來,影片元素就會在現有影片後面附加影片資料。SourceAttach 非常適合用來執行插頁式廣告、播放清單、串流等用途,並非常適合使用單一影片元素播放多部影片。

video.webkitSourceAppend(uint8Array);

二進位 WebSocket

您也可以搭配 WebSocket 使用型別陣列,避免必須將所有資料轉為字串。非常適合用於編寫高效的通訊協定,並盡可能減少網路流量。

socket.binaryType = 'arraybuffer';

呼!以上就是 API 審查的內容。接下來,我們來看看如何透過第三方程式庫處理 Typed Array。

第三方程式庫

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

目前在 Typed Arrays 中使用字串有點麻煩,但有 字串編碼程式庫可提供協助。StringEncoding 實作了建議的 Typed Array 字串編碼規格,因此也是瞭解未來發展方向的好方法。

以下是字串編碼的基本用法範例:

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

BitView.js

我為 Typed Arrays 編寫了一個小型位元操作程式庫,稱為 BitView.js。顧名思義,它與 DataView 的運作方式非常相似,只是它可處理位元。您可以使用 BitView 取得及設定 ArrayBuffer 中指定位元偏移位元值。BitView 也提供方法,可在任意位元偏移量下儲存及載入 6 位元和 12 位元整數。

12 位元整數很適合用於處理螢幕座標,因為螢幕的長邊通常會少於 4096 像素。使用 12 位元 int 而非 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 檔案,並讀取可用於音訊播放器的中繼資料標記。或者,您也可以載入形狀檔案,並將其轉換為 WebGL 模型。或者,您也可以讀取 JPEG 中的 EXIF 標記,並在幻燈片應用程式中顯示這些標記。

ArrayBuffer XHRs 的問題是,從緩衝區讀取類似結構體的資料有點痛苦。DataView 適合用來以非中斷方式一次讀取幾個數字,型別陣列檢視畫面適合用來讀取以元素大小為準的原生端點數字陣列。我們認為缺少的方式,是透過方便的端碼安全方式讀取陣列和資料結構。輸入 DataStream.js。

DataStream.js 是一種 Typed Arrays 程式庫,可以類似檔案的方式讀取及寫入 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
]);

如您所見,結構定義是 [名稱、類型] 組合的平面陣列。巢狀 struct 是透過類型的陣列完成。陣列的定義是使用三個元素的陣列,其中第二個元素是陣列元素類型,第三個元素是陣列長度 (可做為數字、先前讀取欄位的參照或回呼函式)。陣列定義的第一個元素未使用。

類型的可能值如下:

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 的檢視畫面,以便存取緩衝區。系統也會為其他整數和浮點大小以及帶正負號/未簽署的變體新增類型。

設計須知

從一開始,我們設計 Typed Array 的目的,就是為了有效地將二進位資料傳遞至原生程式庫。因此,類型陣列檢視畫面會在主機 CPU 的原生字節順序中,對對齊的資料進行運算。這些決策可讓 JavaScript 在傳送頂點資料至顯示卡等作業期間,達到最佳效能。

DataView 專為檔案和網路 I/O 設計,在這些情況下,資料一律會具有指定的字節序,且可能不會對齊,以便盡可能提高效能。

我們有意將記憶體內資料組合作業 (使用型別陣列檢視畫面) 和 I/O 作業 (使用 DataView) 分開設計。現代的 JavaScript 引擎可大幅最佳化型別陣列檢視畫面,利用它們在數值作業上達到極佳效能。這項設計決策讓類型陣列檢視畫面可達到目前的成效。

參考資料