Learn Wgpuというサイトを参考にして、WebGPUプログラミングに入門するメモ書きです。私はリアルタイム3Dグラフィクスの経験者などでは特にありませんので、おかしなことを書いたらご指摘ください。
WebGPUについて(能書き)
各種の環境における3DグラフィクスAPIは DirectX, OpenGL, Vulkan, Metal と様々なものが存在しますが、Web上で3Dグラフィクスを使うにはこれらの差異を吸収して1つのインタフェースに統一しなければなりません。これまでも、Webの世界には OpenGL ES API に基づく WebGL が存在しましたが、より柔軟にGPUを利用するための次世代APIとして WebGPU が策定されつつあります。WebGPUは、グラフィクスの目的だけでなく Compute Shader による汎用計算の目的にも使えます。
各ブラウザでは以下のような実装が行われています:
- Google (Chromium) では Dawn という C/C++ による実装が行われています。
- Firefox では wgpu という Rust のクレート(ライブラリ)が使われています:
- この wgpu は、ブラウザ上の WebGPU だけでなく、クロスプラットフォームのグラフィクスAPIを抽象化するライブラリとして使えるように作られています。
- 今回はこれ (wgpu) を使います。
- Safari でも 実装されつつあります。
アプリケーションを作る場合は、WebGPUを生で触らずに何らかのフレームワークを介して使うことが大半だと思いますが、GPUの基礎的な概念に触れてみたい方は一度ローレベルなAPIに触れてみて損はないと思います。またWebGPUは、Webで使われるAPIという性質のために、VulkanやDirectX 12などよりもロバストさや扱いやすさやを重視したAPIになっているようなので「すさまじい面倒くささ」は回避して学べそうです。
Learn Wgpu
Rust の wgpu クレートに入門するための「Learn Wgpu」というサイトがあるので、基本的にこのサイトをなぞって学ぶことにします。詳しい解説やコードについては Learn Wgpu を参考にしてください。
この記事には、WebGPU APIの概念を図として整理するなどの個人的な「メモ書き」だけを残します。リアルタイム3Dグラフィクスに関する一般的な説明は特に書きません。
私が勉強で書いたコードは、GitHubに置いてあります:
また、勉強で書いたコードをWasmとしてビルドしたものをGitHub Pagesに置いてあります: https://ciscorn.github.io/learning-wgpu/
Tutorial 1: ウインドウを作る
Rustでクラスプラットフォームにウインドウを扱うための winit
というクレート(ライブラリ)を使います。winit はデスクトップOSのウインドウだけでなく、ブラウザ上のCanvasや、モバイルOSも統一的に扱えることを指向しています。
このステップは説明を省略します。
(チュートリアルを読み替えた箇所)最新のライブラリを使う
"Learn Wgpu" のチュートリアルで使われている各ライブラリのバージョンは少し古いものになっています。今回はそれらを最新版に更新して試すことにします。
2024年3月時点では以下が最新です:
- winit 0.29.x(チュートリアルでは0.28.x)
- wgpu 0.19.x(チュートリアルでは0.18.x)
これに伴い Learn Wgpu のコードを一部書き換える必要がありましたが、特段難しい作業はありませんでした。
(ちなみに、winit は 0.30 でAPIがガラっと変わる予定だそうです)
Tutorial 2: WebGPUの初期化
- https://sotrh.github.io/learn-wgpu/beginner/tutorial2-surface/
- https://github.com/ciscorn/learning-wgpu/tree/main/tutorial2-surface
WebCPUの基本的な初期化を行います。WebGPUインスタンス (wgpu::Instance
) を作り、さらにそこから次のものを作ります:
-
Adapter - 物理的なGPUデバイスを識別するもの
- Device - 論理的なGPUデバイス(→ 物理的なGPUデバイスをそのまま使うわけではなく、物理デバイスの能力を確保しつつ、色々なアプリケーションでGPUデバイスを共用するため)。この Device をWebGPUのメインインタフェースとして使っていきます。
- Queue - GPUに仕事を投げ込むためのキューを表します。
-
Surface -- Window/Canvas/etc. に対応する描画先
- GPUで描画を行わずに計算だけをする場合は不要です。
Tutorial 2:(続き)WebGPUに仕事を投げる
この図はGPUで描画を行うための "RenderPass" における仕事の投げ方を示しています。汎用計算を行う "ComputePass" でも似たようなものになるのだろうと思います。
- さきほど用意した Queue に対してコマンドを投げ込むことでGPUに仕事を投げる、というインタフェースになっています。コマンドの構築は
CommandEncoder
を使って行います。- CommandEncoderの
begin_render_pass
メソッドによって、描画パスRenderPass
の構築を開始できます(ほかに計算パスComputePass
もあります)。RenderPass が持つ各種のメソッドを呼ぶことで描画のためのコマンドを組み上げます。チュートリアルの今の段階では何も描画せずに簡単に済ませます。
- CommandEncoderの
- 画面への描画を行うため、描画した内容を書き込むターゲットが必要です:
- 描画を行う対象(実体はテクスチャ)をSurface から取得します。取得したテクスチャをそのまま使うのではなく、テクスチャへの描き方を制御するための「テクスチャビュー」を介して扱います。
- Surface から得られたTextureViewを、RenderPass の color attachments(複数可)として指定します。これがフラグメントシェーダーの結果の書き込み先になります。
- 描画作業の内容は、RenderPipeline で記述します(次節で触れます)。今の時点ではパイプラインは設定せずに実行します。
ここまでの作業で、画面を指定の色でクリアすることができました。しかしGPUに行わせたい作業内容の記述である RenderPipeline をまだ設定していないので、画面には一切何も描かれません。画面全体が単色がリセットされただけです。
ここでも、Learn Wgpu の説明をいくらか読み替えた箇所があるので、以下に書いておきます。
(変更箇所)WebGL2バックエンドでなくWebGPUを使う
"Learn Wgpu" (2024年3月時点のもの) は、ウェブブラウザ用 (Wasm) のビルドにおいてわざとWebGPUバックエンドではなくWebGL2バックエンドを使うことで互換性を上げています。しかし現在、Chrome 119以降などの一部のブラウザでは既にWebGPUが標準で有効になっているため、せっかくなのでホンモノのWebGPUバックエンドで動かしてみました。
次のようにすればOKです:
- wgpu クレートの "webgl" フィーチャーフラグを外す。
- WebGL2のためのリミットを外す:
- コード中の該当箇所を
wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits())
に変えます。
- コード中の該当箇所を
- 重要な注意点: 大きな罠になりかねない点として、WebGPUはセキュアコンテクスト(HTTPS)でしか使えません。ローカル環境でも、ウェブブラウザでの実行を試したい場合は HTTPS のサーバを立てる必要があります。
(変更箇所)sRGBリニアに統一する
Learn Wgpu の途中でも触れられていますが、描画先サーフェスのテクスチャの色空間には、同じsRGBといっても、ガンマ補正前の sRGB 値と、ガンマ補正後の「リニアsRGB」があります。ちなみに我々がよくみかけるRGB値はガンマ補正前のものです。
Learn Wgpu では is_srgb()
メソッドを使って判定することで、なるべく「リニアsRGB」のサーフェスが作られるようにしています。
しかしブラウザのWebGPU環境などでは、リニアsRGBのサーフェスを作ることができず、結果として最終的に出力される色が希望するものと大きく異なってしまう(暗くなる)ことがあります。
解決策として、サーフェスのTextureが非リニアなsRGBの場合でも、TextureViewを「リニアsRGB」にすることで両者を内部的に自動で変換させることができます(このやり方はBevyというRustのゲームエンジンのコードを覗いて参考にしました。パフォーマンス等にいくらかの影響を与える可能性はあります)。詳しいやり方はリポジトリのソースコードを参照してください。
Tutorial 3: パイプラインを組む
- https://sotrh.github.io/learn-wgpu/beginner/tutorial3-pipeline/
- https://github.com/ciscorn/learning-wgpu/tree/main/tutorial3-pipeline
「パイプライン」とは、GPUに行わせたい一連の作業内容や入出力の仕様をすべて記述するものです。なおパイプラインはあくまでも作業内容の仕様を記述するもので、必要なデータはまた別途に与えます。ここでは描画を行うための RednerPipeline
を扱います(ほかにComputePipeline
があります)。
RenderPipeline は概ね以下の情報を与えて構築します:
-
VertexState, FragmentState -- シェーダーの構成
- 頂点シェーダとフラグメントシェーダのプログラムの内容
- 頂点バッファの構成とメモリレイアウト
- など
-
PipelineLayout -- 「バインドグループ」の構成(あとで扱う)
- バッファ、テクスチャ、サンプラー、など
- 今回は使わず
- PrimitiveState -- 頂点情報をプリミティブ (基本図形) としてどう扱うかなどの設定
- その他いくつかの設定
- デプスステンシルバッファについての設定(今後触れる)
- マルチサンプリングの設定(高度なので扱わない)
- マルチビューの設定(高度なので扱わない)
WebGPUでは、シェーダーは WGSL という言語で書きます。そしてWGSLで書かれたシェーダーは、自動で各バックエンド用のシェーダー言語 (GLSL (OpenGL), HLSL (DirectX), SPIR-V (Vulkan), MSL (Metal)) に変換される... という仕組みになっています。wgpuではnagaというライブラリがこの変換を担っているそうです。
まずは非常に単純なパイプラインを組んで三角形を表示します:
- 頂点データ(頂点バッファ)は外部から与えない。シェーダー上で3つの頂点を計算で作って済ませる。
- 「バインドグループ・バインディング」も一切使わない。
- その他も最低限の設定をするだけ。
WGSLシェーダーの記述内容も最低限の内容になっています。
ここまでの作業で、画面に三角形が描かれました。
Tutorial 4: バッファとインデクス
- https://sotrh.github.io/learn-wgpu/beginner/tutorial4-buffer/
- https://github.com/ciscorn/learning-wgpu/tree/main/tutorial4-buffer
「バッファ」とは、GPUのメモリ上に一定のメモリレイアウトで連続的に配置されたデータのことです。配列だったり(例:頂点の位置や色などの属性の列)、単なる1つの構造体(例:シェーダーに与える定数群)だったりします。バッファは様々な目的で使われますが、グラフィクスにおいて最も重要なものが「頂点バッファ」と「インデクスバッファ」です。
先の例では三角形の頂点をシェーダー内部で生成しましたが、これは現実にはほぼ役に立ちません。現実には3Dモデルのデータを外部からシェーダーに入力することになるでしょう。
頂点バッファを作る
- 頂点の回転方向で三角形の表裏を表すことに注意します。裏を向いていると「背面カリング」によって描画されません。
- Rust の struct に
#[repr(C)]
を指定してメモリ上の alignment を保証します - データ(構造体の配列)は、
bytemuck
クレートを使って&[u8]
にキャストしてからバッファに書き込みます。これを行うため、頂点データの構造体でbytemuck::Pod
とbytemuck::Zeroable
を derive して、コンパイルタイムでキャストの安全性を保証する必要があります。
頂点バッファのレイアウトを記述する
バッファは単なるバイト列なので、バッファの中にどんなデータがどのように詰めこまれているかをWebGPU側に教える必要があります。描画作業の内容や入出力の仕様はすべて RenderPipeline に記述するのでした。頂点バッファのレイアウトは RenderPipeline の VertexState.buffers
にVertexBufferLayout
として記述します。レイアウトは複数の頂点属性を持てます。
頂点バッファレイアウトは複数個定義できます。これらはそれぞれが「スロット」を表し、描画時にはそれぞれのスロットに Buffer
を1つセットできます。シェーダー側からは location のみで頂点バッファの属性を参照するので、複数のスロットを定める場合は各 attribute の location が重複しないようにする必要があります。
描画する
パイプライン(=作業内容の記述)と頂点バッファ(=作業に使う入力データ)を、RenderPassにセットして、描画を実行します。
ここまでの作業で、CPU側から頂点データを与えて、画面に色付きの三角形を描かせることができました。
インデクスバッファを使う
「インデクス」とは何であるかについては Learn Wgpu による説明などを参照してください。(雑な説明: 多くの3Dモデルでは同じ頂点が何度も使いまわされることがよくあります。同じ頂点の情報を何度も記録するのは無駄です。頂点情報は重複なく記録しておいて、整数のインデクスによってそれを参照して三角形を構成すれば、必要なバイト数を大幅に削減できます。というのがインデクスバッファの意義です)。
インデクスバッファの作り方は、頂点バッファの作り方とほぼ同じです。インデクスバッファには、頂点インデクス整数の情報を、整数の列 [u32]
または [u16]
のデータとして持たせます(頂点データのときのようにわざわざ構造体を用意する必要は特にありません)。
チュートリアルではインデクスを使って五角形を描いています。
Tutorial 5: テクスチャとバインドグループ
- https://sotrh.github.io/learn-wgpu/beginner/tutorial5-textures/
- https://github.com/ciscorn/learning-wgpu/tree/main/tutorial5-textures
テクスチャ画像を利用することで、頂点データだけでは実現が難しいディティールの表現を、低い処理コストで実現できます。テクスチャの用途は多様で、最も基本となる「基本色(アルベド)のマッピング」だけでなく、法線マップ、オクルージョンマップ、材質のパラメータのマッピング、などなどに使われる、ようです。
必要なリソースの用意
画像を読みこむ
- Rustの
image
クレートを使って画像を読み込んで、to_rgba8()
メソッドで画像のRGBA形式のバッファを得ます。 - 本当にパフォーマンスを出したい場合は、Wasmで画像のデコードをするのではなく、ブラウザにデコードさせたほうがいい、と書かれていました。
テクスチャを作る
- デバイスの
create_texture
メソッドでテクスチャを作る。 - テクスチャの用途 (usage) には以下を指定します:
-
wgpu::TextureUsages::TEXTURE_BINDING
-- テクスチャをシェーダで利用する(バインディングする。後述) -
wgpu::TextureUsages::COPY_DST
-- データのコピー先にする。画像をテクスチャに書き込むために必要。
-
- その他、テクスチャのサイズなどを指定します。
テクスチャに画像データを書き込む
Queue の write_texture
メソッドを使うことで画像データをテクスチャに書き込むコマンドを発行できます。(ただし実際のコマンドの発行はQueueの次回のsubmitまで遅延されるそうです)
サンプラーを用意する
テクスチャの特定の位置の色というか値をどのように取り出すかを決める機構は「サンプラー」として抽象化されています。テクスチャを描画に使うにはサンプラーも用意する必要があります。サンプラーには以下のような設定ができます:
- テクスチャ座標がテクスチャの四隅を超えたときにどう扱うか (AddressMode)
- 補間の方法: 線形補間 or 最近傍補間
- など
BindGroup: パイプラインにつなぎこむ
作成したテクスチャ関係のリソースを、レンダリングパイプラインに送り込めるようにします。
BindGroup は、GPUのリソースをシェーダーから利用できるように紐づけるためのリソースのグループです。1つの BindGroup の中に複数のリソースのバインディングをもつことができます。リソース群をパイプラインに紐づける際にはこのグループの単位で紐づけます。
Device の create_bind_group_layout
メソッドで BindGroupLayout
を作れます。「レイアウト」という名の通り、これはバインドグループの設計図のようなもので、実際のリソース本体ではありません。BindGroupLayout は PipelineLayout のメンバーとして RenderPipeline にセットされます。
もう一方で、実際のリソースをバインドグループにまとめるために Device の create_bind_group
メソッドで BindGroup を作ります。この BindGroup にテクスチャビューやサンプラーなどの実際のリソースを紐づけます。
RenderPipeline はすでに RenderPass にセットされているので、あとは実際のデータリソースを表す BindGroup を RenderPass にセットすれば完了です。
あとは、シェーダーを書き変えて、BindGroupからテクスチャとサンプラーを取り出して使えば、テクスチャ付きで描画できます。
つづく
Part 2につづく、予定...?