Tworzenie aplikacji za pomocą WebGPU

François Beaufort
François Beaufort

Dla programistów internetowych WebGPU to interfejs API do grafiki internetowej, który zapewnia jednolity i szybki dostęp do GPU. WebGPU eksponuje nowoczesne możliwości sprzętowe oraz umożliwia renderowanie i przetwarzanie danych na GPU, podobnie jak w przypadku Direct3D 12, Metal i Vulkan.

To prawda, ale ta historia jest niekompletna. WebGPU to efekt współpracy dużych firm, takich jak Apple, Google, Intel, Mozilla i Microsoft. Między innymi zdali sobie sprawę, że WebGPU to coś więcej niż interfejs JavaScript API, ale wieloplatformowy interfejs API do obsługi grafiki przeznaczony dla programistów w ekosystemach innych niż internet.

Aby zrealizować główny przypadek użycia, wprowadziliśmy w Chrome 113 interfejs JavaScript API. Opracowano jednak inny ważny projekt: interfejs C API webgpu.h. Ten plik nagłówka C zawiera listę wszystkich dostępnych procedur i struktur danych w WebGPU. Jest to niezależna od platformy warstwa abstrakcji sprzętowej, która umożliwia tworzenie aplikacji na konkretnych platformach przez zapewnienie spójnego interfejsu na wielu platformach.

Z tego dokumentu dowiesz się, jak napisać małą aplikację w C++ z wykorzystaniem interfejsu WebGPU, który działa zarówno w internecie, jak i na określonych platformach. Uwaga: zobaczysz ten sam czerwony trójkąt, który wyświetla się w oknie przeglądarki i na komputerze, z minimalnymi zmianami w bazie kodu.

Zrzut ekranu przedstawiający czerwony trójkąt z technologią WebGPU w oknie przeglądarki i okno pulpitu w systemie macOS.
Ten sam trójkąt z technologią WebGPU w oknie przeglądarki i oknie pulpitu.

Jak to działa?

Aby zobaczyć kompletną aplikację, zajrzyj do repozytorium aplikacji wieloplatformowych WebGPU.

Jest to minimalistyczny przykład w języku C++, który pokazuje, jak za pomocą WebGPU tworzyć aplikacje komputerowe i internetowe, używając jednej bazy kodu. Używa on platformy webgpu.h platformy WebGPU jako niezależnej od platformy warstwy abstrakcji sprzętowej z kodem C++ o nazwie webgpu_cpp.h.

W internecie aplikacja została stworzona na bazie usługi Emscripten, która zawiera powiązania implementujące komponent webgpu.h nad interfejsem JavaScript API. W przypadku określonych platform, takich jak macOS lub Windows, ten projekt można tworzyć za pomocą Dawn – wieloplatformowej implementacji WebGPU w Chromium. Warto wspomnieć o wgpu-native, która jest implementacją Rust biblioteki webgpu.h, ale nie jest używana w tym dokumencie.

Rozpocznij

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

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

int main() {}

Plik CMakeLists.txt zawiera podstawowe informacje o projekcie. Ostatni wiersz określa nazwę pliku wykonywalnego „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 cmake -B build, aby utworzyć pliki kompilacji w podfolderze „build/”, i cmake --build build, by 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 żadnych danych wyjściowych, ponieważ potrzebujesz sposobu na rysowanie elementów na ekranie.

Świt

Aby narysować trójkąt, możesz skorzystać z Dawn – wieloplatformowej implementacji WebGPU w Chromium. Dotyczy to między innymi biblioteki GLFW C++ do rysowania na ekranie. Jednym ze sposobów pobrania aplikacji Dawn jest dodanie jej jako modułu podrzędnego git do repozytorium. Poniższe polecenia pozwalają pobrać ją do podfolderu „dawn/”.

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

Następnie dołącz do pliku CMakeLists.txt w ten sposób:

  • Opcja CMake DAWN_FETCH_DEPENDENCIES pobiera wszystkie zależności Dawn.
  • Podfolder dawn/ jest uwzględniony w folderze docelowym.
  • Twoja aplikacja będzie korzystać z celów webgpu_cpp, webgpu_dawn i webgpu_glfw, by można było ich później użyć w pliku main.cpp.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn webgpu_glfw)

Otwieranie okna

Świt jest już dostępny, więc możesz rysować na ekranie przy użyciu GLFW. Ta biblioteka jest dostępna w webgpu_glfw dla wygody i umożliwia pisanie kodu niezależnego od platformy do zarządzania oknami.

Aby otworzyć okno o nazwie „Okno WebGPU” i rozdzielczością 512 x 512, zaktualizuj plik main.cpp w podany niżej sposób. Pamiętaj, że element glfwWindowHint() jest używany w tym miejscu, by nie inicjować konkretnego interfejsu graficznego.

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

Po skompilowaniu aplikacji i uruchomieniu jej tak jak dotychczas powoduje to wyświetlenie pustego okna. Robisz postępy!

Zrzut ekranu przedstawiający puste okno macOS.
Puste okno.

Kup urządzenie GPU

W skrypcie JavaScript navigator.gpu to punkt początkowy dostępu do GPU. W C++ musisz ręcznie utworzyć zmienną wgpu::Instance, która jest używana w tym samym celu. Dla wygody zadeklaruj instance na początku pliku main.cpp i wywołaj wgpu::CreateInstance() w obrębie main().

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

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

Ze względu na kształt interfejsu JavaScript API dostęp do GPU jest asynchroniczny. W języku C++ utwórz funkcję pomocniczą GetDevice(), która przyjmuje argument funkcji wywołania zwrotnego i wywołuje ją za pomocą wynikowej funkcji wgpu::Device.

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

Aby mieć do niej łatwiejszy dostęp, zadeklaruj zmienną wgpu::Device u góry pliku main.cpp i zaktualizuj funkcję main() tak, aby wywoływała GetDevice() i przypisała wynikowe wywołanie zwrotne do device przed wywołaniem metody Start().

wgpu::Device device;
…

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

Narysuj trójkąt

Łańcuch zamiany nie jest widoczny w interfejsie JavaScript API, ponieważ zajmuje się nim przeglądarka. W C++ musisz utworzyć go ręcznie. Dla wygody ponownie zadeklaruj zmienną wgpu::SwapChain na początku pliku main.cpp. Tuż po utworzeniu okna GLFW w Start() wywołaj wygodną funkcję wgpu::glfw::CreateSurfaceForWindow(), aby utworzyć wgpu::Surface (podobny do kanw HTML), i użyj jej do skonfigurowania łańcucha wymiany, wywołując nową funkcję pomocniczą SetupSwapChain() w InitGraphics(). Musisz też wywołać swapChain.Present(), by zaprezentować następną teksturę w pętli podczas trwania. Nie ma to żadnego widocznego efektu, ponieważ renderowanie nie jest jeszcze wykonywane.

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

To dobry moment na utworzenie potoku renderowania za pomocą poniższego kodu. Aby mieć do niej łatwiejszy dostęp, zadeklaruj zmienną wgpu::RenderPipeline na górze pliku main.cpp i wywołaj funkcję pomocniczą CreateRenderPipeline() w pliku 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 = 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();
}

Na koniec wyślij do GPU w funkcji Render() polecenia renderowania wywoływane każdą z klatek.

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

Po ponownym skompilowaniu aplikacji za pomocą CMake i uruchomieniu tej aplikacji w oknie pojawi się długo oczekiwany czerwony trójkąt. Zrób sobie przerwę—zasługujesz na to

Zrzut ekranu z czerwonym trójkątem w oknie macOS.
Czerwony trójkąt w oknie na komputerze.

Kompilowanie do WebAssembly

Przyjrzyjmy się teraz minimalnym zmianom niezbędnym do dostosowania istniejącej bazy kodu w celu narysowania tego czerwonego trójkąta w oknie przeglądarki. Ponadto aplikacja została stworzona na podstawie Emscripten, narzędzia do kompilowania programów w C/C++ w WebAssembly, które ma powiązania implementujące webgpu.h w stosunku do JavaScript API.

Aktualizowanie ustawień CMake

Po zainstalowaniu Emscripten zaktualizuj plik kompilacji CMakeLists.txt w ten sposób. Musisz tylko zmienić zaznaczony kod.

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

Aktualizacja kodu

Utworzenie w Emscripten elementu wgpu::surface wymaga elementu canvas. W tym celu wywołaj instance.CreateSurface() i określ selektor #canvas, który odpowiada odpowiedniemu elementowi canvas na stronie HTML wygenerowanej przez Emscripten.

Zamiast korzystać z pętli czasu, wywołaj emscripten_set_main_loop(Render), aby mieć pewność, że funkcja Render() będzie wywoływana z płynną szybkością i zrównoważona z przeglądarką i monitorem.

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

Utwórz aplikację z Emscripten

Jedyną zmianą potrzebną do utworzenia aplikacji przy użyciu 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 z czerwonym trójkątem w oknie przeglądarki.
Czerwony trójkąt w oknie przeglądarki.

Co dalej

W przyszłości:

  • Ulepszenia stabilizacji interfejsów API webgpu.h i webgpu_cpp.h.
  • Pierwsza obsługa Dawn na Androida i iOS.

Tymczasem prześlij sugestie i pytania do problemów z WebGPU w Emscripten i problemach z Dawn.

Zasoby

Zapoznaj się z kodem źródłowym tej aplikacji.

Jeśli chcesz dowiedzieć się więcej o tworzeniu od podstaw aplikacji 3D w języku C++ z użyciem WebGPU, zapoznaj się z dokumentacją dotyczącą WebGPU dla C++ i przykładami użycia natywnego komponentu WebGPU w Dawn.

Jeśli interesuje Cię platforma Rust, możesz też przejrzeć bibliotekę grafiki wgpu opartą na technologii WebGPU. Obejrzyj prezentację hello-triangle.

Poświadczenia

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

Zdjęcie: Marc-Olivier Jodoin, Unsplash.