WebGPU でアプリをビルドする

François Beaufort
François Beaufort

ウェブ デベロッパーにとって、WebGPU は GPU への統一された迅速なアクセスを提供するウェブ グラフィック API です。WebGPU は、Direct3D 12、Metal、Vulkan と同様に、最新のハードウェア機能を公開し、GPU 上でレンダリングと計算処理を行うことができます。

しかし、その話は完全とは言えません。WebGPU は、Apple、Google、Intel、Mozilla、Microsoft などの大手企業による共同の取り組みの成果です。中でも、WebGPU は JavaScript API ではなく、ウェブ以外のエコシステムのデベロッパーにとってクロス プラットフォームのグラフィック API になり得ると認識する方もいました。

主なユースケースに対応するため、Chrome 113 で JavaScript API が導入されました。ただし、これと並行して別の重要なプロジェクトである webgpu.h C API も開発されています。この C ヘッダー ファイルには、WebGPU で利用可能なすべてのプロシージャとデータ構造がリストされています。プラットフォームに依存しないハードウェア抽象化レイヤとして機能し、異なるプラットフォーム間で一貫したインターフェースを提供することで、プラットフォーム固有のアプリケーションを構築できます。

このドキュメントでは、WebGPU を使用して、ウェブと特定のプラットフォームの両方で動作する小規模な C++ アプリを作成する方法について説明します。今回はブラウザ ウィンドウとデスクトップ ウィンドウにそれぞれ赤い三角形が表示されますが、コードベースを少し調整するだけでこのようになります。

WebGPU による赤い三角形の macOS のブラウザ ウィンドウとデスクトップ ウィンドウのスクリーンショット。
ブラウザ ウィンドウとデスクトップ ウィンドウに表示された、WebGPU による同じ三角形。

メンバーシップの仕組み

完成したアプリケーションを確認するには、WebGPU クロスプラットフォーム アプリ リポジトリをご覧ください。

このアプリは C++ のシンプルな例で、WebGPU を使用して単一のコードベースからデスクトップ アプリとウェブアプリを構築する方法を示しています。内部では、WebGPU の webgpu.h が、webgpu_cpp.h という C++ ラッパーを介してプラットフォームに依存しないハードウェア抽象化レイヤとして使用されます。

ウェブの場合、アプリは JavaScript API の上に webgpu.h を実装するバインディングを持つ Emscripten に対してビルドされます。macOS や Windows などの特定のプラットフォームで、このプロジェクトを Chromium のクロス プラットフォーム WebGPU 実装である Dawn に対してビルドできます。wgpu-native(webgpu.h の Rust 実装)も存在しますが、このドキュメントでは使用しません。

始める

まず、C++ コンパイラと CMake で、標準的な方法でクロスプラットフォーム ビルドを処理する必要があります。専用のフォルダ内に、main.cpp ソースファイルと CMakeLists.txt ビルドファイルを作成します。

現時点では、main.cpp ファイルには空の main() 関数が含まれている必要があります。

int main() {}

CMakeLists.txt ファイルには、プロジェクトに関する基本情報が含まれています。最後の行では、実行可能ファイル名が「app」、そのソースコードが main.cpp であることを指定しています。

cmake_minimum_required(VERSION 3.13) # CMake version check
project(app)                         # Create project "app"
set(CMAKE_CXX_STANDARD 20)           # Enable C++20 standard

add_executable(app "main.cpp")

cmake -B build を実行して「build/」サブフォルダにビルドファイルを作成し、cmake --build build を実行して実際にアプリをビルドし、実行可能ファイルを生成します。

# Build the app with CMake.
$ cmake -B build && cmake --build build

# Run the app.
$ ./build/app

アプリは実行されますが、画面に何かを描画する必要があるため、出力はまだありません。

夜明けを迎える

三角形を描画するには、Chromium のクロス プラットフォームの WebGPU 実装である Dawn を利用できます。これには、画面に描画するための GLFW C++ ライブラリが含まれます。Dawn をダウンロードする方法の一つは、Dawn を git サブモジュールとしてリポジトリに追加することです。次のコマンドは、これを「dawn/」サブフォルダで取得します。

$ git init
$ git submodule add https://dawn.googlesource.com/dawn

次に、次のように CMakeLists.txt ファイルに追加します。

  • CMake の DAWN_FETCH_DEPENDENCIES オプションは、すべての Dawn 依存関係を取得します。
  • dawn/ サブフォルダはターゲットに含まれます。
  • アプリは webgpu_cppwebgpu_dawnwebgpu_glfw のターゲットに依存するため、後で main.cpp ファイルで使用できます。
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn webgpu_glfw)

ウィンドウを開く

Dawn が利用できるようになったので、GLFW を使って画面に何かを描画します。利便性のため webgpu_glfw に含まれているこのライブラリを使用すると、ウィンドウ管理に関してプラットフォームに依存しないコードを記述できます。

解像度 512x512 の「WebGPU window」という名前のウィンドウを開くには、以下のように main.cpp ファイルを更新します。ここでは glfwWindowHint() を使用して、特定のグラフィック API の初期化をリクエストしないことに留意してください。

#include <GLFW/glfw3.h>

const uint32_t kWidth = 512;
const uint32_t kHeight = 512;

void Start() {
  if (!glfwInit()) {
    return;
  }

  glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
  GLFWwindow* window =
      glfwCreateWindow(kWidth, kHeight, "WebGPU window", nullptr, nullptr);

  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    // TODO: Render a triangle using WebGPU.
  }
}

int main() {
  Start();
}

アプリをビルドし直して以前と同じように実行すると、ウィンドウが空になります。順調に進んでいます!

空の macOS ウィンドウのスクリーンショット。
空のウィンドウ。

GPU デバイスの取得

JavaScript では、navigator.gpu が GPU にアクセスするためのエントリ ポイントです。C++ では、同じ目的に使用する wgpu::Instance 変数を手動で作成する必要があります。便宜上、main.cpp ファイルの先頭で instance を宣言し、main() 内で wgpu::CreateInstance() を呼び出します。

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

int main() {
  instance = wgpu::CreateInstance();
  Start();
}

JavaScript API の形状により、GPU へのアクセスは非同期になります。C++ で、コールバック関数の引数を受け取り、結果の wgpu::Device で呼び出すヘルパー GetDevice() 関数を作成します。

#include <iostream>
…

void GetDevice(void (*callback)(wgpu::Device)) {
  instance.RequestAdapter(
      nullptr,
      [](WGPURequestAdapterStatus status, WGPUAdapter cAdapter,
         const char* message, void* userdata) {
        if (status != WGPURequestAdapterStatus_Success) {
          exit(0);
        }
        wgpu::Adapter adapter = wgpu::Adapter::Acquire(cAdapter);
        adapter.RequestDevice(
            nullptr,
            [](WGPURequestDeviceStatus status, WGPUDevice cDevice,
               const char* message, void* userdata) {
              wgpu::Device device = wgpu::Device::Acquire(cDevice);
              device.SetUncapturedErrorCallback(
                  [](WGPUErrorType type, const char* message, void* userdata) {
                    std::cout << "Error: " << type << " - message: " << message;
                  },
                  nullptr);
              reinterpret_cast<void (*)(wgpu::Device)>(userdata)(device);
            },
            userdata);
      },
      reinterpret_cast<void*>(callback));
}

アクセスを容易にするには、main.cpp ファイルの先頭で wgpu::Device 変数を宣言し、GetDevice() を呼び出すように main() 関数を更新し、Start() を呼び出す前に結果のコールバックを device に代入します。

wgpu::Device device;
…

int main() {
  instance = wgpu::CreateInstance();
  GetDevice([](wgpu::Device dev) {
    device = dev;
    Start();
  });
}

三角形を描く

スワップ チェーンはブラウザが処理するため、JavaScript API では公開されません。C++ では手動で作成する必要があります。ここでも、便宜上 main.cpp ファイルの先頭で wgpu::SwapChain 変数を宣言します。Start() で GLFW ウィンドウを作成した直後に、便利な wgpu::glfw::CreateSurfaceForWindow() 関数を呼び出して wgpu::Surface(HTML キャンバスに似ています)を作成し、それを使用して InitGraphics() で新しいヘルパー SetupSwapChain() 関数を呼び出してスワップ チェーンをセットアップします。また、swapChain.Present() を呼び出して while ループで次のテクスチャを表示する必要があります。まだレンダリングが行われていないため、目に見える効果はありません。

#include <webgpu/webgpu_glfw.h>
…

wgpu::SwapChain swapChain;

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

void InitGraphics(wgpu::Surface surface) {
  SetupSwapChain(surface);
}

void Render() {
  // TODO: Render a triangle using WebGPU.
}

void Start() {
  …
  wgpu::Surface surface =
      wgpu::glfw::CreateSurfaceForWindow(instance, window);

  InitGraphics(surface);

  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    swapChain.Present();
    instance.ProcessEvents();
  }
}

ここで、以下のコードを使用してレンダリング パイプラインを作成しましょう。アクセスを容易にするには、main.cpp ファイルの先頭で wgpu::RenderPipeline 変数を宣言し、InitGraphics() でヘルパー関数 CreateRenderPipeline() を呼び出します。

wgpu::RenderPipeline pipeline;
…

const char shaderCode[] = R"(
    @vertex fn vertexMain(@builtin(vertex_index) i : u32) ->
      @builtin(position) vec4f {
        const pos = array(vec2f(0, 1), vec2f(-1, -1), vec2f(1, -1));
        return vec4f(pos[i], 0, 1);
    }
    @fragment fn fragmentMain() -> @location(0) vec4f {
        return vec4f(1, 0, 0, 1);
    }
)";

void CreateRenderPipeline() {
  wgpu::ShaderModuleWGSLDescriptor wgslDesc{};
  wgslDesc.code = shaderCode;

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

  wgpu::ColorTargetState colorTargetState{
      .format = wgpu::TextureFormat::BGRA8Unorm};

  wgpu::FragmentState fragmentState{.module = shaderModule,
                                    .targetCount = 1,
                                    .targets = &colorTargetState};

  wgpu::RenderPipelineDescriptor descriptor{
      .vertex = {.module = shaderModule},
      .fragment = &fragmentState};
  pipeline = device.CreateRenderPipeline(&descriptor);
}

void InitGraphics(wgpu::Surface surface) {
  …
  CreateRenderPipeline();
}

最後に、各フレームを呼び出す Render() 関数で GPU にレンダリング コマンドを送信します。

void Render() {
  wgpu::RenderPassColorAttachment attachment{
      .view = swapChain.GetCurrentTextureView(),
      .loadOp = wgpu::LoadOp::Clear,
      .storeOp = wgpu::StoreOp::Store};

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

  wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
  wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderpass);
  pass.SetPipeline(pipeline);
  pass.Draw(3);
  pass.End();
  wgpu::CommandBuffer commands = encoder.Finish();
  device.GetQueue().Submit(1, &commands);
}

CMake でアプリを再ビルドして実行すると、ウィンドウに待ち望まれていた赤い三角形が表示されるようになりました。一息つきましょう。

macOS ウィンドウの赤い三角形のスクリーンショット。
デスクトップ ウィンドウの赤い三角形

WebAssembly にコンパイルする

では、既存のコードベースを調整して、ブラウザ ウィンドウでこの赤い三角形を描画するために必要な最小限の変更を見てみましょう。ここでも、このアプリは C/C++ プログラムを WebAssembly にコンパイルするツールである Emscripten に対してビルドされています。Emscripten には JavaScript API の上に webgpu.h を実装するバインディングがあります。

CMake 設定を更新する

Emscripten をインストールしたら、次のように CMakeLists.txt ビルドファイルを更新します。ハイライト表示されたコードのみ変更する必要があります。

  • set_target_properties を使用すると、「html」ファイル拡張子がターゲット ファイルに自動的に追加されます。「app.html」ファイルを生成します。
  • Emscripten で WebGPU サポートを有効にするには、USE_WEBGPU アプリリンク オプションが必要です。この権限がないと、main.cpp ファイルは webgpu/webgpu_cpp.h ファイルにアクセスできません。
  • GLFW コードを再利用できるように、ここでは USE_GLFW アプリリンク オプションも必要です。
cmake_minimum_required(VERSION 3.13) # CMake version check
project(app)                         # Create project "app"
set(CMAKE_CXX_STANDARD 20)           # Enable C++20 standard

add_executable(app "main.cpp")

if(EMSCRIPTEN)
  set_target_properties(app PROPERTIES SUFFIX ".html")
  target_link_options(app PRIVATE "-sUSE_WEBGPU=1" "-sUSE_GLFW=3")
else()
  set(DAWN_FETCH_DEPENDENCIES ON)
  add_subdirectory("dawn" EXCLUDE_FROM_ALL)
  target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn webgpu_glfw)
endif()

コードを更新する

Emscripten で wgpu::surface を作成するには、HTML キャンバス要素が必要です。そのためには、instance.CreateSurface() を呼び出し、Emscripten によって生成された HTML ページの適切な HTML キャンバス要素と一致する #canvas セレクタを指定します。

while ループを使用する代わりに emscripten_set_main_loop(Render) を呼び出して、ブラウザとモニタリングに合わせて適切なスムーズ レートで Render() 関数が呼び出されるようにします。

#include <GLFW/glfw3.h>
#include <webgpu/webgpu_cpp.h>
#include <iostream>
#if defined(__EMSCRIPTEN__)
#include <emscripten/emscripten.h>
#else
#include <webgpu/webgpu_glfw.h>
#endif
void Start() {
  if (!glfwInit()) {
    return;
  }

  glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
  GLFWwindow* window =
      glfwCreateWindow(kWidth, kHeight, "WebGPU window", nullptr, nullptr);

#if defined(__EMSCRIPTEN__)
  wgpu::SurfaceDescriptorFromCanvasHTMLSelector canvasDesc{};
  canvasDesc.selector = "#canvas";

  wgpu::SurfaceDescriptor surfaceDesc{.nextInChain = &canvasDesc};
  wgpu::Surface surface = instance.CreateSurface(&surfaceDesc);
#else
  wgpu::Surface surface =
      wgpu::glfw::CreateSurfaceForWindow(instance, window);
#endif

  InitGraphics(surface);

#if defined(__EMSCRIPTEN__)
  emscripten_set_main_loop(Render, 0, false);
#else
  while (!glfwWindowShouldClose(window)) {
    glfwPollEvents();
    Render();
    swapChain.Present();
    instance.ProcessEvents();
  }
#endif
}

Emscripten でアプリをビルドする

Emscripten を使用してアプリをビルドするために必要な唯一の変更は、cmake コマンドの前に、魔法のような emcmake シェル スクリプトを付加することです。今回は、build-web サブフォルダにアプリを生成し、HTTP サーバーを起動します。最後に、ブラウザを開いて build-web/app.html にアクセスします。

# Build the app with Emscripten.
$ emcmake cmake -B build-web && cmake --build build-web

# Start a HTTP server.
$ npx http-server
ブラウザ ウィンドウの赤い三角形のスクリーンショット。
ブラウザ ウィンドウに表示された赤い三角形

次のステップ

今後の流れ:

  • webgpu.h API と webgpu_cpp.h API の安定化を改善しました。
  • Android と iOS で Dawn を初期サポート。

それまでは、提案や質問があれば、Emscripten の WebGPU に関する問題Dawn に関する問題に報告してください。

リソース

このアプリのソースコードは自由にご確認ください。

WebGPU を使用して C++ ネイティブ 3D アプリケーションをゼロから作成する場合は、C++ 用 WebGPU について学ぶDawn Native WebGPU の例をご覧ください。

Rust に興味がある方は、WebGPU をベースにした wgpu グラフィック ライブラリもご覧ください。hello-triangle デモをご覧ください。

謝辞

この記事は、Corentin WallezKai NinomiyaRachel Andrew によってレビューされました。

写真撮影: Marc-Olivier JodoinUnsplash