Tworzenie aplikacji za pomocą WebGPU

François Beaufort
François Beaufort

WebGPU to interfejs API do grafiki internetowej, który zapewnia szybki i jednolity dostęp do kart graficznych. WebGPU udostępnia nowoczesne możliwości sprzętowe i umożliwia renderowanie oraz operacje obliczeniowe na karcie graficznej, podobnie jak Direct3D 12, Metal i Vulkan.

Chociaż to prawda, to niepełna historia. WebGPU to efekt współpracy z udziałem takich firm jak Apple, Google, Intel, Mozilla i Microsoft. Niektórzy zauważyli, że WebGPU to coś więcej niż interfejs API JavaScript, ale wieloplatformowy interfejs graficzny dla programistów z ekosystemów innych niż internet.

Aby zrealizować główny przypadek użycia, w Chrome 113 wprowadziliśmy interfejs JavaScript API. Oprócz tego rozwija się jednak inny ważny projekt: webgpu.h C API. Ten plik nagłówka C zawiera listę wszystkich dostępnych procedur i struktur danych dostępnych w WebGPU. Stanowi warstwę abstrakcji sprzętowej niezależną od platformy, co pozwala tworzyć aplikacje na konkretne platformy, zapewniając spójny interfejs na różnych platformach.

Z tego dokumentu dowiesz się, jak napisać małą aplikację w C++ z użyciem WebGPU, która działa zarówno w internecie, jak i na określonych platformach. Spoiler: zobaczysz ten sam czerwony trójkąt, który pojawia się w oknie przeglądarki i na komputerze z minimalnymi zmianami w kodzie.

Zrzut ekranu przedstawiający czerwony trójkąt w ramach WebGPU w oknie przeglądarki i oknie aplikacji na komputerze z systemem macOS.
Ten sam trójkąt obsługiwany przez WebGPU w oknie przeglądarki i oknie na komputerze.

Jak to działa?

Aby zobaczyć gotową aplikację, zajrzyj do repozytorium aplikacji na wiele platform WebGPU.

Aplikacja jest minimalistycznym przykładem w języku C++, który pokazuje, jak używać WebGPU do tworzenia aplikacji na komputery i internet na podstawie jednego kodu źródłowego. W środowisku C++ biblioteka WebGPU webgpu.h jest używana jako niezależna od platformy warstwa abstrakcji sprzętowej z kodem C++ o nazwie webgpu_cpp.h.

W internecie aplikacja jest tworzona na podstawie Emscripten, który ma wiązania implementujące webgpu.h na podstawie interfejsu JavaScript API. Na niektórych platformach, takich jak macOS czy Windows, ten projekt można skompilować z użyciem Dawn, czyli nowej implementacji WebGPU w Chromium. Warto wspomnieć o wgpu-native – implementacji Rust dla webgpu.h, która też istnieje, ale nie jest używana w tym dokumencie.

Rozpocznij

Na początek potrzebujesz kompilatora C++ i CMake, aby obsługiwać kompilacje na wiele platform w standardowy sposób. W dedykowanym folderze utwórz plik źródłowy main.cpp i plik kompilacji CMakeLists.txt.

Plik main.cpp powinien zawierać pustą funkcję main().

int main() {}

Plik CMakeLists.txt zawiera podstawowe informacje o projekcie. Ostatni wiersz określa, że nazwa pliku wykonywalnego to „app”, a jego kod źródłowy to 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")

Uruchom polecenie cmake -B build, aby utworzyć pliki kompilacji w podfolderze „build/”, a potem uruchom polecenie cmake --build build, aby faktycznie skompilować aplikację i wygenerować plik wykonywalny.

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

# Run the app.
$ ./build/app

Aplikacja działa, ale nie ma jeszcze żadnego wyjścia, ponieważ musisz narysować coś na ekranie.

Get Dawn

Aby narysować trójkąt, możesz skorzystać z Dawn, czyli implementacji WebGPU na różnych platformach w Chromium. Obejmuje to bibliotekę GLFW w języku C++, która służy do rysowania na ekranie. Jednym ze sposobów pobrania Dawn jest dodanie go jako podmodułu Git do repozytorium. Poniższe polecenia pobierają go z podfolderu „dawn/”.

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

Następnie dodaj do pliku CMakeLists.txt te informacje:

  • Opcja CMake DAWN_FETCH_DEPENDENCIES pobiera wszystkie zależności Dawn.
  • Folder podrzędny dawn/ jest uwzględniony w docelowym folderze.
  • Twoja aplikacja będzie zależeć od celów dawn::webgpu_dawn, glfwwebgpu_glfw, aby można było później używać ich w pliku main.cpp.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE dawn::webgpu_dawn glfw webgpu_glfw)

Otwieranie okna

Aplikacja Dawn jest już dostępna. Teraz możesz rysować na ekranie za pomocą GLFW. Ta biblioteka zawarta w webgpu_glfw umożliwia wygodne pisanie kodu, który jest niezależny od platformy w przypadku zarządzania oknami.

Aby otworzyć okno o nazwie „WebGPU window” (okno WebGPU) o rozdzielczości 512 x 512, zaktualizuj plik main.cpp w podany niżej sposób. Pamiętaj, że glfwWindowHint() jest tutaj używany do żądania inicjowania interfejsu API grafiki.

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

Ponowne skompilowanie aplikacji i uruchomienie jej w taki sam sposób powoduje, że otwiera się puste okno. Robisz postępy.

Zrzut ekranu pustego okna macOS.
Puste okno.

Pobieranie danych z urządzenia GPU

W JavaScript element navigator.gpu jest punktem wejścia do dostępu do GPU. W języku C++ musisz ręcznie utworzyć zmienną wgpu::Instance, która służy do tego samego celu. Dla wygody zadeklaruj zmienną instance u góry pliku main.cpp i wywołuj ją jako wgpu::CreateInstance() w pliku main().

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

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

Dostęp do GPU jest asynchroniczny ze względu na kształt interfejsu JavaScript API. W C++ utwórz 2 funkcje pomocnicze o nazwach GetAdapter()GetDevice(), które zwracają odpowiednio funkcję wywołania zwrotnego z argumentem wgpu::Adapterwgpu::Device.

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

Aby ułatwić sobie dostęp, u góry pliku main.cpp zadeklaruj 2 zmiennych wgpu::Adapterwgpu::Device. Zmodyfikuj funkcję main() tak, aby wywoływała funkcję GetAdapter() i przypisała jej wywołanie zwrotne do funkcji adapter, a następnie wywołaj funkcję GetDevice() i przypisz jej wywołanie zwrotne do funkcji device, zanim wywołasz funkcję Start().

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

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

Narysuj trójkąt

Łańcuch wymiany nie jest widoczny w interfejsie JavaScript API, ponieważ zajmuje się nim przeglądarka. W języku C++ musisz go utworzyć ręcznie. Ponownie, dla wygody, zadeklaruj zmienną wgpu::Surface u góry pliku main.cpp. Tuż po utworzeniu okna GLFW w Start() wywołaj przydatną funkcję wgpu::glfw::CreateSurfaceForWindow(), aby utworzyć wgpu::Surface (podobny do obszaru roboczego HTML), i skonfiguruj ją, wywołując nową funkcję pomocniczą ConfigureSurface() w InitGraphics(). Musisz też wywołać surface.Present(), aby wyświetlić następną teksturę w pętli while. Nie ma to widocznego wpływu, ponieważ nie jest jeszcze renderowane.

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

Teraz możesz utworzyć potok renderowania za pomocą kodu poniżej. Aby ułatwić dostęp, zadeklaruj zmienną wgpu::RenderPipeline na górze pliku main.cpp i wywołaj funkcję pomocniczą CreateRenderPipeline() w InitGraphics().

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

Na koniec wyślij polecenia renderowania do GPU w funkcji Render() nazywanej każdą klatką.

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

Odbudowanie aplikacji przy użyciu CMake i jej uruchomienie powoduje wyświetlenie w oknie długo wyczekiwanego czerwonego trójkąta. Zrób sobie przerwę – zasługujesz na odpoczynek.

Zrzut ekranu przedstawiający czerwony trójkąt w oknie macOS.
Czerwony trójkąt w oknie na komputerze.

Kompilowanie kodu na WebAssembly

Przyjrzyjmy się teraz minimalnym zmianom wymaganym do dostosowania istniejącej bazy kodu, aby wyświetlała czerwony trójkąt w oknie przeglądarki. Aplikacja jest skompilowana za pomocą Emscripten, czyli narzędzia do kompilowania programów C/C++ do WebAssembly, które ma wiązania implementujące webgpu.h na podstawie interfejsu JavaScript API.

Aktualizowanie ustawień CMake

Po zainstalowaniu Emscripten zaktualizuj plik kompilacji CMakeLists.txt w następujący sposób. Trzeba tylko zmienić zaznaczony kod.

  • set_target_properties służy do automatycznego dodawania do pliku docelowego rozszerzenia „html”. Inaczej mówiąc, wygenerujesz plik „app.html”.
  • Opcja linku do aplikacji USE_WEBGPU jest wymagana do włączenia obsługi WebGPU w Emscripten. Bez niego plik main.cpp nie będzie mieć dostępu do pliku webgpu/webgpu_cpp.h.
  • Opcja USE_GLFW link do aplikacji jest też wymagana, aby można było ponownie użyć kodu 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 dawn::webgpu_dawn glfw webgpu_glfw)
endif()

Aktualizowanie kodu

Do utworzenia elementu wgpu::surface w Emscripten wymagany jest element HTML canvas. W tym celu wywołaj funkcję instance.CreateSurface() i określ selektor #canvas, aby dopasować odpowiedni element kanwy HTML na stronie HTML wygenerowanej przez Emscripten.

Zamiast pętli while wywołaj funkcję emscripten_set_main_loop(Render), aby mieć pewność, że funkcja Render() jest wywoływana z odpowiednią częstotliwością, która jest odpowiednio dopasowana do przeglądarki i monitora.

#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
}

Utwórz aplikację za pomocą Emscripten

Jedyną zmianą, jaką należy wprowadzić, aby skompilować aplikację za pomocą Emscripten, jest dodanie do poleceń cmake magicznego skryptu powłoki emcmake. Tym razem wygeneruj aplikację w podfolderze build-web i uruchom serwer HTTP. Na koniec otwórz przeglądarkę i wejdź na 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
Zrzut ekranu czerwonego trójkąta w oknie przeglądarki.
Czerwony trójkąt w oknie przeglądarki.

Co dalej?

Czego możesz się spodziewać w przyszłości:

  • Poprawki w stabilności interfejsów API webgpu.h i webgpu_cpp.h.
  • wstępna obsługa Dawn na Androida i iOS;

Tymczasem prosimy o przesyłanie sugestii i pytań dotyczących WebGPU w EmscriptenDawn.

Zasoby

Możesz też zapoznać się z kodem źródłowym tej aplikacji.

Jeśli chcesz dowiedzieć się więcej o tworzeniu natywnych aplikacji 3D w języku C++ od podstaw przy użyciu WebGPU, zapoznaj się z dokumentacją dotyczącą WebGPU dla języka C++ i przykładami natywnego procesora internetowego (Dawn Native WebGPU).

Jeśli interesuje Cię Rust, możesz też zapoznać się z biblioteką graficzną wgpu opartą na WebGPU. Obejrzyj ich prezentację hello-triangle.

Poświadczenia

Ten artykuł napisali Corentin Wallez, Kai Ninomiya i Rachel Andrew.

Zdjęcie autorstwa Marc-Olivier JodoinUnsplash.