Creare un'app con WebGPU

François Beaufort
François Beaufort

Per gli sviluppatori web, WebGPU è un'API per la grafica web che offre un ambiente l'accesso alle GPU. WebGPU espone funzionalità hardware moderne e consente il rendering e operazioni di calcolo su una GPU, come Direct3D 12, Metal e Vulkan.

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

Per soddisfare il caso d'uso principale, è stata introdotto in Chrome 113. Tuttavia, un altro aspetto significativo è stato sviluppato insieme al progetto: webgpu.h C tramite Google Cloud CLI o tramite l'API Compute Engine. Questo file di intestazione C elenca tutte le procedure e le strutture di dati disponibili di WebGPU. Serve come livello di astrazione hardware indipendente dalla piattaforma, consentendo di creare applicazioni specifiche per la piattaforma, fornendo un'interfaccia coerente su diverse piattaforme.

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

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

Come funziona?

Per vedere l'applicazione completata, consulta il repository di app multipiattaforma WebGPU.

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

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

Inizia

Per iniziare, ti servono un compilatore C++ e CMake per gestire le build multipiattaforma in modo standard. All'interno di una cartella dedicata, crea un main.cpp file di origine 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 il nome dell'eseguibile è "app" con 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 "build/" e cmake --build build per creare 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 ci sono ancora output, perché ti serve un modo per disegnare elementi sullo schermo.

Inizia l'alba

Per disegnare il triangolo, puoi sfruttare Dawn, l'implementazione multipiattaforma 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 un file "dawn/" sottocartella.

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

Quindi, aggiungilo al file CMakeLists.txt come segue:

  • L'opzione CMake DAWN_FETCH_DEPENDENCIES recupera tutte le dipendenze Dawn.
  • La cartella secondaria dawn/ è inclusa nella destinazione.
  • La tua app dipenderà dai target dawn::webgpu_dawn, glfw e webgpu_glfw per consentirti di 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 dawn::webgpu_dawn glfw webgpu_glfw)

Aprire una finestra

Ora che Dawn è disponibile, utilizza GLFW per disegnare oggetti sullo schermo. Questa libreria inclusa in webgpu_glfw per praticità, 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 una particolare inizializzazione 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();
}

Se ricrei l'app ed eseguila come prima, viene aperta una finestra vuota. Stai facendo grandi progressi!

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

Recupera dispositivo GPU

In JavaScript, navigator.gpu è il punto di ingresso per accedere alla GPU. In C++, devi creare manualmente una variabile wgpu::Instance che utilizzi 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 due funzioni helper denominate GetAdapter() e GetDevice() che restituiscano rispettivamente una funzione di callback con wgpu::Adapter e wgpu::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));
}

Per facilitare l'accesso, dichiara due variabili wgpu::Adapter e wgpu::Device nella parte superiore del file main.cpp. Aggiorna la funzione main() per chiamare GetAdapter() e assegnare il callback di risultati a adapter, quindi chiama GetDevice() e assegna il callback del risultato a device prima di chiamare 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();
    });
  });
}

Disegna un triangolo

La catena di scambio non è esposta nell'API JavaScript perché se ne occupa il browser. In C++, devi crearlo manualmente. Anche in questo caso, per praticità, dichiara una variabile wgpu::Surface 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 configuralo chiamando la nuova funzione helper ConfigureSurface() in InitGraphics(). Devi anche chiamare surface.Present() per presentare la texture successiva nel loop "Mentre". Questa operazione non ha alcun effetto visibile in quanto non è ancora in corso alcun rendering.

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

Ora è un buon momento per creare la pipeline di rendering con il codice seguente. Per un accesso più semplice, dichiara una variabile wgpu::RenderPipeline nella parte superiore del file main.cpp e richiama 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 = 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();
}

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

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

Ricreando l'app con CMake e eseguendola ora, si apre il triangolo rosso tanto 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 di un desktop.

Compila in WebAssembly

Vediamo ora le modifiche minime necessarie per modificare il codebase esistente in modo da disegnare questo triangolo rosso in una finestra del browser. Anche in questo caso, l'app è basata su Emscripten, uno strumento per la compilazione di programmi C/C++ in WebAssembly, dotato di bindings che implementano webgpu.h oltre all'API JavaScript.

Aggiorna impostazioni 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 il codice "html" al file di destinazione. In altre parole, genererai un file "app.html" .
  • L'opzione di link dell'app USE_WEBGPU è necessaria per attivare il supporto di WebGPU in Emscripten. In caso contrario, il file main.cpp non può accedere al file webgpu/webgpu_cpp.h.
  • Qui è richiesta anche l'opzione di collegamento dell'app USE_GLFW per poter 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 dawn::webgpu_dawn glfw webgpu_glfw)
endif()

Aggiorna il codice

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

Anziché utilizzare un loop "time", chiama emscripten_set_main_loop(Render) per assicurarti che la funzione Render() venga chiamata a una frequenza fluida adeguata e in linea con il browser e il monitor.

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

Crea l'app con Emscripten

L'unica modifica necessaria per creare l'app con Emscripten è anteporre ai comandi cmake lo script shell magico di emcmake. Questa volta genera l'app in una sottocartella build-web e avvia un server HTTP. Infine, apri il browser e visita la pagina 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 della stabilizzazione delle API webgpu.h e webgpu_cpp.h.
  • Supporto iniziale Dawn per Android e iOS.

Nel frattempo, invia problemi WebGPU per Emscripten e Problemi Dawn con suggerimenti e domande.

Risorse

Esplora il codice sorgente di questa app.

Se vuoi approfondire la creazione da zero di applicazioni 3D native in C++ con WebGPU, dai un'occhiata alla documentazione sull'apprendimento di WebGPU per C++ e agli esempi di Dawn Native WebGPU.

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

Riconoscimenti

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

Foto di Marc-Olivier Jodoin su Unsplash.