SPARKCREATIVE Tech Blog

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

こんにちわWebGPU

エンジニアの中島悟です。

去る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を取得する段階で欲しいものを選ぶ形になるようです。

ただIntelRadeonが乗っているちょっと前のMacBook Proで試した限りではIntelしか返ってきませんでしたが…

そして注目すべきは返り値がことごとくPromiseだというところです。start関数にasyncが付けてあるのはここでawaitを使って解決するためです。

いかにもモダンなJavascriptAPIという感じです。

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と略したり、varletの変数宣言の後に型を置いたり、返り値を->で宣言するあたり最近出てきた言語らしい感じがします。

シェーダー言語ならではの部分として、シェーダー実行時にシステムがよしなに設定してくれる値をあらわすIO attributeと呼ばれるものがあります。今回使っているのは2種類で、 おなじみのgl_PositionSV_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ではプロパティと値どちらも未知のものがあるとエラーが出るようになっていました。

設定項目は各ステージ毎に使いたいshaderModuleentryPointの関数名、出力したい形状(primitive)、テクスチャなどのレイアウト情報などです。

fragmentステージのtargetsは今時マルチレンダーターゲットが珍しくないからか配列で設定します。フラグメントシェーダーの出力のlocation(number)の数値と配列のインデックスが対応する形になります。

また今回は頂点バッファもテクスチャも使わないのでlayoutautoとしておきました。

描画処理

今回は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();

GPUCommandEncoderGPURenderPassEncoderという似た名前のオブジェクトがあるので注意です。

一連の流れとしては次のようになっています。

  • GPUCommandEncoderを作成
    • GPUCommandEncoder各attachmentなどを設定してRenderPassを開始するとGPURenderPassEncoderが返ってくる
      • GPURenderPassEncoderに作っておいたGPURenderPipelineを設定
      • GPURenderPassEncoderdraw(num_verts)などの命令を発行
      • 終わったらend()
  • GPUCommandEncoderfinish()するとGPUCommandBufferが返される
  • 返されたGPUCommandBufferをdeviceのqueueに転送

RenderPassを開始する時の設定値も通常のJavascriptのオブジェクトです。 colorAttachmentsは配列で、RenderPipelineのfragmenttargetsに対応したテクスチャを設定します。 描画開始時の動作loadOp、終了時の動作storeOp、それから描画先のテクスチャから取得できるviewの設定が必要です。

GPUCommandEncoderはRenderPassを何度でも開始できます。 その都度GPURenderPassEncoderを取得して命令を発行、end()で終了を繰り返すことになります。

そしてWebGPUではGPUCommandBufferGPUDevicequeueに転送すればそれでおしまいです。 画面の更新などはWebブラウザが良きに計らってくれます。

まとめ

ありがちな感じでとりあえずWebGPUで三角形を描いてみました。 最近のトレンドをしっかり取り入れていて、なおかつWebブラウザで手軽に今風のGPU向けのAPIに触れられる、とても素敵な環境ではないでしょうか。

今回は本当に最小限だったので、そのうち頂点バッファやテクスチャを使ったものや、お楽しみのコンピュートシェーダーを取り上げられたらと思っています。

それではまたいずれ。

おまけ

最初のChromeの開発ブログに良い感じのリンク集があったので一部整理して転載しておきます。