WebGPUで扱うデータは、ほとんどの場合、シェーダで定義したデータ構造に合わせて 明示的にメモリレイアウトする必要があります。 このことは、JavaScriptやTypeScriptにおけるデータの扱いとは対照的です。 JavaScriptやTypeScriptのプログラミングでは、そういったことを意識する機会は滅多にありません。
シェーダはWGSLという言語で書きますが、WGSL言語のプログラミングでは、頻繁にstruct
(構造体)を定義します。
構造体というのは、JavaScriptで言えばオブジェクトと似た仕組みです。
WGSLの構造体では「メンバー変数」を定義しますが、
これはJavaScriptのオブジェクトでプロパティを定義するのに似ています。
JavaScriptでは、オブジェクトのプロパティの定義は名前を付けて終わりですが、 WGSLの構造体のメンバー変数の定義では名前のほかに、「変数のデータ型」を明示する必要があります。 また、シェーダ中の構造体に対してはバッファからデータを渡すことになりますが、 「バッファ上の、どこに、どういったデータを配置すれば、構造体の各メンバーに正しくデータが割り当てられるか」 は、あなたの責任となっています。
WGSL v1には、基本となる4種類のデータ型があります。
f32
(32ビット浮動小数点数型)i32
(32ビット符号付き整数型)u32
(32ビット符号なし整数型)f16
(16ビット浮動小数点数型) [1]1バイトは8ビットなので、32ビット型データなら4バイト、16ビット型データなら2バイトです。
以下のような構造体の場合、
struct OurStruct { velocity: f32, acceleration: f32, frameCount: u32, };
そのデータレイアウトを図示するとこのようになります。
図中の各四角ブロックは1バイトを表しています。
上の図の通り、この構造体全体は12バイトとなります。
12バイトの、最初の4バイトはvelocity
に、次の4バイトはacceleration
に、
最後の4バイトはframeCount
に使用されます。
この構造体OurStruct
にデータを渡すためには、
渡すデータの方を構造体に合わせたメモリレイアウトにする必要があります。
実際にやってみましょう。
まずArrayBuffer
を12バイトのサイズで生成します。
ArrayBuffer
に実際のデータを書き込む際には、各種のTypedArray
(型付き配列)ビューを利用します。
const kOurStructSizeBytes = 4 + // velocity 4 + // acceleration 4 ; // frameCount const ourStructData = new ArrayBuffer(kOurStructSizeBytes); const ourStructValuesAsF32 = new Float32Array(ourStructData); const ourStructValuesAsU32 = new Uint32Array(ourStructData);
上のコードで、ourStructData
はArrayBuffer
のオブジェクトです。これはメモリチャンク、つまりは、バイト列です。
この「バイト列」を、3つの「数値」として扱うために、「ビュー(view)」を作成しています。
ourStructValuesAsF32
は、メモリの中身を「32ビット浮動小数点数値として扱う」ためのビューです。
ourStructValuesAsU32
は、メモリの中身を「32ビット符号なし整数値として扱う」ためのビューです。
これらのビューは同じメモリを見ている、という点に注意してください。
ここまでで、バッファ1つ、そのバッファに対するビューを2つ、用意しました。 これを利用して、バッファに、構造体の各メンバーのデータ型に合わせた数値データをセットできます。
const kVelocityOffset = 0; const kAccelerationOffset = 1; const kFrameCountOffset = 2; ourStructValuesAsF32[kVelocityOffset] = 1.2; ourStructValuesAsF32[kAccelerationOffset] = 3.4; ourStructValuesAsU32[kFrameCountOffset] = 56; // これは整数値
TypedArrays
以上で、「12バイトのバッファ」に「3つの数値」を適切に配置することができました。
さて、プログラミングでは、同じことをやるために色々な書き方ができます。
Float32Array
のようなTypeArray
(型付き配列)には、以下のようなコンストラクタがあります。
new Float32Array(12)
このやり方では、引数に「配列要素数」を指定しています。この例ではまず48バイト(Float32Array(12)
に合わせて48=12*4バイト)のArrayBuffer
が、「新たに」生成されます。そしてそのArrayBuffer
をFloat32Array
として扱うためのビューが作られます。
new Float32Array([4, 5, 6])
このやり方では、引数に「配列要素」を直接与えています。この例ではまず12バイト(32bit floatの配列要素が3つと解釈されるので12=4*3バイト)のArrayBuffer
が「新たに」生成されます。そしてそのArrayBuffer
をFloat32Array
として扱うためのビューが作られます。そして、そこに初期値として、4、5、6という数値データが書き込まれます。
引数として「TypedArray
」を与えるやり方もあります。たとえば、
new Float32Array(someUint8ArrayOf6Values)
と書いた場合(someUint8ArrayOf6Valuesは
「6要素の、Uint8Arrayデータ」とします)、24バイト(各要素のサイズは、元データは8bitだが生成されるビューの側が32bitなので6要素*32bit=192bit=24バイト)のArrayBuffer
が「新たに」生成されます。そしてそのArrayBuffer
をFloat32Array
として扱うためのビューが作られます。そして、そこに初期値として元のビューsomeUint8ArrayOf6Values
の各要素の値がFloat32Array
へとコピーされます。値のコピーは、バイナリデータとしてではなく数値として解釈されます。下のコードの様な意味です。
srcArray.forEach((v, i) => dstArray[i] = v);
new Float32Array(someArrayBuffer)
このやり方では、引数に既存の「ArrayBuffer
」を与えています。記事の上の方で書いたサンプルプログラムのやり方はこの形です。Float32Array
は「既存のバッファ」に対するビューとなり、新たにArrayBuffer
が生成されることはありません。
new Float32Array(someArrayBuffer, byteOffset)
このやり方では、引数として、既存のArrayBuffer
と「オフセット値」を与えています。Float32Array
は「既存のバッファの先頭をbyteOffset
と見なした範囲」に対するビューとなります。また、新たにArrayBuffer
が生成されることはありません。
new Float32Array(someArrayBuffer, byteOffset, length)
このやり方では、引数として、既存のArrayBuffer
と「オフセット値」と「length」を与えています。
Float32Array
は「既存のバッファの、先頭をbyteOffset
、要素数をlength
と見なした範囲」に対するビューとなります。要素数なので、lengthが3であればfloat32要素が3つとなるので12バイトに相当します。また、新たにArrayBuffer
が生成されることはありません。
先ほどのコードを、最後のやり方で書き直すなら、このように書けます。
const kOurStructSizeBytes = 4 + // velocity 4 + // acceleration 4 ; // frameCount const ourStructData = new ArrayBuffer(kOurStructSizeBytes); const velocityView = new Float32Array(ourStructData, 0, 1); const accelerationView = new Float32Array(ourStructData, 4, 1); const frameCountView = new Uint32Array(ourStructData, 8, 1); velocityView[0] = 1.2; accelerationView[0] = 3.4; frameCountView[0] = 56;
TypedArray
についてもう少し説明します。
すべてのTypedArray
には以下のプロパティがあります。
length
: 要素数byteLength
: 全体のバイト数byteOffset
: TypeArray
が見ているArrayBuffer
のオフセット値buffer
: TypeArray
が見ているArrayBuffer
また、TypeArray
には各種のメソッドがあります。
TypeArray
の持つメソッドの多くは、JavaScriptのArray
オブジェクトが持つメソッドと似ていますが、
独特なものとしてsubarray
メソッドがあります。
subarray
メソッドは元のビューと同じ型のTypedArray
ビューを生成します。
引数はsubarray(begin, end)
となっていますが、end
番要素は含まれません。
例えばsomeTypedArray.subarray(5, 10)
と書いた場合、
「元のビューが見ているArrayBuffer
と同じArrayBuffer
を、
someTypedArrayとして5番目から9番目の要素の部分だけを見るためのビューを生成する」、
といった意味になります。
先ほどのコードは、こんな風に書き替えることもできます。
const kOurStructSizeFloat32Units = 1 + // velocity 1 + // acceleration 1 ; // frameCount const ourStructDataAsF32 = new Float32Array(kOurStructSizeFloat32Units); const ourStructDataAsU32 = new Uint32Array(ourStructDataAsF32.buffer); const velocityView = ourStructDataAsF32.subarray(0, 1); const accelerationView = ourStructDataAsF32.subarray(1, 2); const frameCountView = ourStructDataAsU32.subarray(2, 3); velocityView[0] = 1.2; accelerationView[0] = 3.4; frameCountView[0] = 56;
WGSLには最初に書いた4種の基本型を基にしたデータ型が多数あります。 これを列挙してみます。
type | description | short name |
---|---|---|
vec2<f32> | a type with 2 f32 s | vec2f |
vec2<u32> | a type with 2 u32 s | vec2u |
vec2<i32> | a type with 2 i32 s | vec2i |
vec2<f16> | a type with 2 f16 s | vec2h |
vec3<f32> | a type with 3 f32 s | vec3f |
vec3<u32> | a type with 3 u32 s | vec3u |
vec3<i32> | a type with 3 i32 s | vec3i |
vec3<f16> | a type with 3 f16 s | vec3h |
vec4<f32> | a type with 4 f32 s | vec4f |
vec4<u32> | a type with 4 u32 s | vec4u |
vec4<i32> | a type with 4 i32 s | vec4i |
vec4<f16> | a type with 4 f16 s | vec4h |
mat2x2<f32> | a matrix of 2 vec2<f32> s | mat2x2f |
mat2x2<u32> | a matrix of 2 vec2<u32> s | mat2x2u |
mat2x2<i32> | a matrix of 2 vec2<i32> s | mat2x2i |
mat2x2<f16> | a matrix of 2 vec2<f16> s | mat2x2h |
mat2x3<f32> | a matrix of 2 vec3<f32> s | mat2x3f |
mat2x3<u32> | a matrix of 2 vec3<u32> s | mat2x3u |
mat2x3<i32> | a matrix of 2 vec3<i32> s | mat2x3i |
mat2x3<f16> | a matrix of 2 vec3<f16> s | mat2x3h |
mat2x4<f32> | a matrix of 2 vec4<f32> s | mat2x4f |
mat2x4<u32> | a matrix of 2 vec4<u32> s | mat2x4u |
mat2x4<i32> | a matrix of 2 vec4<i32> s | mat2x4i |
mat2x4<f16> | a matrix of 2 vec4<f16> s | mat2x4h |
mat3x2<f32> | a matrix of 3 vec2<f32> s | mat3x2f |
mat3x2<u32> | a matrix of 3 vec2<u32> s | mat3x2u |
mat3x2<i32> | a matrix of 3 vec2<i32> s | mat3x2i |
mat3x2<f16> | a matrix of 3 vec2<f16> s | mat3x2h |
mat3x3<f32> | a matrix of 3 vec3<f32> s | mat3x3f |
mat3x3<u32> | a matrix of 3 vec3<u32> s | mat3x3u |
mat3x3<i32> | a matrix of 3 vec3<i32> s | mat3x3i |
mat3x3<f16> | a matrix of 3 vec3<f16> s | mat3x3h |
mat3x4<f32> | a matrix of 3 vec4<f32> s | mat3x4f |
mat3x4<u32> | a matrix of 3 vec4<u32> s | mat3x4u |
mat3x4<i32> | a matrix of 3 vec4<i32> s | mat3x4i |
mat3x4<f16> | a matrix of 3 vec4<f16> s | mat3x4h |
mat4x2<f32> | a matrix of 4 vec2<f32> s | mat4x2f |
mat4x2<u32> | a matrix of 4 vec2<u32> s | mat4x2u |
mat4x2<i32> | a matrix of 4 vec2<i32> s | mat4x2i |
mat4x2<f16> | a matrix of 4 vec2<f16> s | mat4x2h |
mat4x3<f32> | a matrix of 4 vec3<f32> s | mat4x3f |
mat4x3<u32> | a matrix of 4 vec3<u32> s | mat4x3u |
mat4x3<i32> | a matrix of 4 vec3<i32> s | mat4x3i |
mat4x3<f16> | a matrix of 4 vec3<f16> s | mat4x3h |
mat4x4<f32> | a matrix of 4 vec4<f32> s | mat4x4f |
mat4x4<u32> | a matrix of 4 vec4<u32> s | mat4x4u |
mat4x4<i32> | a matrix of 4 vec4<i32> s | mat4x4i |
mat4x4<f16> | a matrix of 4 vec4<f16> s | mat4x4h |
ここで、問題です。
「vec3f
は、f32
要素を3つ持つ型です。また、mat4x4f
は、各要素がf32
となっている4x4行列、つまりf32
要素を16個持つ型です。
以下のような構造体を定義したとき、メモリレイアウトがどうなるか答えなさい。」
struct Ex2 { scale: f32, offset: vec3f, projection: mat4x4f, };
さて正解は!?
予想と違いましたか? 上の図は、「各データ型にはアラインメント(alignment)要件がある」、ということを示してます。 各データの開始バイトは、メモリ上では「各変数の型に対応した特定のバイト数の倍数」に整列(align)する必要があります。
次の表は、各種のデータ型のサイズとアラインメントの一覧です。
さらに!もう少し話は続きます。
以下の構造体のレイアウトはどうなると思いますか?
struct Ex3 { transform: mat3x3f, directions: array<vec3f, 4>, };
array<type, count>
というのは、「データ型がtype
、要素数がcount
の配列」です。
正解は、このようになります。
アラインメントの表を見ると、vec3<f32>
のalignの値は16となっています。
このため、vec3<f32>
中の12バイトデータはすべて16バイト単位に整列されます。
これは、「各vec3<f32>
の開始バイト」は、「offsetが0や16の倍数の位置」にしか置けない、ということです。
数が合わない場合は、必要に応じて、バッファには空白(padding。図中では"-pad-")が配置されます。
このルールは、vec3<f32>
のデータが「行列の要素」であっても「配列の要素」であっても同様です。
次の例を見てみましょう。
struct Ex4a { velocity: vec3f, }; struct Ex4 { orientation: vec3f, size: f32, direction: array<vec3f, 1>, scale: f32, info: Ex4a, friction: f32, };
size
は、直前のvec3f
型データorientation
のすぐ後ろ、オフセット12バイト目の部分にうまくはまっています。
一方で、scale
やfriction
はうまくはまらず、パディングを挟んで、それぞれオフセット32、64のところに飛び出して配置されています。
この違いは何でしょう。
これは、配列や構造体には、基本型とはまた別の、特別なアラインメントのルールがあるためです。
配列や構造体は、この例のdirection
のような「要素数がひとつだけの配列」であっても、
Ex4a
のような「要素がvec3f
ひとつだけの構造体」であっても、
配列や構造体の独自の、アラインメントのルールに従います。
WGSL中のデータのサイズとオフセットの計算は、WebGPUの扱いにおいて、恐らくは最大の難所と言えるでしょう。 これらの計算は自分でやる必要があります。 シェーダ中の構造体のデータ構造に変更があれば、メモリレイアウトがどう変わるのか再考して、 JavaScript側でオフセット値を書き換えて、つじつまを合わせる必要があります。 変更が構造体の中ほどのデータだった場合、それ以降のすべてのメンバのオフセット値を再計算する必要があります。
どこかで1バイトでも間違えれば、シェーダには誤ったデータが送られることになります。 シェーダはエラーメッセージを返すでもなく、ただ黙々と間違った結果を出力します。 シェーダの入力データが間違っているからです。 3Dモデルが表示されなかったり、計算結果が間違ったり、といったことが起きます。
幸い、こういった作業を手助けするライブラリが存在しています。
例えば「webgpu-utils」です。
このライブラリにWGSLコードを与えると、すべてをあなたの替わりにやってくれるAPIを返してくれます。 構造体の変更をしても、これを使えばたいていの場合、コードはうまく動くはずです。
今回のサンプルプログラムで実際にwebgpu-utils
を使ってみる場合、以下のようなコードになるでしょう。
import { makeShaderDataDefinitions, makeStructuredView, } from 'https://greggman.github.io/webgpu-utils/dist/0.x/webgpu-utils-1.x.module.js'; const code = ` struct Ex4a { velocity: vec3f, }; struct Ex4 { orientation: vec3f, size: f32, direction: array<vec3f, 1>, scale: f32, info: Ex4a, friction: f32, }; @group(0) @binding(0) var<uniform> myUniforms: Ex4; ... `; const defs = makeShaderDataDefinitions(code); const myUniformValues = makeStructuredView(defs.uniforms.myUniforms); // Set some values via set myUniformValues.set({ orientation: [1, 0, -1], size: 2, direction: [0, 1, 0], scale: 1.5, info: { velocity: [2, 3, 4], }, friction: 0.1, }); // now pass myUniformValues.arrayBuffer to WebGPU when needed.
このライブラリを使うか、別のライブラリを使うか、何も使わず自力でやるかは、あなたの判断です。 ただ、筆者の場合、実際の場面でどこが間違ったか調べて、オフセットとサイズを手計算するだけのために、 20分、30分……いや60分?頭を抱えるという経験を、何度もしています。 私にとっては、このライブラリはよく効く頭痛薬です。
ライブラリでなく、半手動で計算したい人のために、 「オフセット計算機」も用意しました。