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

Ilmari Heikkinen

簡介

型別的陣列是瀏覽器最近新增的一個新項目,原先需要以高效率的方式處理 WebGL 中的二進位資料。型別陣列是記憶體的實驗室,其具有類型檢視,就像陣列在 C 中的運作方式。由於 Typed Array 採用原始記憶體,因此 JavaScript 引擎可以將記憶體直接傳遞給原生資料庫,不必費心將資料轉換為原生表示法。因此,型別陣列的資料傳遞作業要比 JavaScript 陣列更勝一,以將資料傳遞至 WebGL 以及其他處理二進位資料的 API。

型別陣列檢視的作用類似於單一類型陣列,與 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 中的圖片套用 Gamma 係數的方法。不太好:

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

使用 Uint8ClampedArray 時,您可以略過手動取值範圍限制:

pixels[i] *= gamma;

建立型別陣列檢視畫面的另一種方式,是先建立 ArrayBuffer,然後建立指向該陣列的檢視畫面。取得外部資料的 API 通常會處理 ArrayBuffers,這就是您取得型別陣列檢視的方式。

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,並使用 設定 複製資料。

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 位元無正負號的 int,後面接著兩個 16 位元 int,以及 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);
}

在上述範例中,我讀取的所有值都是大端子。如果緩衝區中的值是小端序,則可將選用的 microEndian 參數傳送至 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

關於權責的討論

半形句號 (即位元組順序) 是多位元組數字儲存在電腦記憶體中的順序。big-endian 一詞是指 CPU 架構,此架構會先儲存最重要的位元組,而「little-endian」(偏重最小的位元組)。特定 CPU 架構中使用的終端性完全是自由的,因此請選擇任一架構的理由。事實上,部分 CPU 可設為支援大端和小端的資料。

您為什麼需要擔心區域性?原因很簡單。從磁碟或網路讀取或寫入資料時,必須指定資料結束度。如此一來,無論處理資料的 CPU 程度為何,系統都能正確解讀資料。隨著網路規模日益擴大,為需要處理來自伺服器或其他網路同業的二進位資料,各種裝置 (無論大或小海) 來說,都必須妥善支援。

DataView 介面專門用於在檔案和網路之間讀寫資料,DataView 會根據指定的資料範圍執行資料。無論瀏覽器在哪個 CPU 上執行運算工作,都必須在讀取或寫入二進位資料時指定信賴度 (大或小)。

一般而言,當應用程式從伺服器讀取二進位資料時,您必須掃描一次,才能將其轉換為應用程式內部使用的資料結構。在這個階段,您應該使用 DataView。我們不建議將多位元組型別陣列檢視畫面 (Int16Array、Uint16Array 等) 與透過 XMLHttpRequest、FileReader 或任何其他輸入/輸出 API 擷取的資料直接搭配使用,因為型別陣列檢視區塊使用 CPU 的原生端序。稍後會再詳細討論。

一起來看看幾個簡單的範例。Windows BMP 檔案格式過去是 Windows 早期儲存圖片的標準格式。上方連結中的說明文件清楚指出檔案中的所有整數值均以 Little-Endian 格式儲存。以下程式碼片段會使用本文隨附的 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、XMLHttpRequests、WebSocket、Web Workers、Media Source API 和 File API。在 API 清單中,您可以看到型別陣列非常適合用於處理效能的多媒體工作,以及有效率地傳送資料。

WebGL

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

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
);

您還需要 Typed Arrays,才能從 WebGL 內容讀取像素。

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

畫布 2D

近期的 Canvas ImageData 物件能夠與 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';

提醒您,從網路下載資料時,請務必留意到端性問題!請參閱上文關於字首的章節。

檔案 API

FileReader 能以 ArrayBuffer 形式讀取檔案內容。接著,您可以將型別陣列檢視畫面和 DataView 附加至緩衝區,以操控其內容。

reader.readAsArrayBuffer(file);

您也需要謹記遺憾,如需詳細資料,請參閱「確定性」部分。

可轉移的物件

postMessage 中的可移轉物件可以大幅加快將二進位資料傳送到其他 Windows 和 Web Worker 的速度。當您以可轉移的身分將物件傳送至 worker 時,傳送執行緒就會無法存取該物件,而接收的 worker 會取得該物件的擁有權。這允許在未複製傳送資料的實作中,進行高度最佳化的實作,僅將 Typed Array 的擁有權轉移給接收器。

如要將可移轉的物件與網路工作處理序搭配使用,您需要在 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

您也可以將 Typed Arrays 與 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.
}

字串編碼

目前在 Typed Arrays 中使用字串可能會有一點問題,但字串編碼程式庫能幫上忙。字串編碼會實作建議的 Typed Array 字串編碼規格,也是您瞭解未來情況的好方法。

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

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

BitView.js

我編寫了稱為 BitView.js 的 Typed Arrays 小型操控程式庫。顧名思義,它的運作方式與 DataView 很類似,差別在於前者處理位元。透過 BitView,您可以在 ArrayBuffer 中取得並設定位元位移的位元值。BitView 也提供可在任意位元偏移上儲存及載入 6 位元和 12 位元 int 的方法。

12 位元 int 非常適合處理螢幕座標,因為螢幕的長邊通常小於 4096 像素。從使用 12 位元 int 而非 32 位元的整數,可減少 62% 的大小。舉個例子來說,我處理的是使用 64 位元浮點值做為座標的 Shapefile,但我不需要精確度,因為模型只會以螢幕尺寸顯示。改用 6 位元差異值,改用 12 位元基本座標,對先前的座標值進行編碼,使檔案大小降到十次。相關示範請見此處

以下是使用 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 模型。或者,您也可以將 EXIF 標記轉換成 JPEG 格式,並在投影播放應用程式中顯示。

ArrayBuffer XHRs 的問題是,從緩衝區讀取類似結構的資料有點痛苦。DataView 能以安全的終端方式一次讀取幾個數字,型別陣列檢視區塊就適合讀取與元素大小對齊的原生祖先數陣列。我們所認為,是一種以方便端到端安全的方式讀取陣列和資料結構。請輸入 DataStream.js。

DataStream.js 是型別陣列程式庫,能以類似檔案的方式讀取及寫入 ArrayBuffers 資料的純量、字串、陣列和資料結構。

來自 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 圖片。

型別陣列歷史記錄

我們發現將 JavaScript 陣列傳遞至圖形驅動程式會造成效能問題,因此型別陣列在 WebGL 的早期實作階段進入了初始實作階段。使用 JavaScript 陣列時,WebGL 繫結必須分配一個原生陣列並填入其內容,方法是步行 JavaScript 陣列,然後將陣列中的每個 JavaScript 物件轉換為所需的原生類型。

為修正資料轉換瓶頸,Mozilla 的 Vladimir Vukicevic 編寫了 CanvasFloatArray:具有 JavaScript 介面的 C 樣式浮點陣列。現在您可以在 JavaScript 中編輯 CanvasFloatArray 並直接傳送至 WebGL,而不需要在繫結中進行任何額外作業。在進一步的疊代作業中,CanvasFloatArray 已重新命名為 WebGLFloatArray,已進一步重新命名為 Float32Array,並分割為幕後 ArrayBuffer 和輸入的 Float32Array-view 以存取緩衝區。其他整數和浮點大小以及帶正負號/無正負號的變體也新增了類型。

設計須知

我們從一開始就推動 Typed Array 的設計,是要有效將二進位資料傳遞至原生資料庫。因此,型別陣列檢視畫面會根據主機 CPU 原生端點中的校正資料運作。這些決定能讓 JavaScript 在作業期間達到最佳效能,例如將頂點資料傳送至顯示卡。

DataView 專為檔案和網路 I/O 而設計,其中資料一律具有「指定端點」,而且可能無法配合最佳效能。

記憶體內資料組合 (使用型別陣列檢視畫面) 和 I/O (使用 DataView) 之間的設計分歧了這一點。新型 JavaScript 引擎大幅改善了型別陣列檢視畫面,並運用這類引擎在數值作業上達到高效能。這種設計決定了目前的型別陣列檢視畫面的效能等級。

參考資料