SPARKCREATIVE Tech Blog

https://www.spark-creative.jp/

WebGPU Next Step

こんにちはエンジニアの中島悟です。
前回は半年ほど前になりますがWebGPUで最小の手間で三角形を描いてみました。

しかしこの四角形ではフィルタやシェーダー芸くらいしか使い道がありません。
そこでもう少し拡張しようと思います。具体的には下のものです。

  • 頂点バッファとインデックスバッファでポリゴンメッシュを描画する
  • テクスチャを使う
  • シェーダーにパラメーターを渡す

これだけあればモデルを描画することもできるでしょう。

サンプルコード

今回はコードの量もそれなりで、またテクスチャ用の画像もあるのでGithub Pagesに置きました。 WebGPUに対応したブラウザで開けばテクスチャの貼られた四面体が回転しているはずです。 またこの記事中ではコードを参照したい時にはGitHubへのリンクを貼っています。
なお↗のリンクはMDNのリファレンスへのリンクです。

それでは目的の部分を見ていきましょう。

頂点バッファとインデックスバッファでポリゴンメッシュを描画する

まずは設定した頂点データを描画できるようにします。
頂点バッファだけでも良いのですが、応用が効くようにインデックスバッファも使って描画します。

//----- vertex buffer -----のコメントのあたりが頂点バッファの設定です。
WebGPUでは汎用に使えるGPUBufferがあり、gpudevice.createBuffer(descriptor)descriptorにサイズや用途を指定して作成します。

頂点バッファとして使うためには用途usageGPUBufferUsage.VERTEXを設定します。 またモデルデータは一度書き込んだら変更しないので、mappedAtCreation: trueを指定して作成後すぐに書き込めるようにしておきます。

gpubuffer.getMappedRange()でCPUから書き込めるようにマップされたArrayBufferが取得できます。これからFloat32Arrayを作成し、用意した頂点データをsetすれば完了です。 書き込みが終わったらgpubuffer.unmap()しておきましょう。

今回は頂点データとして、位置(x,y,z)、色(r,g,b,a)、UV座標(u,v)を使うことにしました。 Cの構造体として書くと頂点1つあたりはこのようになります。

struct VertexInput {
    float position[3];
    float color[4];
    float uv[2];
}

しかしJavascriptでは構造体は定義できません。とはいえ今回は全てfloatなので単純にFloat32Arrayにメンバの順番に並べれば十分です。

頂点シェーダーではエントリーポイントの関数で、この構成と同じになるように引数を定義します。

@vertex
fn vertex_main(
    @location(0) position : vec3f,
    @location(1) color : vec4f,
    @location(2) uv : vec2f
    ) -> VertexOut

そしてこのデータの構造をvertexBufferLayoutオブジェクトに定義します。これは後ほど使います。

次はインデックスバッファです。 //----- index buffer -----のコメントの部分です。

インデックスバッファは値1つ当たりのバイト数と三角形の数からサイズを決めることができます。
ただ、バッファーのサイズが4の倍数Byteになるように切り上げないといけないようです。 これはWebGPUの仕様には書いてなさそうなので、現行のChrome固有の制限かもしれません。
このためインデックスデータの数とArrayのlengthは一致しない可能性があるので注意しましょう。

そしてインデックスバッファも頂点と同様に即座にマップされるようにして作成し、書き込んでおきます。

これでモデル周りの準備は完了です。

テクスチャを使う

テクスチャは//----- image texture -----のあたりです。

下準備として画像などからCanvasAPIcreateImageBitmap(src)メソッドImageBitmapオブジェクトを取得しておきます。

WebGPUではテクスチャはGPUTextureで、gpudevice.createTexture(descriptor)で作成します。

descriptorにサイズ、フォーマット、用途などを記述します。 テクスチャとして使用するだけなのですが、画像データを描き込むためには用途としてGPUTextureUsage.COPY_DSTGPUTextureUsage.RENDER_ATTACHMENTを設定しないといけないようです。

データの書き込みはImageBitmapからGPUで転送するGPUQueueのメソッド、gpuqueue.copyExternalImageToTexture(source, destination, copySize)を使います。

それからWebGPUでのテクスチャのサンプリング設定はGPUSamplerオブジェクトです。これもテクスチャのついでに作っておきます。gpudevice.createSampler(descriptor)です。

また今回は3Dでレンダリングするので深度バッファが必要です。 WebGPUではこれにもテクスチャを使います。
フォーマットを深度バッファとして使えるdepth24plusなどにして、描画先と同じサイズで作成しておきます。 今回はテクスチャとしては使わないので、用途はGPUTextureUsage.RENDER_ATTACHMENTだけにしておきました。

シェーダーにパラメーターを渡す

3Dで描画するにあたって、CPU側(Javascript)から設定したいシェーダーのパラメーターがいくつかあります。 そのためにWebGPUではUniform Bufferという仕組みが用意されています。
これはシェーダー(GPU側)で定義したvar<uniform>変数にGPUBufferを対応させてJavascriptから値を書き込むことができるというものです。

というわけでGPUBufferを用意します。用途にGPUBufferUsage.UNIFORMを設定して作成します。
合わせてJavascript上で書き込み用に使うFloat32Arrayを用意します。ここでは単純にUniform Bufferと同じ大きさのものを用意しておくことにしました。

Uniform Bufferに値を書き込むにはいくつか下準備が必要です。 GPU上のメモリには値の配置にいくつか制限があり、それを踏まえて対応した位置に値を書き込まなくてはいけません。

そのためにWebGPUではまずシェーダー側で、メモリレイアウトを明示的に設定することができます。 ただ今回はmat4x4f(f32が16個)が並んでいるだけの単純なものなので省略しています。

また色々な型のメンバが含まれている場合には、TypedArrayのコンストラクターにあるTypedArray(buffer, byteOffset, length)ArrayBufferのビューを作成できるので、これを利用しても良さそうです。

例えばシェーダーで下のような構造体を定義したなら、

struct TypeMixedStruct {
    f32Value : f32,
    u32Value : u32
}

Javascript上では下のような感じでアクセスすることになるでしょう。

float32Array = new Float32Array(2); // f32換算のTypeMixedStruct全体のサイズ
uint32Array = new Uint32Array(float32Array.buffer); // float32ArrayのbufferからUint32としてアクセスするビューを作成

float32Array[0] = 1.0; // TypeMixedStruct.f32Value へアクセス
uint32Array[1] = 255;  // TypeMixedStruct.u32Value へアクセス

この場合ではbyteOffsetを使っても良いかもしれません。 この辺りはwebブラウザ上という制約による面倒なところでしょうか。

シェーダーにパラメーターを渡す(改めて)

さて、テクスチャとUniform Bufferの準備ができたところで、改めてこれらにシェーダーからアクセスできるように設定します。

//----- uniform binding -----に続く部分です。

リソースの対応付けはGPUBindGroupLayoutでシェーダー内のリソースの配置を定義し、GPUBindGroupでレイアウトに対応するオブジェクトを設定します。

GPUBindGroupLayoutは定義を自分で書くこともできますが、layout: 'auto'を設定してRenderPipelineを作成するとシェーダーで付けた@binding(i)@group(i)属性から自動で生成してくれるのでそれを使うことにします。

gpurenderpipeline.getBindGroupLayout(index)で使いたいGPUBindGroupLayoutを取得できます。index@group(index)の数値です。

それから@binding(i)の数値を考慮してGPUBindGroupを作るためのオブジェクトを作成します。

const uniformBindGroupDescriptor = {
    layout: lauoutGroup,
    entry: [
        {binding: 0, resource: resourceObject0},
        {binding: 1, resource: resourceObject1},
        ...
    ]
}

binding: iはもちろん@binding(i)に対応します。 このオブジェクトをgpudevice.createBindGroup(descriptor)に渡してやることで、所望のGPUBindGroupが作成できます。

そして実際にBufferなどをバインドするのはGPURenderPassEncoderのそれぞれのメソッドで行います。

当たり前ですがGPURenderPipelineに設定したシェーダーと、使用するGPUBindGroupのLayoutは一致していなくてはいけません。
そしてgpurenderpassencoder.setBindGroup(index, bindGroup)indexは、GPURenderPipelineが持っているGPUBindGroupLayoutのインデックスです。
前述の通り今回はgpurenderpipeline.getBindGroupLayout(index)で取得しているので、これと同じインデックスの値を設定します。

似たような名前が並んでいて少しややこしいですが、これでUniform Bufferの準備は完了です。

シェーダーに渡すパラメーターを更新する

Uniform Bufferの準備もできましたが、中には毎フレーム更新したいパラメーターも存在します。

そのためにはまずUnifomr Buffer作成時に作ったGPUBufferと対になるFloat32Arrayの値を更新します。 Uniform Bufferの中のメンバーのオフセットを取得できればスマートなのですが、これについては特に取得する手段などは無さそうなので自分で把握しておくしかないようです。
ここではuniformBufferOffsetsとして定義しました。 これを使って、まずはFloat32Array上の必要な部分を更新します。

今回はmat4x4fが並んでいるだけなのでオフセットを間違えなければ大丈夫です。

そして更新したFloat32Arrayは描画に合わせたタイミングでUniform Bufferに転送されて欲しいので、gpuqueue.writeBuffer(buffer, bufferOffset, data, dataOffset=0, size=undefined)を使って転送コマンドをキューに積みます。 バッファーとデータ双方にoffsetとlengthが指定できるので部分的に更新することもできるのですが、今回は丸ごと全部を書き込んでいます。
なおdataのサイズはバイト換算したサイズで4の倍数になっている必要があるそうです。

描画する

最後にGPUCommandEncoderを作って描画コマンドをGPUQueueに積みます。 今まで準備してきたPipelineとBindGroup、VertexBufferとIndexBufferをセットし、gpurenderpassencoder.drawIndexed(indexCount)を使います。 これでポリゴンメッシュが描画されるはずです。

まとめ

結局のところWebGPUも他のコマンドバッファ形式のAPIと同様にそれなりに地道な準備が必要です。 Javascriptとブラウザが隠してくれていて楽なところもある一方で、Bufferの読み書きのように若干面倒になってしまっている部分もあります。 あちらを立てればこちらが立たずという感じですが、総合的には割ととっつきやすいのではないでしょうか。

さて次回はWebGPUでついに解禁されたComputeShaderを使ってみたいと思います。
それではまた。