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

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 非常適合讀取及寫入檔案標頭和其他類似結構體的資料。

使用類型陣列的基本概念

Image for: 使用類型陣列的基本概念

型別陣列檢視

如要使用類型陣列,您必須建立 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);
}

在上述範例中,我讀取的所有值都是 big-endian。如果緩衝區中的值是小端序,您可以將選用的 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

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

Image for: 使用輸入的陣列的瀏覽器 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 緩衝區物件的內容,請使用 gl.bufferData() 呼叫搭配 Typed Array。

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 中的可轉移物件可讓您更快速地將二進位資料傳送至其他視窗和 Web Workers。當您將物件以可轉移的形式傳送至 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 的形式獲得了一些 Typed Array 優勢。您可以使用 webkitSourceAppend,直接將含有影片資料的 Typed Array 傳遞至影片元素。這樣一來,影片元素就會在現有影片後面附加影片資料。在使用插播廣告、播放清單、串流和其他可能需要使用單一影片元素播放多部影片的情況下,SourceAppend 就非常實用。

video.webkitSourceAppend(uint8Array);

二進位 WebSocket

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

socket.binaryType = 'arraybuffer';

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

第三方程式庫

Image for: 第三方程式庫

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 位元整數而非 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 XHR 的問題在於,從緩衝區讀取類似結構的資料有點麻煩。DataView 適合以 big-endian 安全模式一次讀取少數數字,而型別陣列檢視畫面則適合讀取元素大小對齊原生 big-endian 數字的陣列。我們認為缺少的方式,是透過方便的端碼安全方式讀取陣列和資料結構。輸入 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 圖片。

型別陣列的發展歷程

Image for: 型別陣列的發展歷程

在 WebGL 的早期實作階段,我們發現將 JavaScript 陣列傳遞至圖形驅動程式會導致效能問題,因此開始開發類型陣列。使用 JavaScript 陣列時,WebGL 繫結必須分配本機陣列,並透過遍歷 JavaScript 陣列,���陣列中的每個 JavaScript 物件轉換為必要的本機類型。

為瞭解決資料轉換瓶頸,Mozilla 的 Vladimir Vukicevic 編寫了 CanvasFloatArray:一個具有 JavaScript 介面的 C 樣式浮點陣列。您現在可以在 JavaScript 中編輯 CanvasFloatArray,並直接將其傳遞至 WebGL,而不需要在繫結中執行任何額外作業。在後續迭代中,CanvasFloatArray 已重新命名為 WebGLFloatArray,並進一步重新命名為 Float32Array,然後分割為支援的 ArrayBuffer 和以型別為 Float32Array 的檢視畫面,以便存取緩衝區。我們也為其他整數和浮點大小,以及帶正負號的變化版本新增了類型。

設計須知

Image for: 設計須知

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

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

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

參考資料

Image for: 參考資料