Creare un'app con WebGPU

François Beaufort
François Beaufort

Per gli sviluppatori web, WebGPU è un'API di grafica web che fornisce un accesso rapido e unificato alle GPU. WebGPU espone funzionalità hardware moderne e consente operazioni di rendering e calcolo su GPU, analogamente a Direct3D 12, Metal e Vulkan.

Sebbene sia vero, si tratta di una storia incompleta. WebGPU è il risultato di un lavoro collaborativo, che include grandi aziende, come Apple, Google, Intel, Mozilla e Microsoft. Tra questi, alcuni si sono resi conto che WebGPU potrebbe essere più di un'API JavaScript, ma di un'API grafica multipiattaforma per sviluppatori di ecosistemi diversi dal web.

Per soddisfare il caso d'uso principale, in Chrome 113 è stata introdotta un'API JavaScript. Tuttavia, è stato sviluppato insieme a questo altro progetto significativo: l'API C webgpu.h. Questo file di intestazione C elenca tutte le procedure e le strutture di dati disponibili di WebGPU. Funge da livello di astrazione hardware indipendente dalla piattaforma, permettendoti di creare applicazioni specifiche per piattaforma fornendo un'interfaccia coerente su diverse piattaforme.

In questo documento imparerai a scrivere una piccola app C++ utilizzando WebGPU che venga eseguita sia sul web sia su piattaforme specifiche. Spoiler: riceverai lo stesso triangolo rosso che appare in una finestra del browser e in una del desktop, con modifiche minime al codebase.

Screenshot di un triangolo rosso basato su WebGPU in una finestra del browser e una finestra sul desktop su macOS.
Lo stesso triangolo basato su WebGPU in una finestra del browser e una finestra del desktop.

Come funzionano?

Per vedere l'applicazione completata, controlla il repository per app multipiattaforma WebGPU.

L'app è un esempio C++ minimalista che mostra come utilizzare WebGPU per creare app web e desktop da un unico codebase. In background, utilizza webgpu.h di WebGPU come livello di astrazione hardware indipendente dalla piattaforma attraverso un wrapper C++ chiamato webgpu_cpp.h.

Sul web, l'app è basata su Emscripten, che ha associazioni che implementano webgpu.h oltre all'API JavaScript. Su piattaforme specifiche come macOS o Windows, questo progetto può basarsi su Dawn, l'implementazione multipiattaforma di WebGPU di Chromium. Vale la pena menzionare anche wgpu-native, un'implementazione Rust di webgpu.h, che esiste anche ma non è utilizzata in questo documento.

Inizia

Per iniziare, sono necessari un compilatore C++ e CMake per gestire le build multipiattaforma in modo standard. All'interno di una cartella dedicata, crea un file di origine main.cpp e un file di build CMakeLists.txt.

Per il momento, il file main.cpp dovrebbe contenere una funzione main() vuota.

int main() {}

Il file CMakeLists.txt contiene informazioni di base sul progetto. L'ultima riga specifica che il nome eseguibile è "app" e il suo codice sorgente è 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")

Esegui cmake -B build per creare file di build in una cartella secondaria "build/" e cmake --build build per creare effettivamente l'app e generare il file eseguibile.

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

# Run the app.
$ ./build/app

L'app è in esecuzione, ma non c'è ancora nessun output, perché ti serve un modo per disegnare elementi sullo schermo.

Pronti all'alba

Per disegnare il triangolo, puoi sfruttare Dawn, l'implementazione multipiattaforma di WebGPU di Chromium. È inclusa la libreria C++ GLFW per disegnare sullo schermo. Un modo per scaricare Dawn è aggiungerlo come sottomodulo Git al tuo repository. I seguenti comandi lo recuperano in una sottocartella "dawn/".

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

Quindi, aggiungi al file CMakeLists.txt come segue:

  • L'opzione CMake DAWN_FETCH_DEPENDENCIES recupera tutte le dipendenze Dawn.
  • La sottocartella dawn/ è inclusa nella destinazione.
  • La tua app dipenderà dai target webgpu_cpp, webgpu_dawn e webgpu_glfw, così puoi utilizzarli in un secondo momento nel file main.cpp.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn webgpu_glfw)

Aprire una finestra

Ora che Dawn è disponibile, utilizza GLFW per disegnare oggetti sullo schermo. Questa libreria inclusa per praticità in webgpu_glfw ti consente di scrivere codice indipendente dalla piattaforma per la gestione delle finestre.

Per aprire una finestra denominata "Finestra WebGPU" con una risoluzione di 512 x 512, aggiorna il file main.cpp come indicato di seguito. Tieni presente che glfwWindowHint() viene utilizzato qui per non richiedere alcuna inizializzazione particolare dell'API grafica.

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

Ricreando l'app ed eseguendola come prima ora viene visualizzata una finestra vuota. Stai facendo progressi!

Screenshot di una finestra di macOS vuota.
Una finestra vuota.

Ottieni dispositivo GPU

In JavaScript, navigator.gpu è il tuo punto di ingresso per accedere alla GPU. In C++, devi creare manualmente una variabile wgpu::Instance da utilizzare per lo stesso scopo. Per praticità, dichiara instance nella parte superiore del file main.cpp e chiama wgpu::CreateInstance() all'interno di main().

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

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

L'accesso alla GPU è asincrono a causa della forma dell'API JavaScript. In C++, crea una funzione GetDevice() helper che prende un argomento di funzione di callback e la chiama con il risultante 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));
}

Per facilitare l'accesso, dichiara una variabile wgpu::Device nella parte superiore del file main.cpp e aggiorna la funzione main() in modo che chiami GetDevice() e assegni il callback dei risultati a device prima di chiamare Start().

wgpu::Device device;
…

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

Disegna un triangolo

La catena di scambio non è esposta nell'API JavaScript perché viene gestita dal browser. In C++, devi crearlo manualmente. Ancora una volta, per praticità, dichiara una variabile wgpu::SwapChain nella parte superiore del file main.cpp. Subito dopo aver creato la finestra GLFW in Start(), chiama la pratica funzione wgpu::glfw::CreateSurfaceForWindow() per creare un wgpu::Surface (simile a un canvas HTML) e utilizzalo per configurare la catena di scambio chiamando la nuova funzione helper SetupSwapChain() in InitGraphics(). Devi anche chiamare swapChain.Present() per presentare la texture successiva nel ciclo before. Questo non ha effetto visibile perché non è ancora in corso il rendering.

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

Questo è un buon momento per creare la pipeline di rendering con il codice riportato di seguito. Per facilitare l'accesso, dichiara una variabile wgpu::RenderPipeline nella parte superiore del file main.cpp e chiama la funzione helper CreateRenderPipeline() in 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();
}

Infine, invia i comandi di rendering alla GPU nella funzione Render() chiamata ogni frame.

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

Ricostruire l'app con CMake ed eseguirla ora si traduce in un triangolo rosso atteso in una finestra. Prenditi una pausa, te la meriti.

Screenshot di un triangolo rosso in una finestra di macOS.
Un triangolo rosso in una finestra del desktop.

Compila in WebAssembly

Diamo ora un'occhiata alle modifiche minime necessarie per modificare il codebase esistente e disegnare questo triangolo rosso in una finestra del browser. Anche in questo caso, l'app si basa su Emscripten, uno strumento per la compilazione di programmi C/C++ in WebAssembly, che ha associazioni che implementano webgpu.h oltre all'API JavaScript.

Aggiornamento delle impostazioni di CMake

Una volta installato Emscripten, aggiorna il file di build CMakeLists.txt come segue. Il codice evidenziato è l'unica cosa che devi modificare.

  • set_target_properties viene utilizzato per aggiungere automaticamente l'estensione "html" al file di destinazione. In altre parole, genererai un file "app.html".
  • Per attivare il supporto di WebGPU in Emscripten, è necessaria l'opzione del link dell'app USE_WEBGPU. Se non lo fai, il file main.cpp non potrà accedere al file webgpu/webgpu_cpp.h.
  • Qui è richiesta anche l'opzione del link dell'app USE_GLFW, per consentirti di riutilizzare il codice 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()

Aggiorna il codice

In Emscripten, la creazione di un wgpu::surface richiede un elemento canvas HTML. Per farlo, chiama instance.CreateSurface() e specifica il selettore #canvas in modo che corrisponda all'elemento canvas HTML appropriato nella pagina HTML generata da Emscripten.

Invece di utilizzare un loop keep, chiama emscripten_set_main_loop(Render) per assicurarti che la funzione Render() venga richiamata a una frequenza uniforme, in modo da allinearsi correttamente al browser e al monitoraggio.

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

Crea l'app con Emscripten

L'unica modifica necessaria per creare l'app con Emscripten è anteporre i comandi cmake con lo script shell di emcmake magico. Questa volta, genera l'app in una sottocartella build-web e avvia un server HTTP. Infine, apri il browser e visita 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
Screenshot di un triangolo rosso in una finestra del browser.
Un triangolo rosso in una finestra del browser.

Passaggi successivi

Ecco che cosa puoi aspettarti in futuro:

  • Miglioramenti nella stabilizzazione delle API webgpu.h e webgpu_cpp.h.
  • Supporto iniziale di Dawn per Android e iOS.

Nel frattempo, segnala i problemi relativi a WebGPU per Emscripten e Dawn con suggerimenti e domande.

Risorse

Puoi esplorare il codice sorgente di questa app.

Se vuoi saperne di più sulla creazione di applicazioni 3D native in C++ da zero con WebGPU, consulta la documentazione relativa a WebGPU per C++ e gli esempi di WebGPU native Dawn.

Se ti interessa Rust, puoi anche esplorare la libreria grafica wgpu basata su WebGPU. Dai un'occhiata alla loro demo di hello-triangle.

Riconoscimenti

Questo articolo è stato esaminato da Corentin Wallez, Kai Ninomiya e Rachel Andrew.

Foto di Marc-Olivier Jodoin su Unsplash.