🍵

WebGPUとC++でブラウザ上に三角ポリゴン描画してみる

2023/03/31に公開
1

はじめに

こんにちは。神戸電子専門学校 ゲーム技術研究部(技研部)部長の猫茶です。
ゲーム開発と技術研究・共有に勤しむ部活です。

さて、今回はWebブラウザ上でC++で描画してみようぜ! という記事です。
折角なので、次世代を見据えてWebGPUを使います。

最終的に出来上がるWebページはこちらです。
(ChromeとMicrosoft Edgeで動作確認しています)

https://mewmew-tea.github.io/WebGPUCppTriangle/

Windows環境で解説します。
そのほか使っている環境は以下の通りです。

※2023年12月7日 更新: Emscripten バージョン 3.1.30 -> 3.1.50に変更。該当バージョンで動作するように説明やソースを変更。

Chrome 119.0.6045.200
Emscripten 3.1.50
Python 3.9.13

ついでに、今回扱うものについて簡単に説明しておきます。

WebAssembly(Wasm)

Webブラウザ上で実行できる仮想マシン用命令セットアーキテクチャ。
C/C++などのネイティブ向けの言語でも、この形式にコンパイルすることでWebブラウザ上でも実行できます。

WebGPU

次世代Webブラウザ向け3Dグラフィックスライブラリおよびその規格。W3Cにより仕様策定中。
GPUを使った描画や計算ができます。

同様にWebブラウザで3D描画できるものとして、WebGL(2.0)があります。
WebGPUはその後継的な存在として、モダンなグラフィックスAPIに適応できるよう設計されています。

公式サンプルを見ると、できることが大体わかると思います。

https://webgpu.github.io/webgpu-samples

WasmとC++でHelloWorld

まずは、Wasm(WebAssembly)を使って、ブラウザ上でC++で書いた処理を実行してみます。

Emscriptenをインストール

VisualC++などではWasmへのコンパイルやビルドはできません。
そこで、Emscriptenというコンパイラを使います。

以下のコマンドでEmscriptenをインストールします。

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
git pull
emsdk install latest
emsdk activate latest

Emscriptenの環境変数を設定

引き続きコマンドプロンプト上で、さきほどのディレクトリで、

emsdk_env.bat

を実行してください。

ただし、これは一時的&このコマンドプロンプトでのみ有効です。
(毎度これを実行するのが面倒なら、手動でパスを通してあげるなどの必要があります。)

なので、このコマンドプロンプトは閉じずに置いておいてください。あとで使います。

C++書く

簡単なC++ソースを作成します。
任意のディレクトリに保存してください。
ファイル名は、main.cppとでもしておきます。

main.cpp
#include <iostream>

int main()
{
	std::cout << "Hello, Wasm!!" << std::endl;
	return 0;
}

ビルド

さきほどemsdk_env.batを実行したコマンドプロンプトで、main.cppのあるディレクトリへ移動してください。

そして、ビルドします。

em++ main.cpp -s WASM=1 -o index.html

これでindex.html index.js index.wasmが生成されたはずです。

ローカルサーバーを立てて実行

出力されたWebページは、htmlファイル(index.html)をただ開くだけではうまく動きません。
サーバーPC上にデプロイする必要があります。
(ちなみに、htmlファイルを普通に開くと、ずっと「Preparing...」と表示されて先に進んでくれません)

今回は、Pythonを使って動作確認用のローカルサーバーを立てます。

htmlファイルのある場所をカレントディレクトリにしてから、以下のコマンドを実行します。

python -m http.server 8000

8000の部分はポート番号なので、好きな番号に設定してもOKです。

つづいて、ブラウザを開いて、
http://localhost:8000/index.html
にアクセスします。
8000の部分はポート番号です。さきほどのpythonコマンドで指定したポート番号を入れてください。

ちゃんと実行されるはずです。すごい!!

ローカルサーバーを閉じるときは、コマンドプロンプト上でCtrl+Cを入力します。

もしビルドに成功しているのにうまく行かないときは、コンソール(ページ下 or F12キー)を確認して対応してください。
(個人的には、F12キーのブラウザのコンソールのほうが扱いやすいのでおすすめです)

ついでなので、同じネットワーク上のスマホなどで確認する方法も以下に折りたたんでおきます。
スマホのChromeのWebGPU対応はまだ先のようですが、今作ったWasmを使ってるだけのものなら動作するはずです。

同じネットワーク上の別デバイスで接続するとき

コマンドプロンプトで ipconfigコマンドを実行して、IPアドレスを確認します。
(大抵は 192.168.xxx.xxx のはず)
先述のURLのlocalhostのかわりに、IPアドレスを入れたURLにアクセス。
(例:http://192.168.123.45:8000/index.html

WebGPU

つづいて、WebGPUでの描画に移ります。
長いので、要所要所で解説入れていきます。
サンプルソース全体は、本記事の最後のほうに載せています。

WebGPUは、次世代Webブラウザ向け3Dグラフィックスライブラリです。
Webブラウザ上でGPUを活用した描画や計算ができます。

グラフィックスAPIのように扱えますが、内部的にはDirectX12やVulkan、MetalなどのグラフィックスAPIを呼び出しています。
WebGPUはこれらのモダンなグラフィックスAPIを効果的に扱えるように設計されています。

WebGPUの背景

私がWebGPUについての背景を知らず、調べる際に苦労したので触れておきます。

Chrome(Chromium)では「Dawn(https://dawn.googlesource.com/dawn )」という実装が組み込まれていて、これを使ってWebGPUを使ったWebアプリケーションが実行されます。

Firefoxでは「wgpu( https://wgpu.rs/ )」という実装が組み込まれています。

Dawnもwgpuも、Webブラウザだけでなく、通常のC++やRustのネイティブアプリケーション向けにも使うことができます。
これを使えば、Web用に作ったものとほぼ同じソースコードで、マルチプラットフォームに対応したネイティブアプリケーションを作成できます。

環境構築(WebGPUを実行できるブラウザ)

ChromeCanaryの導入説明(Chromeバージョン113以降を使っている場合は不要であるため折りたたんでいます)

実はこのWebGPU、本記事の執筆当時(2023年3月ごろ)はまだ正式リリースではありません。
そのため、特殊な環境や設定が必要です。

今回は「Chrome Canary」という、Chromeの開発者向けバージョンを使います。
(Origin-Trialに登録してTokenを使うという手段もありますが、今回は割愛します。記事執筆地点で正式リリースも近いので、じきにお役御免ですし…)

↓ここからDL&インストールしてください。

https://www.google.com/intl/ja/chrome/canary/

インストールできたら、
chrome://flags/#enable-unsafe-web
にアクセスして、「Unsafe WebGPU」をEnabledに設定してください。

これで設定完了です。

今後はこのChromeCanaryを使っていきます。

webgpu_cpp.h

emscriptenのWebGPUのヘッダーファイルには、webgpu.hwebgpu_cpp.hの2種類があります。
前者がC言語向け、後者がC++言語向けです。
webgpu_cpp.hwebgpu.hをC++らしくラッピングしたものです。
なんと、デストラクタで解放処理までやってくれます。
今回はこちらのwebgpu_cpp.hを使います。

初期化処理の流れ

EmscriptenとWebGPUを用いた初期化処理は下図のようになります。
Adapter取得とDevice取得は非同期処理です。

また、処理の順番は
Adapter取得→Device取得→そのほか初期化処理
のように行う必要があります。
CallBackを利用して、それぞれの処理が完了してから次の処理を行うようにします。

アダプターとデバイス

Adapterとは、物理的なGPUハードウェアのことを指します。
DirectXのAdapterと同様に、どのAdapterを取得するかでどのGPUハードウェアを使うかを指定することができます。

DeviceはGPUの機能を提供します。
まずは、このDeviceを取得することでスタートラインに立てるということです。

先述のように、Adapter取得→Device取得→そのほか初期化処理の順番を守って取得します。
Adapter取得とDevice取得は非同期処理なので、CallBackを活用して、それぞれの処理が完了してから次の処理を行うようにします。

wgpu::Instance instance;

void getDevice(void (*callback)(wgpu::Device)) {
    // instanceの作成
    WGPUInstance wgpuInstance = wgpuCreateInstance(nullptr);
    instance = wgpu::Instance::Acquire(wgpuInstance);

    wgpuInstanceRequestAdapter(wgpuInstance, nullptr, [](WGPURequestAdapterStatus status, WGPUAdapter adapter, const char* message, void* userdata) {
        if (message) {
            printf("wgpuInstanceRequestAdapter: %s\n", message);
        }
        if (status == WGPURequestAdapterStatus_Unavailable) {
            printf("WebGPU unavailable; exiting cleanly\n");
            exit(0);
        }
        assert(status == WGPURequestAdapterStatus_Success);

        wgpuAdapterRequestDevice(adapter, nullptr, [](WGPURequestDeviceStatus status, WGPUDevice dev, const char* message, void* userdata) {
            if (message) {
                printf("wgpuAdapterRequestDevice: %s\n", message);
            }
            assert(status == WGPURequestDeviceStatus_Success);

            wgpu::Device device = wgpu::Device::Acquire(dev);
            reinterpret_cast<void (*)(wgpu::Device)>(userdata)(device);
        }, userdata);
    }, reinterpret_cast<void*>(callback));
}

残りの初期化処理(run関数)はコールバックで呼ばれるようにしておきます。

void run() {
    device.SetUncapturedErrorCallback(
        [](WGPUErrorType errorType, const char* message, void*) {
            printf("%d: %s\n", errorType, message);
        }, nullptr);
    
    queue = device.GetQueue();

    initRenderPipelineAndBuffers();
    initSwapChain();
    initDepthStencil();

    // メインループ設定
    emscripten_set_main_loop(frame, 0, false);
}

int main() {
    // GetDeviceのあとに残りの処理を行いたいので、コールバックしてもらう必要がある
    getDevice([](wgpu::Device dev) {
        device = dev;
        run();
    });

    return 0;
}

run関数のはじめには、エラー時のコールバックの登録と、queueの取得をしています。(queueについては後述)

メインループの登録

上記ソースのrun関数の最後、emscripten_set_main_loopの部分です。

この環境でのメインループは少し特殊です。
メインループは、登録した関数が自動で1フレーム分の処理としてに呼び出されることで動きます。
つまり、自分でメインループを回すことはなく、メインループが勝手に回るといった形です。

emscripten_set_main_loopでfpsを指定しない(0 or 0未満を指定する)場合、BrowserのrequestAnimationFrameが使われます。これはEmscriptenの公式リファレンスでも、レンダリングを行うときに推奨と書かれています

パイプラインとバッファー

WebGPUにもビルトインのパイプラインはありません。
自力で組んでいきます。

シェーダーのコンパイルと作成

後述のシェーダーをコンパイルして、ShaderModuleを作成し、実行できる形式にします。

void initRenderPipelineAndBuffers() {
    //---------------------------------------
    // Shaderのコンパイル、ShaderModule作成
    //---------------------------------------
    wgpu::ShaderModule shaderModule{};

    wgpu::ShaderModuleWGSLDescriptor wgslDesc{};
    wgslDesc.code = shaderCode;

    wgpu::ShaderModuleDescriptor smDesc{};
    smDesc.nextInChain = &wgslDesc;
    shaderModule = device.CreateShaderModule(&smDesc);
    ...
}

各種バッファ

CPU側からシェーダー(GPU)側へ送信するデータ(Buffer)について、定義・作成していきます。

Vertex Buffer

頂点バッファです。頂点1つ1つについての情報を格納します。
今回は、頂点の座標と頂点カラーを入れます。

頂点1つあたりの構造体Vertexを定義します。

struct Vertex
{
    float pos[2] = { 0.0f, 0.0f };
    float color[3] = { 0.0f, 0.0f, 0.0f };
};

頂点情報も作っておきます。

// 頂点情報(座標と頂点色)
// 座標系はDirectXと同じY-Up
Vertex const vertData[] = {
{  0.8f, -0.8f, 0.0f, 1.0f, 0.0f },     // 右下
{ -0.8f, -0.8f, 0.0f, 0.0f, 1.0f },     // 左下
{ -0.0f,  0.8f, 1.0f, 0.0f, 0.0f },     // 上
};

Index Buffer

頂点インデックスです。
どの順番でどの頂点を並べるかを定義します。

uint16_t const indxData[] = {
	0, 1, 2
	, 0 // padding。各バッファのサイズは4byteの倍数である必要がある。
};

最後の1つはpaddingです。
WebGPUのバッファーについて、「書き込む際の総サイズは、必ず4byteの倍数でなければならない」 というルールがあります。
indxDatauint16_t型なので、3頂点で6byteです。この条件を満たしません。
これを解決するために、余分に1つ追加して8byteにしています。

Uniform Buffer

DirectXのConstantBuffer(定数バッファ)と同等のもの。
シェーダ(GPU)側では、グローバル定数のように振舞います。

今回はポリゴンの座標(オフセット)を入れてみます。

パッキング規則に気を付けながら、定数バッファの構造体を定義します。

  • サイズは16byteの倍数
  • 各変数は16byte境界を跨ぐことのないようにする
struct UbObject
{
    float pos[3] = { 0.0f, 0.0f, 0.0f };
    float padding;  // 構造体サイズが16byteの倍数になるように調整
};

バッファの作成

さきほど定義した各種バッファを、シェーダー(GPU)側でも作成していきます。

いずれのバッファも、作り方は共通しています。
なので、バッファ作成処理はcreateBuffer関数を作ってまとめておきます。
device.CreateBufferでバッファを作成して、queue.WriteBufferでバッファにデータを書き込みます。

wgpu::Buffer createBuffer(const void* data, size_t size, wgpu::BufferUsage usage) {
	wgpu::BufferDescriptor desc = {};
	desc.usage = wgpu::BufferUsage::CopyDst | usage;
	desc.size  = size;
	wgpu::Buffer buffer = device.CreateBuffer(&desc);
	queue.WriteBuffer(buffer, 0, data, size);
	return buffer;
}
void initRenderPipelineAndBuffers() {
	...
	// vertex buffer
	vertexBuffer = createBuffer(vertData, sizeof(vertData), wgpu::BufferUsage::Vertex);
	// index buffer
	indexBuffer = createBuffer(indxData, sizeof(indxData), wgpu::BufferUsage::Index);

	// Uniform buffer
	uniformBuffer = createBuffer(&ubObject, sizeof(ubObject), wgpu::BufferUsage::Uniform);
	...
}

BindGroupLayoutとBindGroup

BindGroupは、シェーダー側で扱う、バッファー(Uniform)やサンプラー、テクスチャなどのリソースと、その使われ方をまとめたものです。
BindGroupに情報を割り当てておいて、描画する際にpass.SetBindGroupでシェーダー(GPU側)へセットします。

BindGroupLayoutは、BindGroupについての抽象的な情報で、レンダリングパイプラインにあらかじめセットしておきます。
BindGroupLayoutでの定義情報は、BindGroupと一致するように書きます。

ここでは、UniformBufferについて定義していきます。

void initRenderPipelineAndBuffers() {
	...
	//---------------------------------------
	// uniformBufferについての
	// group layoutとbind group
	//---------------------------------------
	// bind group layout
	// bind groupの抽象的な情報。render pipelineに覚えてもらうために、ここで作成&登録する。
	wgpu::BufferBindingLayout buf = {};
	buf.type = wgpu::BufferBindingType::Uniform;
	wgpu::BindGroupLayoutEntry bglEntry = {};
	bglEntry.binding = 0;   // シェーダーソース上の「@binding(0)」に割り当てられる。
	bglEntry.visibility = wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment;
	bglEntry.buffer = buf;
	wgpu::BindGroupLayoutDescriptor bglDesc{};
	bglDesc.entryCount = 1;
	bglDesc.entries = &bglEntry;
	wgpu::BindGroupLayout bgl = device.CreateBindGroupLayout(&bglDesc);
	
	// bind group
	wgpu::BindGroupEntry bgEntry = {};
	bgEntry.binding = 0;
	bgEntry.buffer = uniformBuffer;
	bgEntry.offset = 0;
	bgEntry.size = sizeof(ubObject);

	wgpu::BindGroupDescriptor bgDesc;
	bgDesc.layout = bgl;
	bgDesc.entryCount = 1;
	bgDesc.entries = &bgEntry;
	bindGroup = device.CreateBindGroup(&bgDesc);
	...
}

render pipeline

いよいよパイプラインの構築です。

vertex

VertexAttributeで、頂点情報(構造体Vertex)の要素1つずつについて、型情報やオフセット、セット先について記述します。
VertexBufferLayoutで、頂点情報全体についての情報を記述します。
VertexStateで、構造体Vertexを送る先のシェーダーについての情報を記述します。

void initRenderPipelineAndBuffers() {
	...
	// vertex
	wgpu::VertexAttribute vertAttrs[2] = {};
	vertAttrs[0].format = wgpu::VertexFormat::Float32x2;
	vertAttrs[0].offset = 0;
	vertAttrs[0].shaderLocation = 0;
	vertAttrs[1].format = wgpu::VertexFormat::Float32x3;
	vertAttrs[1].offset = 2 * sizeof(float);
	vertAttrs[1].shaderLocation = 1;
	wgpu::VertexBufferLayout vertexBufferLayout = {};
	vertexBufferLayout.arrayStride = 5 * sizeof(float);
	vertexBufferLayout.attributeCount = 2;
	vertexBufferLayout.attributes = vertAttrs;
	wgpu::VertexState vertexState = {};
	vertexState.module = shaderModule;
	vertexState.entryPoint = "main_v";
	vertexState.bufferCount = 1;
	vertexState.buffers = &vertexBufferLayout;
	...
}

fragment

描画時の色を決定する処理まわりを指定します。
BlendStateで、どのように色やアルファ値をブレンドするかを指定します。
operationで加算や減算などをの計算方法を指定し、srcFactor/dstFactorで計算時の係数を指定できます。
今回は、ただの加算合成として作成します。

fragmentShaderについての情報もここに記述しています。

void initRenderPipelineAndBuffers() {
	...
	// fragment and blendState
	wgpu::BlendState blend = {};
	blend.color.operation = wgpu::BlendOperation::Add;
	blend.color.srcFactor = wgpu::BlendFactor::SrcAlpha;
	blend.color.dstFactor = wgpu::BlendFactor::OneMinusDstAlpha;
	blend.alpha.operation = wgpu::BlendOperation::Add;
	blend.alpha.srcFactor = wgpu::BlendFactor::One;
	blend.alpha.dstFactor = wgpu::BlendFactor::OneMinusDstAlpha;
	wgpu::ColorTargetState colorTargetState{};
	colorTargetState.format = wgpu::TextureFormat::BGRA8Unorm;
	colorTargetState.blend = &blend;
	wgpu::FragmentState fragmentState{};
	fragmentState.module = shaderModule;
	fragmentState.entryPoint = "main_f";
	fragmentState.targetCount = 1;
	fragmentState.targets = &colorTargetState;
	...
}

DepthStencilState

DepthStencilStateで、深度ステンシルバッファについての情報も定義しておきます。

void initRenderPipelineAndBuffers() {
	...
	// DepthStencilState
	wgpu::DepthStencilState depthStencilState{};
	depthStencilState.format = wgpu::TextureFormat::Depth24PlusStencil8;
	depthStencilState.depthWriteEnabled = true;
	depthStencilState.depthCompare = wgpu::CompareFunction::Less;
	...
}

render pipelineの作成

さきほどまで作成したものをもとに、render pipelineの作成をします。

また、ここでポリゴン(primitive)の描画時の裏表カリングや、PrimitiveTopologyの指定などを行います。
今回は、頂点が右回り(CW)に並んでいる面を表として、裏面をカリング、渡される頂点はTriangleList(3つずつ連続的に扱う)としています。

void initRenderPipelineAndBuffers() {
	...
	// render pipelineの作成
	wgpu::PipelineLayoutDescriptor pllDesc{}; // bindGroupLayoutをまとめたもの
	pllDesc.bindGroupLayoutCount = 1;
	pllDesc.bindGroupLayouts = &bgl;
	wgpu::RenderPipelineDescriptor descriptor{};
	descriptor.layout = device.CreatePipelineLayout(&pllDesc);
	descriptor.vertex = vertexState;
	descriptor.fragment = &fragmentState;
	descriptor.primitive.frontFace = wgpu::FrontFace::CW;   // 表面とするときの頂点の並び順。
								// デフォルトはCCW(反時計回り)
	descriptor.primitive.cullMode = wgpu::CullMode::Back;   // カリングする面(表裏)
	descriptor.primitive.topology = wgpu::PrimitiveTopology::TriangleList;
	descriptor.primitive.stripIndexFormat = wgpu::IndexFormat::Undefined;
	descriptor.depthStencil = &depthStencilState;

	pipeline = device.CreateRenderPipeline(&descriptor);
}

シェーダー

WebGPUでのシェーディング言語は、「WGSL(WebGPU Shading Language)」です。
文法はRustに似ています。

本来なら外部ファイルにするべきですが、今回はこれもmain.cppに文字列としてハードコーディングします。

const char shaderCode[] = R"(
    struct VSIn {
        @location(0) Pos : vec2<f32>,
	@location(1) Color : vec3<f32>,
    };
    struct VSOut {
        @builtin(position) Pos: vec4<f32>,
        @location(0) Color: vec3<f32>,
    };
    struct UbObject
    {
        Pos : vec2<f32>,
        Padding : vec2<f32>,
    };
    @group(0) @binding(0) var<uniform> ubObject : UbObject;

    @vertex
    fn main_v(In: VSIn) -> VSOut {
        var out: VSOut;
        out.Pos = vec4<f32>(In.Pos + ubObject.Pos, 0.0, 1.0);
        out.Color = In.Color;
        return out;
    }
    @fragment
    fn main_f(In : VSOut) -> @location(0) vec4<f32> {
        return vec4<f32>(In.Color, 1.0f);
    }
)";

属性

@が付いているものは、関数や変数についての属性です。
DirectXにおけるHLSLのセマンティクスに似たものです。
属性一覧:https://www.w3.org/TR/WGSL/#attributes

builtin属性

@builtin属性は、WebGPUでビルトインとして組み込まれているものに付与します。
builtin属性の一覧:https://www.w3.org/TR/WGSL/#builtin-values

今回使っている@builtin(position)は、VertexShaderの出力とFragmentShaderの入力において、ラスタライザが正規化デバイス座標空間上の座標として扱う座標に付けます。
DirectXのHLSLでのSV_POSITONにあたります。

location属性

@location属性は、f32 vec2<f32> vec3<f32> vec4<f32>など、数値のベクトルやスカラー(数値単体)に付けます。
ComputeShader以外のエントリポイントの関数または構造体のメンバに付けることができます。
ちなみにベクトルやスカラーなので、行列(mat4x4<f32>など)には使えません。

group属性とbinding属性

どちらもUniformBufferなどのリソースについてのBindGroupに関連するものです。

@group属性は、BindGroupごとに割り振られた番号です。
どのBindGroupであるかを明示します。

@binding属性は、BindGroupの要素(Entry)ごとに割り振られた番号です。
どの要素(Entry)であるかを明示します。
BindGroupLayoutEntryBindGroupEntryのメンバbindingに設定した値が使われます。

シェーダステージ属性

main_vmain_f関数に、@vertex @fragmentとあるのは、シェーダーステージ属性です。
それぞれのシェーダーステージにおける、エントリポイントを明示します。
ほかにCompute Shaderのエントリポイントを示す@compute属性もあるようです。

Vertex Shader

頂点シェーダ。
頂点の位置などを決定します。
今回は、Uniform Bufferに格納した座標のオフセット値分だけずらすという処理にしています。

計算結果は、構造体VSOutに格納して返します。
これはラスタライザで調整された上で、ピクセルシェーダに渡されます。

Fragment Shader

ピクセルの色を決定します。
UnityやOpenGLとかならお馴染みですが、DirectXではPixelShaderと呼ばれてるやつです。

構造体VSOutが、頂点シェーダから出力されて、それをラスタライザが整えてくれた状態で渡されます。
これをもとに、ピクセルの色を決定します。

SwapChainとDepthStencil作成

Surfaceの取得

描画したいWebページ上のCanvasを取得して、描画先であるSurfaceを作成します。

void initSwapChain() {
	// HTMLページ内の、Canvas要素からSurfaceを作成
	wgpu::SurfaceDescriptorFromCanvasHTMLSelector canvasDesc{};
	canvasDesc.selector = "#canvas";

	wgpu::SurfaceDescriptor surfDesc{};
	surfDesc.nextInChain = &canvasDesc;
	surface = instance.CreateSurface(&surfDesc);
	...
}

SwapChain

さきほど作成したSurfaceに対して、SwapChainを作成します。

void initSwapChain() {
	...
	// SwapChain作成
	wgpu::SwapChainDescriptor scDesc{};
	scDesc.usage = wgpu::TextureUsage::RenderAttachment;
	scDesc.format = wgpu::TextureFormat::BGRA8Unorm;
	scDesc.width = kWidth;
	scDesc.height = kHeight;
	scDesc.presentMode = wgpu::PresentMode::Fifo;
	swapChain = device.CreateSwapChain(surface, &scDesc);
}

DepthStencil

Zバッファ法による前後判定で、深度(奥行き)情報を保存するテクスチャです。
今回は3Dポリゴン1枚だけ描画しているので意味がないですが、作っておきます。
また、テクスチャのフォーマットを wgpu::TextureFormat::Depth24PlusStencil8 を指定することで、同じテクスチャでステンシルバッファも兼ねさせています。

void initDepthStencil() {
	// DepthStencil作成
	wgpu::TextureDescriptor descriptor{};
	descriptor.usage = wgpu::TextureUsage::RenderAttachment;
	descriptor.size = {kWidth, kHeight, 1};
	descriptor.format = wgpu::TextureFormat::Depth24PlusStencil8;
	canvasDepthStencilView = device.CreateTexture(&descriptor).CreateView();
}

描画

ついに描画です。
frame関数に書いていきます。
この関数はEmscriptenによってフレームループとして呼ばれます。

バックバッファとDepthStencilバッファのクリアと設定

直前フレームで描画した結果が残り続けてはいけないので、描画処理を行う前にクリアをしておきます。

void frame() {
	// バックバッファとDepthStencilViewのクリア
	wgpu::TextureView backbuffer = swapChain.GetCurrentTextureView();
	wgpu::RenderPassColorAttachment attachment{};
	attachment.view = backbuffer;
	attachment.loadOp = wgpu::LoadOp::Clear;
	attachment.storeOp = wgpu::StoreOp::Store;
	attachment.clearValue = {0, 0, 0, 1};

	wgpu::RenderPassDescriptor renderpass{};
	renderpass.colorAttachmentCount = 1;
	renderpass.colorAttachments = &attachment;

	wgpu::RenderPassDepthStencilAttachment depthStencilAttachment = {};
	depthStencilAttachment.view = canvasDepthStencilView;
	depthStencilAttachment.depthClearValue = 1.0f;
	depthStencilAttachment.depthLoadOp = wgpu::LoadOp::Clear;
	depthStencilAttachment.depthStoreOp = wgpu::StoreOp::Store;
	depthStencilAttachment.stencilClearValue = 0;
	depthStencilAttachment.stencilLoadOp = wgpu::LoadOp::Clear;
	depthStencilAttachment.stencilStoreOp = wgpu::StoreOp::Store;
	renderpass.depthStencilAttachment = &depthStencilAttachment;
	...
}

描画開始

WebGPUにおける各種命令タスクは、「Command」という単位で処理されます。
このCommandたちを「Queue」というタスク記録リストに格納してから、まとめてGPU側へ送信(Submit)することで、描画等を実行することができます。

また、Commandの集合体「CommandBuffer」は、「CommandEncoder」を使って作成していきます。

これらの概念は、DirectX12などの近年のグラフィックスAPIのものに似ています。

void frame() {
	...
	wgpu::CommandBuffer commands;
	    {
		wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
		{
			...
}

RenderPass開始~終了

RenderPassは、encoder.BeginRenderPassで開始して、pass.End();で終了します。
この間に、描画やバッファの書き込みなどの処理を行います。

ついでに、uniform bufferの更新とシェーダー(GPU)側への再書き込みもしています。
今回は、ひたすら左右に往復させ続けるだけのシンプルな処理にしてみました。

あとは、初期化時に作成した、Pipeline、UniformBufferのBindGroup、Vertex/IndexBufferをセットして、DrawIndexedで描画させます。

最後にpass.End()で、RenderPassを終了させます。

void frame() {
	...
	wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderpass);

	// uniform buffer更新&書き込み
	static float speed = 0.015f;
	ubObject.pos[0] += speed;
	if (ubObject.pos[0] > (1.0f - 0.8f) || ubObject.pos[0] < -(1.0f - 0.8f))
	{
		speed = -speed;
	}
	queue.WriteBuffer(uniformBuffer, 0, &ubObject, sizeof(ubObject));

	pass.SetPipeline(pipeline);
	pass.SetBindGroup(0, bindGroup, 0, 0);
	pass.SetVertexBuffer(0, vertexBuffer, 0, WGPU_WHOLE_SIZE);
	pass.SetIndexBuffer(indexBuffer, wgpu::IndexFormat::Uint16, 0, WGPU_WHOLE_SIZE);
	pass.DrawIndexed(3, 1, 0, 0, 0);
	pass.End();
	...
}

描画の終了

encoder.Finish()で、CommandEncoderを使って、RenderPassに関連するCommandBufferを作成します。
その後、queue.Submit(1, &commands)で、作成したCommandを転送します。
これで描画処理も完成です!!

void frame() {
	...
	   }
        commands = encoder.Finish();
    }
    queue.Submit(1, &commands);
}

ビルド

完成したので動かしてみます。
今度は、USE_WEBGPUオプションを有効化してビルドします。

em++ main.cpp -s WASM=1 -s USE_WEBGPU=1 -o index.html

先ほどと同様に、ローカルサーバを立ち上げて動作を確認してみてください。
ちゃんと左右に三角ポリゴンが揺れていたら成功です!
お疲れ様でした。

(私が使ってるラップトップPCのモニタが240Hzなので、多めに更新処理が掛かって超高速になっちゃってます…)

ソースコード全文

長いので折りたたんでいます。

また、ソースコードなどをGitHubにも置いておきました。
https://github.com/mewmew-tea/WebGPUCppTriangle

ソースコード全文
// Based and inspired by https://github.com/cwoffenden/hello-webgpu
// and https://github.com/emscripten-core/emscripten/blob/main/test/webgpu_basic_rendering.cpp

#include <webgpu/webgpu_cpp.h>

#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <memory>

#include <emscripten.h>
#include <emscripten/html5.h>
#include <emscripten/html5_webgpu.h>

struct Vertex
{
    float pos[2] = { 0.0f, 0.0f };
    float color[3] = { 0.0f, 0.0f, 0.0f };
};

struct UbObject
{
    float pos[2] = { 0.0f, 0.0f };
    float padding[2];  // 構造体サイズが16byteの倍数になるように調整
};


const char shaderCode[] = R"(
    struct VSIn {
        @location(0) Pos : vec2<f32>,
		@location(1) Color : vec3<f32>,
    };
    struct VSOut {
        @builtin(position) Pos: vec4<f32>,
        @location(0) Color: vec3<f32>,
    };
    struct UbObject
    {
        Pos : vec2<f32>,
        Padding : vec2<f32>,
    };
    @group(0) @binding(0) var<uniform> ubObject : UbObject;

    @vertex
    fn main_v(In: VSIn) -> VSOut {
        var out: VSOut;
        out.Pos = vec4<f32>(In.Pos + ubObject.Pos, 0.0, 1.0);
        out.Color = In.Color;
        return out;
    }
    @fragment
    fn main_f(In : VSOut) -> @location(0) vec4<f32> {
        return vec4<f32>(In.Color, 1.0f);
    }
)";

wgpu::Instance instance;

wgpu::Device device;
wgpu::Queue queue;
wgpu::Buffer readbackBuffer;
wgpu::RenderPipeline pipeline;
wgpu::Surface surface;

wgpu::SwapChain swapChain;
wgpu::TextureView canvasDepthStencilView;
const uint32_t kWidth = 1280;
const uint32_t kHeight = 720;

wgpu::Buffer vertexBuffer; // vertex buffer
wgpu::Buffer indexBuffer; // index buffer
wgpu::Buffer uniformBuffer; // uniform buffer

wgpu::BindGroup bindGroup;

UbObject ubObject;

wgpu::Buffer createBuffer(const void* data, size_t size, wgpu::BufferUsage usage) {
	wgpu::BufferDescriptor desc = {};
	desc.usage = wgpu::BufferUsage::CopyDst | usage;
	desc.size  = size;
	wgpu::Buffer buffer = device.CreateBuffer(&desc);
	queue.WriteBuffer(buffer, 0, data, size);
	return buffer;
}

void initRenderPipelineAndBuffers() {
    //---------------------------------------
    // Shaderのコンパイル、ShaderModule作成
    //---------------------------------------
    wgpu::ShaderModule shaderModule{};

    wgpu::ShaderModuleWGSLDescriptor wgslDesc{};
    wgslDesc.code = shaderCode;

    wgpu::ShaderModuleDescriptor smDesc{};
    smDesc.nextInChain = &wgslDesc;
    shaderModule = device.CreateShaderModule(&smDesc);
    
    //---------------------------------------
    // bufferの作成
    //---------------------------------------
    // 頂点情報(座標と頂点色)
    // 座標系はDirectXと同じY-Up
    Vertex const vertData[] = {
        {  0.8f, -0.8f, 0.0f, 1.0f, 0.0f },     // 右下
        { -0.8f, -0.8f, 0.0f, 0.0f, 1.0f },     // 左下
        { -0.0f,  0.8f, 1.0f, 0.0f, 0.0f },     // 上
    };
    // 頂点インデックス情報
    uint16_t const indxData[] = {
        0, 1, 2
        , 0 // padding。各バッファのサイズは4byteの倍数である必要がある。
    };

    // vertex buffer
    vertexBuffer = createBuffer(vertData, sizeof(vertData), wgpu::BufferUsage::Vertex);
    // index buffer
    indexBuffer = createBuffer(indxData, sizeof(indxData), wgpu::BufferUsage::Index);
    // Uniform buffer
    uniformBuffer = createBuffer(&ubObject, sizeof(ubObject), wgpu::BufferUsage::Uniform);
    
    //---------------------------------------
    // uniformBufferについての
    // group layoutとbind group
    //---------------------------------------
    // bind group layout
    // bind groupの抽象的な情報。render pipelineに覚えてもらうために、ここで作成&登録する。
    wgpu::BufferBindingLayout buf = {};
    buf.type = wgpu::BufferBindingType::Uniform;
    wgpu::BindGroupLayoutEntry bglEntry = {};
    bglEntry.binding = 0;   // シェーダーソース上の「@binding(0)」に割り当てられる。
    bglEntry.visibility = wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment;
    bglEntry.buffer = buf;
    wgpu::BindGroupLayoutDescriptor bglDesc{};
    bglDesc.entryCount = 1;
    bglDesc.entries = &bglEntry;
    wgpu::BindGroupLayout bgl = device.CreateBindGroupLayout(&bglDesc);

    // bind group
    wgpu::BindGroupEntry bgEntry = {};
    bgEntry.binding = 0;
    bgEntry.buffer = uniformBuffer;
    bgEntry.offset = 0;
    bgEntry.size = sizeof(ubObject);

    wgpu::BindGroupDescriptor bgDesc;
    bgDesc.layout = bgl;
    bgDesc.entryCount = 1;
    bgDesc.entries = &bgEntry;
    bindGroup = device.CreateBindGroup(&bgDesc);

    //---------------------------------------
    // render pipelineの作成
    //---------------------------------------
    // vertex
    wgpu::VertexAttribute vertAttrs[2] = {};
    vertAttrs[0].format = wgpu::VertexFormat::Float32x2;
    vertAttrs[0].offset = 0;
    vertAttrs[0].shaderLocation = 0;
    vertAttrs[1].format = wgpu::VertexFormat::Float32x3;
    vertAttrs[1].offset = 2 * sizeof(float);
    vertAttrs[1].shaderLocation = 1;
    wgpu::VertexBufferLayout vertexBufferLayout = {};
    vertexBufferLayout.arrayStride = 5 * sizeof(float);
    vertexBufferLayout.attributeCount = 2;
    vertexBufferLayout.attributes = vertAttrs;
    wgpu::VertexState vertexState = {};
    vertexState.module = shaderModule;
    vertexState.entryPoint = "main_v";
    vertexState.bufferCount = 1;
    vertexState.buffers = &vertexBufferLayout;

    // fragment and blendState
    wgpu::BlendState blend = {};
    blend.color.operation = wgpu::BlendOperation::Add;
    blend.color.srcFactor = wgpu::BlendFactor::SrcAlpha;
    blend.color.dstFactor = wgpu::BlendFactor::OneMinusDstAlpha;
    blend.alpha.operation = wgpu::BlendOperation::Add;
    blend.alpha.srcFactor = wgpu::BlendFactor::One;
    blend.alpha.dstFactor = wgpu::BlendFactor::OneMinusDstAlpha;
    wgpu::ColorTargetState colorTargetState{};
    colorTargetState.format = wgpu::TextureFormat::BGRA8Unorm;
    colorTargetState.blend = &blend;
    wgpu::FragmentState fragmentState{};
    fragmentState.module = shaderModule;
    fragmentState.entryPoint = "main_f";
    fragmentState.targetCount = 1;
    fragmentState.targets = &colorTargetState;

    // DepthStencilState
    wgpu::DepthStencilState depthStencilState{};
    depthStencilState.format = wgpu::TextureFormat::Depth24PlusStencil8;
    depthStencilState.depthWriteEnabled = true;
    depthStencilState.depthCompare = wgpu::CompareFunction::Less;

    // render pipelineの作成
    wgpu::PipelineLayoutDescriptor pllDesc{}; // bindGroupLayoutをまとめたもの
    pllDesc.bindGroupLayoutCount = 1;
    pllDesc.bindGroupLayouts = &bgl;
    wgpu::RenderPipelineDescriptor descriptor{};
    descriptor.layout = device.CreatePipelineLayout(&pllDesc);
    descriptor.vertex = vertexState;
    descriptor.fragment = &fragmentState;
    descriptor.primitive.frontFace = wgpu::FrontFace::CW;   // 表面とするときの頂点の並び順。
                                                            // デフォルトはCCW(反時計回り)
    descriptor.primitive.cullMode = wgpu::CullMode::Back;   // カリングする面(表裏)
    descriptor.primitive.topology = wgpu::PrimitiveTopology::TriangleList;
    descriptor.primitive.stripIndexFormat = wgpu::IndexFormat::Undefined;
    descriptor.depthStencil = &depthStencilState;

    pipeline = device.CreateRenderPipeline(&descriptor);
}

void initSwapChain() {
    // HTMLページ内の、Canvas要素からSurfaceを作成
    wgpu::SurfaceDescriptorFromCanvasHTMLSelector canvasDesc{};
    canvasDesc.selector = "#canvas";

    wgpu::SurfaceDescriptor surfDesc{};
    surfDesc.nextInChain = &canvasDesc;
    surface = instance.CreateSurface(&surfDesc);

    // SwapChain作成
    wgpu::SwapChainDescriptor scDesc{};
    scDesc.usage = wgpu::TextureUsage::RenderAttachment;
    scDesc.format = wgpu::TextureFormat::BGRA8Unorm;
    scDesc.width = kWidth;
    scDesc.height = kHeight;
    scDesc.presentMode = wgpu::PresentMode::Fifo;
    swapChain = device.CreateSwapChain(surface, &scDesc);
}

void initDepthStencil() {
    // DepthStencil作成
    wgpu::TextureDescriptor descriptor{};
    descriptor.usage = wgpu::TextureUsage::RenderAttachment;
    descriptor.size = {kWidth, kHeight, 1};
    descriptor.format = wgpu::TextureFormat::Depth24PlusStencil8;
    canvasDepthStencilView = device.CreateTexture(&descriptor).CreateView();
}


void frame() {
    // バックバッファとDepthStencilのクリア
    wgpu::TextureView backbuffer = swapChain.GetCurrentTextureView();
    wgpu::RenderPassColorAttachment attachment{};
    attachment.view = backbuffer;
    attachment.loadOp = wgpu::LoadOp::Clear;
    attachment.storeOp = wgpu::StoreOp::Store;
    attachment.clearValue = {0, 0, 0, 1};

    wgpu::RenderPassDescriptor renderpass{};
    renderpass.colorAttachmentCount = 1;
    renderpass.colorAttachments = &attachment;

    wgpu::RenderPassDepthStencilAttachment depthStencilAttachment = {};
    depthStencilAttachment.view = canvasDepthStencilView;
    depthStencilAttachment.depthClearValue = 1.0f;
    depthStencilAttachment.depthLoadOp = wgpu::LoadOp::Clear;
    depthStencilAttachment.depthStoreOp = wgpu::StoreOp::Store;
    depthStencilAttachment.stencilClearValue = 0;
    depthStencilAttachment.stencilLoadOp = wgpu::LoadOp::Clear;
    depthStencilAttachment.stencilStoreOp = wgpu::StoreOp::Store;
    renderpass.depthStencilAttachment = &depthStencilAttachment;

    wgpu::CommandBuffer commands;
    {
        wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
        {
            wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderpass);

            // uniform buffer更新&書き込み
            static float speed = 0.015f;
            ubObject.pos[0] += speed;
            if (ubObject.pos[0] > (1.0f - 0.8f) || ubObject.pos[0] < -(1.0f - 0.8f))
            {
                speed = -speed;
            }
            queue.WriteBuffer(uniformBuffer, 0, &ubObject, sizeof(ubObject));

            pass.SetPipeline(pipeline);
            pass.SetBindGroup(0, bindGroup, 0, 0);
            pass.SetVertexBuffer(0, vertexBuffer, 0, WGPU_WHOLE_SIZE);
            pass.SetIndexBuffer(indexBuffer, wgpu::IndexFormat::Uint16, 0, WGPU_WHOLE_SIZE);
            pass.DrawIndexed(3, 1, 0, 0, 0);
            pass.End();
        }
        commands = encoder.Finish();
    }

    queue.Submit(1, &commands);
}

void run() {
    device.SetUncapturedErrorCallback(
        [](WGPUErrorType errorType, const char* message, void*) {
            printf("%d: %s\n", errorType, message);
        }, nullptr);
    
    queue = device.GetQueue();

    initRenderPipelineAndBuffers();
    initSwapChain();
    initDepthStencil();

    // メインループ設定
    emscripten_set_main_loop(frame, 0, false);
}

void getDevice(void (*callback)(wgpu::Device)) {
    // instanceの作成
    WGPUInstance wgpuInstance = wgpuCreateInstance(nullptr);
    instance = wgpu::Instance::Acquire(wgpuInstance);

    wgpuInstanceRequestAdapter(wgpuInstance, nullptr, [](WGPURequestAdapterStatus status, WGPUAdapter adapter, const char* message, void* userdata) {
        if (message) {
            printf("wgpuInstanceRequestAdapter: %s\n", message);
        }
        if (status == WGPURequestAdapterStatus_Unavailable) {
            printf("WebGPU unavailable; exiting cleanly\n");
            exit(0);
        }
        assert(status == WGPURequestAdapterStatus_Success);

        wgpuAdapterRequestDevice(adapter, nullptr, [](WGPURequestDeviceStatus status, WGPUDevice dev, const char* message, void* userdata) {
            if (message) {
                printf("wgpuAdapterRequestDevice: %s\n", message);
            }
            assert(status == WGPURequestDeviceStatus_Success);

            wgpu::Device device = wgpu::Device::Acquire(dev);
            reinterpret_cast<void (*)(wgpu::Device)>(userdata)(device);
        }, userdata);
    }, reinterpret_cast<void*>(callback));
}

int main() {
    // GetDeviceのあとに残りの処理を行いたいので、コールバックしてもらう必要がある
    getDevice([](wgpu::Device dev) {
        device = dev;
        run();
    });

    return 0;
}

おまけ1:VSCodeで楽にビルド&ローカルサーバ立ち上げ

VSCodeのtasks.jsonで、Crtl+Shift+Bで一発でビルドをしてくれるように設定します。
ついでに、ローカルサーバ立ち上げと、そのURLをクリップボードに貼り付けできるようにしてみました。

"command": "C:/Tools/emsdk/emsdk_env.bat ..... は、Emscritenのインストール先に応じて書き換えてください。

tasks.json
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "em++: build and serve",
            "type": "shell",
            "command": "C:/Tools/emsdk/emsdk_env.bat & em++ ${file} -s USE_WEBGPU=1 -s WASM=1 -o index.html & echo http://localhost:8000/index.html | clip & py -m http.server 8000",
            "group": {
                "kind": "build",
                "isDefault": true
            },
        }
    ]
}

また、settings.jsonでコマンドプロンプトを使うように設定しておきます。
(VSCodeは標準でPowerShellを使うため、この設定が必要です)

settings.json
...
	"terminal.integrated.defaultProfile.windows": "Command Prompt",
...

tasks.jsonとsettings.jsonは、VSCodeで開いているディレクトリに.vscodeディレクトリを作成し、その中に配置してください。
このあたりについての詳細は、VSCodeのtasks.jsonについて調べてください。解説記事が沢山あります。

おまけ2:device取得のアプローチ

本記事では、wgpuInstanceRequestAdapter wgpuAdapterRequestDeviceを使うことで、AdapterとDeviceの取得を行いました。
実はこれらの関数は、内部的にはJavaScriptの関数を呼び出しています。

なので、adapter/device取得をJavaScriptで自力で書くことでもできます。
また、JavaScript内で取得したDeviceは、Module["preinitializedWebGPUDevice"] にいったん格納しておいて、
あとからC/C++プログラム側で emscripten_webgpu_get_device関数を呼び出すことで回収することができます。

私が調べていて見つけたアプローチを3つ紹介します。

C++内にJavaScriptを自力で書く

https://github.com/cwoffenden/hello-webgpu/blob/main/src/ems/glue.cpp

EM_JSマクロによって、JavaScriptをC/C++ソースの中に含めることができます。
C/C++のプログラム本体(main.cpp__main__関数)の前に、Device取得までを行うJavaScriptの処理を入れているようです。

HTMLファイルにJavaScriptを自力で書く

Dear ImGuiのサンプルでの実装がこれにあたります。

https://github.com/ocornut/imgui/blob/b4b79584d138c305e4bff60486197821d93eac82/examples/example_emscripten_wgpu/web/index.html#L64-L69

JavaScriptを自力で書いてpost-jsに設定

Siv3Dでのアプローチです。
https://github.com/Siv3D/OpenSiv3D/blob/6cf5fc00f71cfc39883a2c57a71ad8f51bc69b5d/Web/Siv3D.post.js#L1-L22
https://github.com/Siv3D/OpenSiv3D/blob/6cf5fc00f71cfc39883a2c57a71ad8f51bc69b5d/Web/CMakeLists.txt#L45

Emscriptenのemcc/em++コンパイラでは、--post-jsオプションを使うことで、出力されるJavaScriptの末尾に任意のコードを挿入することができます。

https://emscripten.org/docs/tools_reference/emcc.html

これを使って、デバイス取得処理を挿入できます。

おわりに

WebGPUを使って三角ポリゴン描画という初歩的な部分に触れました。

WebGPUは、公式サンプル にもあるように、Compute Shaderまでも使えます。

また、本記事内で触れたように、ほぼ同じ実装でネイティブアプリケーションまで作れてしまいます。

さらに、2023年3月のGDCでは、同年5月のChrominium 113でWebGPUをリリース予定との言及もありました!
(動画 6:50 ごろ)
正式リリースは近い…?!

2023年5月3日追記:Chromeバージョン 113にて正式リリースされました!!
モバイルなどはまだのようですが…

https://youtu.be/IaOOn1cd0kY&t=408s

WebGPUという可能性に満ちた技術の今後の発展がとても楽しみです。

参考にしたもの

WebGPU(W3C公式のドキュメント)
https://www.w3.org/TR/webgpu/

WebGPU Shading Language(W3C公式のドキュメント)
https://www.w3.org/TR/WGSL/

pythonでローカルwebサーバを立ち上げる
https://qiita.com/okhrn/items/4d3c74563154f191ba16

EmscriptenのWebGPUテストコード
https://github.com/emscripten-core/emscripten/blob/main/test/webgpu_basic_rendering.cpp

"Hello, Triangle" WebGPU and Dawn - cwoffenden/hello-webgpu
https://github.com/cwoffenden/hello-webgpu

Learn Wgpu(Rust言語のwgpuという実装向けの解説ですが、WebGPUの各概念について丁寧に説明されています)
https://sotrh.github.io/learn-wgpu/

神戸電子専門学校ゲーム技術研究部

Discussion