目次

webgpufundamentals.org

Fix, Fork, Contribute

構造体とメモリレイアウト

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

上のコードで、ourStructDataArrayBufferのオブジェクトです。これはメモリチャンク、つまりは、バイト列です。 この「バイト列」を、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が、「新たに」生成されます。そしてそのArrayBufferFloat32Arrayとして扱うためのビューが作られます。

  • new Float32Array([4, 5, 6])

    このやり方では、引数に「配列要素」を直接与えています。この例ではまず12バイト(32bit floatの配列要素が3つと解釈されるので12=4*3バイト)のArrayBufferが「新たに」生成されます。そしてそのArrayBufferFloat32Arrayとして扱うためのビューが作られます。そして、そこに初期値として、4、5、6という数値データが書き込まれます。

    引数として「TypedArray」を与えるやり方もあります。たとえば、

    new Float32Array(someUint8ArrayOf6Values)と書いた場合(someUint8ArrayOf6Valuesは 「6要素の、Uint8Arrayデータ」とします)、24バイト(各要素のサイズは、元データは8bitだが生成されるビューの側が32bitなので6要素*32bit=192bit=24バイト)のArrayBufferが「新たに」生成されます。そしてそのArrayBufferFloat32Arrayとして扱うためのビューが作られます。そして、そこに初期値として元のビュー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種の基本型を基にしたデータ型が多数あります。 これを列挙してみます。

typedescriptionshort name
vec2<f32>a type with 2 f32svec2f
vec2<u32>a type with 2 u32svec2u
vec2<i32>a type with 2 i32svec2i
vec2<f16>a type with 2 f16svec2h
vec3<f32>a type with 3 f32svec3f
vec3<u32>a type with 3 u32svec3u
vec3<i32>a type with 3 i32svec3i
vec3<f16>a type with 3 f16svec3h
vec4<f32>a type with 4 f32svec4f
vec4<u32>a type with 4 u32svec4u
vec4<i32>a type with 4 i32svec4i
vec4<f16>a type with 4 f16svec4h
mat2x2<f32>a matrix of 2 vec2<f32>smat2x2f
mat2x2<u32>a matrix of 2 vec2<u32>smat2x2u
mat2x2<i32>a matrix of 2 vec2<i32>smat2x2i
mat2x2<f16>a matrix of 2 vec2<f16>smat2x2h
mat2x3<f32>a matrix of 2 vec3<f32>smat2x3f
mat2x3<u32>a matrix of 2 vec3<u32>smat2x3u
mat2x3<i32>a matrix of 2 vec3<i32>smat2x3i
mat2x3<f16>a matrix of 2 vec3<f16>smat2x3h
mat2x4<f32>a matrix of 2 vec4<f32>smat2x4f
mat2x4<u32>a matrix of 2 vec4<u32>smat2x4u
mat2x4<i32>a matrix of 2 vec4<i32>smat2x4i
mat2x4<f16>a matrix of 2 vec4<f16>smat2x4h
mat3x2<f32>a matrix of 3 vec2<f32>smat3x2f
mat3x2<u32>a matrix of 3 vec2<u32>smat3x2u
mat3x2<i32>a matrix of 3 vec2<i32>smat3x2i
mat3x2<f16>a matrix of 3 vec2<f16>smat3x2h
mat3x3<f32>a matrix of 3 vec3<f32>smat3x3f
mat3x3<u32>a matrix of 3 vec3<u32>smat3x3u
mat3x3<i32>a matrix of 3 vec3<i32>smat3x3i
mat3x3<f16>a matrix of 3 vec3<f16>smat3x3h
mat3x4<f32>a matrix of 3 vec4<f32>smat3x4f
mat3x4<u32>a matrix of 3 vec4<u32>smat3x4u
mat3x4<i32>a matrix of 3 vec4<i32>smat3x4i
mat3x4<f16>a matrix of 3 vec4<f16>smat3x4h
mat4x2<f32>a matrix of 4 vec2<f32>smat4x2f
mat4x2<u32>a matrix of 4 vec2<u32>smat4x2u
mat4x2<i32>a matrix of 4 vec2<i32>smat4x2i
mat4x2<f16>a matrix of 4 vec2<f16>smat4x2h
mat4x3<f32>a matrix of 4 vec3<f32>smat4x3f
mat4x3<u32>a matrix of 4 vec3<u32>smat4x3u
mat4x3<i32>a matrix of 4 vec3<i32>smat4x3i
mat4x3<f16>a matrix of 4 vec3<f16>smat4x3h
mat4x4<f32>a matrix of 4 vec4<f32>smat4x4f
mat4x4<u32>a matrix of 4 vec4<u32>smat4x4u
mat4x4<i32>a matrix of 4 vec4<i32>smat4x4i
mat4x4<f16>a matrix of 4 vec4<f16>smat4x4h

ここで、問題です。

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バイト目の部分にうまくはまっています。 一方で、scalefrictionはうまくはまらず、パディングを挟んで、それぞれオフセット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分?頭を抱えるという経験を、何度もしています。 私にとっては、このライブラリはよく効く頭痛薬です。

ライブラリでなく、半手動で計算したい人のために、 「オフセット計算機」も用意しました。


  1. f16のサポートはオプション機能となっている。 ↩︎

問題点/バグ? githubでissueを作成.
comments powered by Disqus