Anwendung mit WebGPU erstellen

François Beaufort
François Beaufort

WebGPU ist eine Webgrafik-API, die Webentwicklern einen einheitlichen und schnellen Zugriff auf GPUs bietet. WebGPU stellt moderne Hardwarefunktionen bereit und ermöglicht Rendering- und Berechnungsvorgänge auf einer GPU, ähnlich wie Direct3D 12, Metal und Vulkan.

Das ist zwar richtig, aber diese Geschichte ist unvollständig. WebGPU ist das Ergebnis einer Zusammenarbeit großer Unternehmen wie Apple, Google, Intel, Mozilla und Microsoft. Einige von ihnen erkannten, dass WebGPU mehr als eine JavaScript-API sein könnte, sondern eine plattformübergreifende Grafik-API für Entwickler in verschiedenen Systemen, die nicht dem Web zuzuordnen sind.

Um den primären Anwendungsfall zu erfüllen, wurde in Chrome 113 eine JavaScript API eingeführt. Daneben wurde jedoch noch ein weiteres wichtiges Projekt entwickelt: die C-API webgpu.h. In dieser C-Headerdatei sind alle verfügbaren Verfahren und Datenstrukturen von WebGPU aufgeführt. Es dient als plattformunabhängige Hardwareabstraktionsebene, mit der Sie durch die Bereitstellung einer einheitlichen Schnittstelle über verschiedene Plattformen plattformspezifische Anwendungen erstellen können.

In diesem Dokument erfahren Sie, wie Sie mit WebGPU eine kleine C++-Anwendung schreiben, die sowohl im Web als auch auf bestimmten Plattformen ausgeführt werden kann. Spoiler: Sie sehen dasselbe rote Dreieck, das in einem Browserfenster und einem Desktopfenster angezeigt wird, mit minimalen Anpassungen an Ihrer Codebasis.

Screenshot eines roten Dreiecks, das von WebGPU unterstützt wird, in einem Browserfenster und einem Desktopfenster unter macOS
Dasselbe Dreieck, das von WebGPU in einem Browserfenster und einem Desktopfenster unterstützt wird.

Wie funktioniert das?

Die fertige Anwendung finden Sie im Repository der WebGPU-Plattformübergreifenden App.

Die App ist ein minimalistisches C++-Beispiel, das zeigt, wie mit WebGPU Desktop- und Web-Apps aus einer einzigen Codebasis erstellt werden. Intern verwendet es webgpu.h von WebGPU als plattformunabhängige Hardwareabstraktionsebene über einen C++-Wrapper namens webgpu_cpp.h.

Im Web basiert die Anwendung auf Emscripten, wobei Webgpu.h mithilfe von bindings zusätzlich zur JavaScript API implementiert wird. Auf bestimmten Plattformen wie macOS oder Windows kann dieses Projekt mit Dawn erstellt werden, der plattformübergreifenden WebGPU-Implementierung von Chromium. Erwähnenswert ist, dass wgpu-native, eine Rust-Implementierung von webgpu.h, ebenfalls vorhanden ist, aber in diesem Dokument nicht verwendet wird.

Jetzt starten

Zuerst benötigen Sie einen C++-Compiler und CMake, um plattformübergreifende Builds auf standardmäßige Weise zu verarbeiten. Erstellen Sie in einem separaten Ordner eine main.cpp-Quelldatei und eine CMakeLists.txt-Builddatei.

Die Datei main.cpp sollte vorerst eine leere main()-Funktion enthalten.

int main() {}

Die Datei CMakeLists.txt enthält grundlegende Informationen zum Projekt. In der letzten Zeile wird „app“ als Name für die ausführbare Datei angegeben und der Quellcode lautet 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")

Führen Sie cmake -B build aus, um Build-Dateien im Unterordner „build/“ zu erstellen, und cmake --build build, um die App zu erstellen und die ausführbare Datei zu generieren.

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

# Run the app.
$ ./build/app

Die App wird ausgeführt, aber es gibt noch keine Ausgabe, da Sie etwas auf dem Bildschirm zeichnen müssen.

Get Dawn

Zum Zeichnen des Dreiecks kannst du Dawn nutzen, die plattformübergreifende WebGPU-Implementierung von Chromium. Dazu gehört die GLFW-C++-Bibliothek zum Zeichnen auf dem Bildschirm. Eine Möglichkeit zum Herunterladen von Dawn besteht darin, es Ihrem Repository als Git-Submodul hinzuzufügen. Mit den folgenden Befehlen wird es in den Unterordner „dawn/“ abgerufen.

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

Fügen Sie dann der Datei CMakeLists.txt Folgendes hinzu:

  • Mit der CMake-Option DAWN_FETCH_DEPENDENCIES werden alle Dawn-Abhängigkeiten abgerufen.
  • Der Unterordner „dawn/“ ist im Ziel enthalten.
  • Ihre App hängt von den Zielen dawn::webgpu_dawn, glfw und webgpu_glfw ab, damit Sie sie später in der main.cpp-Datei verwenden können.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE dawn::webgpu_dawn glfw webgpu_glfw)

Fenster öffnen

Jetzt, da Dawn verfügbar ist, können Sie mit GLFW Dinge auf dem Bildschirm zeichnen. Mit dieser in webgpu_glfw enthaltenen Bibliothek können Sie plattformunabhängigen Code für die Fensterverwaltung schreiben.

Wenn Sie ein Fenster mit dem Namen „WebGPU-Fenster“ mit einer Auflösung von 512 × 512 öffnen möchten, aktualisieren Sie die Datei main.cpp wie unten beschrieben. Hinweis: Mit glfwWindowHint() wird hier keine bestimmte Initialisierung der Grafik-API angefordert.

#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();
}

Wenn Sie die App neu erstellen und wie zuvor ausführen, wird jetzt ein leeres Fenster angezeigt. Du machst Fortschritte!

Screenshot eines leeren macOS-Fensters
Ein leeres Fenster.

GPU-Gerät abrufen

In JavaScript ist navigator.gpu der Einstiegspunkt für den Zugriff auf die GPU. In C++ müssen Sie manuell eine wgpu::Instance-Variable erstellen, die für denselben Zweck verwendet wird. Deklarieren Sie instance der Einfachheit halber oben in der Datei main.cpp und rufen Sie wgpu::CreateInstance() in main() auf.

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

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

Der Zugriff auf die GPU erfolgt aufgrund der Struktur der JavaScript API asynchron. Erstellen Sie in C++ zwei Hilfsfunktionen namens GetAdapter() und GetDevice(), die jeweils eine Callback-Funktion mit einer wgpu::Adapter und einer wgpu::Device zurückgeben.

#include <iostream>
…

void GetAdapter(void (*callback)(wgpu::Adapter)) {
  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);
        reinterpret_cast<void (*)(wgpu::Adapter)>(userdata)(adapter);
  }, reinterpret_cast<void*>(callback));
}

void GetDevice(void (*callback)(wgpu::Device)) {
  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);
  }, reinterpret_cast<void*>(callback));
}

Deklarieren Sie zum einfacheren Zugriff oben in der Datei main.cpp zwei Variablen wgpu::Adapter und wgpu::Device. Aktualisieren Sie die Funktion main() so, dass GetAdapter() aufgerufen und der Ergebnis-Callback adapter zugewiesen wird. Rufen Sie dann GetDevice() auf und weisen Sie den Ergebnis-Callback device zu, bevor Sie Start() aufrufen.

wgpu::Adapter adapter;
wgpu::Device device;
…

int main() {
  instance = wgpu::CreateInstance();
  GetAdapter([](wgpu::Adapter a) {
    adapter = a;
    GetDevice([](wgpu::Device d) {
      device = d;
      Start();
    });
  });
}

Ein Dreieck zeichnen

Die Auslagerungskette wird in der JavaScript API nicht bereitgestellt, da sie vom Browser verwaltet wird. In C++ müssen Sie sie manuell erstellen. Auch hier sollten Sie der Einfachheit halber oben in der Datei main.cpp eine Variable wgpu::Surface deklarieren. Rufen Sie direkt nach dem Erstellen des GLFW-Fensters in Start() die praktische Funktion wgpu::glfw::CreateSurfaceForWindow() auf, um eine wgpu::Surface (ähnlich wie ein HTML-Canvas) zu erstellen, und konfigurieren Sie sie durch Aufrufen der neuen Hilfsfunktion ConfigureSurface() in InitGraphics(). Außerdem müssen Sie surface.Present() aufrufen, um die nächste Textur in der while-Schleife darzustellen. Das hat keine sichtbaren Auswirkungen, da noch kein Rendering erfolgt.

#include <webgpu/webgpu_glfw.h>
…

wgpu::Surface surface;
wgpu::TextureFormat format;

void ConfigureSurface() {
  wgpu::SurfaceCapabilities capabilities;
  surface.GetCapabilities(adapter, &capabilities);
  format = capabilities.formats[0];

  wgpu::SurfaceConfiguration config{
      .device = device,
      .format = format,
      .width = kWidth,
      .height = kHeight};
  surface.Configure(&config);
}

void InitGraphics() {
  ConfigureSurface();
}

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

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

  InitGraphics();

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

Jetzt ist ein guter Zeitpunkt, um die Rendering-Pipeline mit dem Code unten zu erstellen. Für einen einfacheren Zugriff deklarieren Sie oben in der main.cpp-Datei eine wgpu::RenderPipeline-Variable und rufen Sie die Hilfsfunktion CreateRenderPipeline() in InitGraphics() auf.

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 = format};

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

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

void InitGraphics() {
  …
  CreateRenderPipeline();
}

Senden Sie abschließend Renderingbefehle an die GPU in der Render()-Funktion, die jeden Frame aufgerufen wird.

void Render() {
  wgpu::SurfaceTexture surfaceTexture;
  surface.GetCurrentTexture(&surfaceTexture);

  wgpu::RenderPassColorAttachment attachment{
      .view = surfaceTexture.texture.CreateView(),
      .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);
}

Wenn Sie die App mit CMake neu erstellen und ausführen, wird jetzt das lang erwartete rote Dreieck in einem Fenster angezeigt. Mach eine Pause – du hast es dir verdient.

Screenshot eines roten Dreiecks in einem macOS-Fenster
Ein rotes Dreieck in einem Desktopfenster.

In WebAssembly kompilieren

Sehen wir uns nun an, welche minimalen Änderungen erforderlich sind, um Ihre vorhandene Codebasis so anzupassen, dass dieses rote Dreieck in einem Browserfenster gezeichnet wird. Auch hier wird die App mit Emscripten erstellt, einem Tool zum Kompilieren von C/C++-Programmen in WebAssembly. Es hat Bindungen, die webgpu.h über der JavaScript API implementieren.

CMake-Einstellungen aktualisieren

Nachdem Emscripten installiert ist, aktualisieren Sie die Build-Datei CMakeLists.txt so: Sie müssen lediglich den hervorgehobenen Code ändern.

  • Mit set_target_properties wird der Zieldatei automatisch die Dateiendung „html“ hinzugefügt. Mit anderen Worten: Sie generieren eine „app.html“-Datei.
  • Die Option „USE_WEBGPU“ für den App-Link ist erforderlich, um die WebGPU-Unterstützung in Emscripten zu aktivieren. Andernfalls kann die Datei main.cpp nicht auf die Datei webgpu/webgpu_cpp.h zugreifen.
  • Die Option „USE_GLFW App-Link“ ist hier ebenfalls erforderlich, damit Sie Ihren GLFW-Code wiederverwenden können.
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 dawn::webgpu_dawn glfw webgpu_glfw)
endif()

Code aktualisieren

In Emscripten ist für das Erstellen eines wgpu::surface ein HTML-Canvas-Element erforderlich. Rufen Sie dazu instance.CreateSurface() auf und geben Sie den #canvas-Selektor an, um das entsprechende HTML-Canvas-Element auf der von Emscripten generierten HTML-Seite zu finden.

Rufen Sie anstelle einer while-Schleife emscripten_set_main_loop(Render) auf, um dafür zu sorgen, dass die Render()-Funktion mit einer geeigneten Rate aufgerufen wird, die dem Browser und Monitor korrekt entspricht.

#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};
  surface = instance.CreateSurface(&surfaceDesc);
#else
  surface = wgpu::glfw::CreateSurfaceForWindow(instance, window);
#endif

  InitGraphics();

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

App mit Emscripten erstellen

Die einzige Änderung, die zum Erstellen der App mit Emscripten erforderlich ist, besteht darin, den cmake-Befehlen das magische emcmake-Shell-Skript voranzustellen. Generiere diesmal die Anwendung in einem build-web-Unterordner und starte einen HTTP-Server. Öffnen Sie abschließend Ihren Browser und rufen Sie build-web/app.html auf.

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

# Start a HTTP server.
$ npx http-server
Screenshot eines roten Dreiecks in einem Browserfenster
Ein rotes Dreieck in einem Browserfenster.

Nächste Schritte

Das erwartet Sie in Zukunft:

  • Verbesserungen bei der Stabilisierung der APIs „webgpu.h“ und „webgpu_cpp.h“.
  • Erste Unterstützung von Dawn für Android und iOS.

In der Zwischenzeit können Sie WebGPU-Probleme für Emscripten und Dawn-Probleme mit Vorschlägen und Fragen melden.

Ressourcen

Sie können sich den Quellcode dieser App ansehen.

Wenn Sie mehr über das Erstellen nativer 3D-Anwendungen in C++ mit WebGPU erfahren möchten, lesen Sie die Dokumentation zu WebGPU für C++ und die Beispiele für native WebGPU-Anwendungen mit Dawn.

Wenn Sie sich für Rust interessieren, können Sie sich auch die wgpu-Grafikbibliothek ansehen, die auf WebGPU basiert. Sehen Sie sich die hello-triangle-Demo an.

Danksagung

Dieser Artikel wurde von Corentin Wallez, Kai Ninomiya und Rachel Andrew geprüft.

Foto von Marc-Olivier Jodoin auf Unsplash.