エンジニアの中島悟です。
去る2023年4月、Chrome Developer BlogでChromeのバージョン113からWebGPUがデフォルトで有効になるという発表がありました。
ついに!WebGPUが!デフォルトで使えます!
Webグラフィックスの新しい時代の幕開けです。
というわけでWebGPUについて書いていきたいと思います。
WebGPUとは
まずはそもそもWebGPUとは何かというところからいきましょう。
WebGPUとはW3Cの策定する仕様です。OpenGL系でおなじみのKhronos Groupの仕様ではありません。 なので仕様のドキュメントはW3Cのサイトにあり、APIリファレンスはMDNにあります。
またChromeではデフォルトで有効になりましたが、仕様としては実はまだドラフト段階です。 まあこれでほぼ確定と踏んだのでしょう。
ではWebGPUは何のためのAPIか、ということですがドキュメントのイントロダクションを超適当にまとめるとこんな感じでしょうか。
Graphics Processing Unitsとは名ばかりで近年色々なことに使われているGPU。
そのハードウェアをWebから直接使えたら嬉しくないですか?
ということでいっそ新しいの作ろうぜ、と生みだされたのがWebGPUです。
WebでGPUといえばWebGLというものがあったわけですが、大元のOpenGLからしてもう時代遅れ感は否めません。 グローバルな状態のenable/disableを切り替えながらGPUに命令を発行していく方式はミスをおかしやすく、並列化も難しいため正直もう無理があります。
またGPUに描画以外のことをさせるためのコンピュートシェーダーもWebGL2には仕様はあるものの、もうオワコンだからWebGPU使いなよなどと言っている始末です。
WebGPUを使う
さて、前置きはこれくらいにしてまずは最小限の手間で三角形を描きましょう。
いきなりコードの全景を貼ってしまいます。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Hello WebGPU</title> <script> async function start() { if(!navigator.gpu) { throw Error("WebGPU not supported."); } const adapter = await navigator.gpu.requestAdapter(); if(!adapter) { throw Error("gpu adapter request failed."); } const device = await adapter.requestDevice(); if(!device) { throw Error("gpu device request failed."); } const canvas = document.getElementsByTagName('canvas')[0]; const context = canvas.getContext('webgpu'); if(!context) { throw Error("Couldn't get WebGPU canvas context."); } const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device: device, format: canvasFormat, alphaMode: 'opaque' }); const shaderSrc = ` struct VertexOut { @builtin(position) position : vec4f, @location(0) color : vec4f } @vertex fn vertex_main(@builtin(vertex_index) vertexIndex : u32) -> VertexOut { var positions = array<vec2f, 3>( vec2f( 0.0, 0.5), vec2f(-0.5, -0.5), vec2f( 0.5, -0.5) ); var colors = array<vec4f, 3>( vec4f(1.0, 0.0, 0.0, 1.0), vec4f(0.0, 1.0, 0.0, 1.0), vec4f(0.0, 0.0, 1.0, 1.0) ); var output : VertexOut; output.position = vec4f(positions[vertexIndex], 0.0, 1.0); output.color = colors[vertexIndex]; return output; } @fragment fn fragment_main(input : VertexOut) -> @location(0) vec4f { return input.color; } `; const shaderModule = device.createShaderModule({code: shaderSrc}); const compileInfo = await shaderModule.getCompilationInfo(); if(compileInfo.messages.length > 0) { for (const message of compileInfo.messages) { const errorText = `${message.type}: ${message.message} at line:${message.lineNum},${message.linePos}(offset:${message.offset})`; console.log(errorText); } throw Error("shader compile error."); } const pipelineDescriptor = { vertex: { module: shaderModule, entryPoint: 'vertex_main' }, fragment: { module: shaderModule, entryPoint: 'fragment_main', targets: [{ format: canvasFormat }] }, primitive: { topology: 'triangle-list' }, layout: 'auto' }; const renderPipeline = device.createRenderPipeline(pipelineDescriptor); const frame = ()=>{ const commandEncoder = device.createCommandEncoder(); const renderPassDescriptor = { colorAttachments: [{ clearValue: {r:0.2, g:0.2, b:0.2, a:0.0}, loadOp: 'clear', storeOp: 'store', view: context.getCurrentTexture().createView() }] }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); passEncoder.setPipeline(renderPipeline); passEncoder.draw(3); passEncoder.end(); device.queue.submit([commandEncoder.finish()]); // requestAnimationFrame(frame); }; frame(); } window.addEventListener('load', start); </script> </head> <body> <canvas width="600" height="500"></canvas> </body> </html>
これを .html で保存して最新のChromeで表示すると、よくある感じのグレー地にグラデーションで色のついた三角形が描かれるはずです。
いかがでしょうか。
とても簡潔で素敵ではないでしょうか。
同種のAPIであるDirectX12やVulkanをご存じの方も多いと思います。 そのようなネイティブなAPIでは避けて通れない初期化やメモリの取り扱いが見事にWebブラウザに吸収されて、コマンドバッファ形式のAPIのエッセンスだけが凝縮されています。
これからは今時のグラフィックスAPIを学びたかったらとりあえずWebGPUという選択肢も有りかもしれません。
難しいことやってないからでしょ、といえばそうでもあるのですがまずは細かく見てみましょう。
Javascript
WebGPUはWebブラウザ上で動作するので、基本的にはJavascriptで操作します。上のHTMLでいうとscriptタグの中身です。
async function start() { // 省略 } window.addEventListener('load', start);
まずはこれでロードされた後にstart関数が呼ばれます。次からはstart関数の中身です。
WebGPUの初期化
// WebGPUに対応しているか if(!navigator.gpu) { throw Error("WebGPU not supported."); } // GPUAdapterを取得 const adapter = await navigator.gpu.requestAdapter(); if(!adapter) { throw Error("gpu adapter request failed."); } // GPUDeviceを取得 const device = await adapter.requestDevice(); if(!device) { throw Error("gpu device request failed."); }
最初のこの部分がWebGPUに関する初期化です。
この手のAPIの御多分に漏れずまずは論理デバイスGPUDevice
を取得します。
WebGPUに対応したWebブラウザにはnavigator.gpu
というプロパティがあります。
そこからまずGPUAdapter
を取得して、そのAdapterからGPUDevice
を取得する形になっています。
navigator.gpu.requestAdapter()
にはオプションの引数を渡すことができ、複数のGPUがある場合にはAdapterを取得する段階で欲しいものを選ぶ形になるようです。
ただIntelとRadeonが乗っているちょっと前のMacBook Proで試した限りではIntelしか返ってきませんでしたが…
そして注目すべきは返り値がことごとくPromiseだというところです。start関数にasync
が付けてあるのはここでawait
を使って解決するためです。
いかにもモダンなJavascriptのAPIという感じです。
Canvasとの紐づけ
// 描画先のcanvas要素からGPUCanvasContextを取得 const canvas = document.getElementsByTagName('canvas')[0]; const context = canvas.getContext('webgpu'); if(!context) { throw Error("Couldn't get WebGPU canvas context."); } // GPUCanvasContextの設定 const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device: device, format: canvasFormat, alphaMode: 'opaque' });
ここはWebブラウザ上のcanvasをWebGPUの描画先として取得する部分です。やっていることはCanvas2DやWebGLと同じです。
1つ違うのは、今まではコンテキストに何らかの設定をしたい時にはcanvas.getContext(type, attributes)
の形で設定していましたが、WebGPUではcontext.configure(configuration)
とGPUCanvasContext
のメソッドを使うようになっていることです。
個人的にはこれはわかりやすい形になったのではないかと思います。
それからさりげなく嬉しいポイントはこの設定のalphaMode
のデフォルト値がopaque
(不透明)だというところです。(リファレンス参照)。
これはCanvas要素のアルファにレンダリングしたアルファを反映するかどうかの設定です。他のHTMLの要素の上に重ねたい時などはpremultiplied
を設定することになるでしょう。
WebGLにも同様のalpha
というブーリアンの設定があるのですが、デフォルト値がtrue
です。そのためにRGBA(0,0,0,0)でclearすると背景が透明になってしまうという初心者に優しくない罠として有名です。
これが解消されているだけで好感度は高いです。
シェーダー
今時のGPUはシェーダーが無いと何もしてくれないのでシェーダーを準備します。
毛色の違う構文の文字列がWebGPU用のシェーダー言語、WebGPU Shading Language (WGSL)です。
const shaderSrc = ` struct VertexOut { @builtin(position) position : vec4f, @location(0) color : vec4f } @vertex fn vertex_main(@builtin(vertex_index) vertexIndex : u32) -> VertexOut { var positions = array<vec2f, 3>( vec2f( 0.0, 0.5), vec2f(-0.5, -0.5), vec2f( 0.5, -0.5) ); var colors = array<vec4f, 3>( vec4f(1.0, 0.0, 0.0, 1.0), vec4f(0.0, 1.0, 0.0, 1.0), vec4f(0.0, 0.0, 1.0, 1.0) ); var output : VertexOut; output.position = vec4f(positions[vertexIndex], 0.0, 1.0); output.color = colors[vertexIndex]; return output; } @fragment fn fragment_main(input : VertexOut) -> @location(0) vec4f { return input.color; } `;
Rustに影響をうけているそうで、関数をfn
と略したり、var
やlet
の変数宣言の後に型を置いたり、返り値を->
で宣言するあたり最近出てきた言語らしい感じがします。
シェーダー言語ならではの部分として、シェーダー実行時にシステムがよしなに設定してくれる値をあらわすIO attributeと呼ばれるものがあります。今回使っているのは2種類で、
おなじみのgl_Position
やSV_Position
のような定義済みの値にあたる@builtin(name)
と、layout(location=N)
やTEXCOORDN
などの番号で指定されるものにあたる@location(number)
です。
WebGLは1.0が主流なこともありlocation
はなじみが薄い方もいるかもしれませんが、この際そちらもES3系にアップデートしてしまいましょう。
それから各シェーダーステージでのエントリーポイントとして使いたい関数にはshader stage attributeとよばれるものを付けておきます。
ここでは頂点シェーダーの@vertex
とフラグメントシェーダーの@fragment
を使っています。
これは1つのソース内にいくつでも書くことができ、使いたいシェーダーの設定時にどれを呼び出すのかを指定する形になっています。
同じような部分を共通化できるので各ステージのシェーダーを別に用意するよりも経済的です。
シェーダーの内容としては、今回は最小限ということで頂点座標の入力は使わずに、頂点番号から決め打ちの座標と色をフラグメントステージに出力するようになっています。 シェーダー芸っぽいですが、ポストプロセスのような画面全体を覆う固定の三角形を出せば良いようなところでは使われることもあるテクニックです。
シェーダーのソースはGPUDevice
に渡してコンパイルすると、モジュールという単位のデータになります。
const shaderModule = device.createShaderModule({code: shaderSrc}); const compileInfo = await shaderModule.getCompilationInfo(); if(compileInfo.messages.length > 0) { for (const message of compileInfo.messages) { const errorText = `${message.type}: ${message.message} at line:${message.lineNum},${message.linePos}(offset:${message.offset})`; console.log(errorText); } throw Error("shader compile error."); }
ちなみにChromeではコンパイルエラーはわざわざ取得しなくてもコンソールに出てきました。
パイプライン
今時のグラフィックスAPIらしいところが出てきました。 レンダリング用のパイプラインを作ります。
const pipelineDescriptor = { vertex: { module: shaderModule, entryPoint: 'vertex_main' }, fragment: { module: shaderModule, entryPoint: 'fragment_main', targets: [{ format: canvasFormat }] }, primitive: { topology: 'triangle-list' }, layout: 'auto' }; const renderPipeline = device.createRenderPipeline(pipelineDescriptor);
パイプラインとはこれからGPUに何をさせたいのかを設定しておくものです。 WebGLなどではGPUへ命令を発行する前にその都度シェーダーを設定する関数などを延々と並べてあれこれ設定していましたが、無駄なうえにミスしやすかったので大きな改善点です。
ここではレンダリング用のGPURenderPipeline
を作ります。
WebGPUでは決められたプロパティをもつ普通のJavascriptのオブジェクトをGPUDevice
に渡してパイプラインを作ります。
この形式は単純なタイプミスが怖いのですが、現時点のChromeではプロパティと値どちらも未知のものがあるとエラーが出るようになっていました。
設定項目は各ステージ毎に使いたいshaderModule
とentryPoint
の関数名、出力したい形状(primitive
)、テクスチャなどのレイアウト情報などです。
fragment
ステージのtargets
は今時マルチレンダーターゲットが珍しくないからか配列で設定します。フラグメントシェーダーの出力のlocation(number)
の数値と配列のインデックスが対応する形になります。
また今回は頂点バッファもテクスチャも使わないのでlayout
はauto
としておきました。
描画処理
今回は1回しか描画していないのですが、1フレーム当たりの描画処理をframe関数としてまとめてあります。
const frame = ()=>{ const commandEncoder = device.createCommandEncoder(); const renderPassDescriptor = { colorAttachments: [{ clearValue: {r:0.2, g:0.2, b:0.2, a:0.0}, loadOp: 'clear', storeOp: 'store', view: context.getCurrentTexture().createView() }] }; const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor); passEncoder.setPipeline(renderPipeline); passEncoder.draw(3); passEncoder.end(); device.queue.submit([commandEncoder.finish()]); // requestAnimationFrame(frame); }; frame();
GPUCommandEncoder
とGPURenderPassEncoder
という似た名前のオブジェクトがあるので注意です。
一連の流れとしては次のようになっています。
GPUCommandEncoder
を作成GPUCommandEncoder
に各attachmentなどを設定してRenderPassを開始するとGPURenderPassEncoder
が返ってくるGPURenderPassEncoder
に作っておいたGPURenderPipeline
を設定GPURenderPassEncoder
にdraw(num_verts)
などの命令を発行- 終わったら
end()
GPUCommandEncoder
をfinish()
するとGPUCommandBuffer
が返される- 返された
GPUCommandBuffer
をdeviceのqueueに転送
RenderPassを開始する時の設定値も通常のJavascriptのオブジェクトです。
colorAttachments
は配列で、RenderPipelineのfragment
のtargets
に対応したテクスチャを設定します。
描画開始時の動作loadOp
、終了時の動作storeOp
、それから描画先のテクスチャから取得できるview
の設定が必要です。
GPUCommandEncoder
はRenderPassを何度でも開始できます。
その都度GPURenderPassEncoder
を取得して命令を発行、end()
で終了を繰り返すことになります。
そしてWebGPUではGPUCommandBuffer
をGPUDevice
のqueue
に転送すればそれでおしまいです。
画面の更新などはWebブラウザが良きに計らってくれます。
まとめ
ありがちな感じでとりあえずWebGPUで三角形を描いてみました。 最近のトレンドをしっかり取り入れていて、なおかつWebブラウザで手軽に今風のGPU向けのAPIに触れられる、とても素敵な環境ではないでしょうか。
今回は本当に最小限だったので、そのうち頂点バッファやテクスチャを使ったものや、お楽しみのコンピュートシェーダーを取り上げられたらと思っています。
それではまたいずれ。
おまけ
最初のChromeの開発ブログに良い感じのリンク集があったので一部整理して転載しておきます。
- W3CのWebGPUとWGSLの仕様ページ
- 公式のサンプル集とGoogleのWGSLツアー
- MDNドキュメントのWebGPUのリファレンス
- 公式のWebGPU解説
- Chromeの開発チームの方のWebGPUベストプラクティス集
- ChromeブログのGPUコンピュートの解説記事とその他の関係者の記事など