Compila una app con WebGPU

François Beaufort
François Beaufort

Para desarrolladores web, WebGPU es una API de gráficos web que proporciona acceso a GPU. WebGPU expone las capacidades de hardware moderno y permite la renderización y cálculos en una GPU, similares a Direct3D 12, Metal y Vulkan.

Si bien es real, esa historia está incompleta. WebGPU es el resultado de un proceso que incluye empresas importantes, como Apple, Google, Intel, Mozilla y Microsoft. Entre ellos, algunos se dieron cuenta que WebGPU podría ser más que una API de JavaScript, pero un gráfico API para desarrolladores de diferentes ecosistemas, distintos de la Web.

Para cumplir con el caso de uso principal, se aplicó una API de JavaScript presentado en Chrome 113. Sin embargo, otro punto importante proyecto se desarrolló junto con él: el webgpu.h C en la API de Cloud. Este archivo de encabezado C enumera todos los procedimientos y estructuras de datos disponibles de WebGPU. Sirve como una capa de abstracción de hardware independiente de la plataforma, lo que permite que te permite compilar aplicaciones específicas de las plataformas proporcionando una interfaz coherente en diferentes plataformas.

En este documento, aprenderás a escribir una app pequeña de C++ con WebGPU que se ejecute en la Web y en plataformas específicas. En la alerta de spoiler, verás el mismo triángulo rojo que aparece en una ventana del navegador y una ventana de escritorio con ajustes mínimos en tu base de código.

Captura de pantalla de un triángulo rojo con la tecnología de WebGPU en una ventana del navegador y una ventana de escritorio en macOS.
El mismo triángulo con la tecnología de WebGPU en una ventana del navegador y una de escritorio.

¿Cómo funciona?

Para ver la aplicación completa, consulta el repositorio de apps multiplataforma de WebGPU.

La app es un ejemplo de C++ minimalista en el que se muestra cómo usar WebGPU para compilar apps web y de escritorio a partir de una base de código única. De forma interna, usa webgpu.h de WebGPU como una capa de abstracción de hardware independiente de la plataforma a través de un wrapper de C++ llamado webgpu_cpp.h.

En la Web, la app se basa en Emscripten, que tiene vinculaciones que implementan webgpu.h sobre la API de JavaScript. En plataformas específicas como macOS o Windows, este proyecto se puede compilar con Dawn, la implementación multiplataforma de WebGPU de Chromium. Vale la pena mencionar que wgpu-native, una implementación de Rust de webgpu.h, también existe, pero que no se usa en este documento.

Comenzar

Para comenzar, necesitas un compilador C++ y CMake para manejar compilaciones multiplataforma de manera estándar. Dentro de una carpeta exclusiva, crea un Archivo fuente main.cpp y archivo de compilación CMakeLists.txt

El archivo main.cpp debería contener una función main() vacía por el momento.

int main() {}

El archivo CMakeLists.txt contiene información básica sobre el proyecto. La última línea especifica que el nombre ejecutable es "app" y su código fuente es 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")

Ejecuta cmake -B build para crear archivos de compilación en una instancia de “build/”. y cmake --build build para compilar la app y generar el archivo ejecutable.

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

# Run the app.
$ ./build/app

La app se ejecuta, pero aún no hay salida, ya que necesitas una forma de dibujar elementos en la pantalla.

Amanece

Para dibujar tu triángulo, puedes aprovechar Dawn, la implementación multiplataforma de WebGPU de Chromium. Esto incluye la biblioteca C++ de GLFW para dibujar en la pantalla. Una forma de descargar Dawn es agregarla como un submódulo de Git a tu repositorio. Los siguientes comandos la recuperan en un “dawn/” subcarpeta.

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

Luego, agrégalo al archivo CMakeLists.txt de la siguiente manera:

  • La opción DAWN_FETCH_DEPENDENCIES de CMake recupera todas las dependencias de Dawn.
  • La subcarpeta dawn/ se incluye en el destino.
  • Tu app dependerá de los destinos dawn::webgpu_dawn, glfw y webgpu_glfw para que puedas usarlos en el archivo main.cpp más adelante.

set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE dawn::webgpu_dawn glfw webgpu_glfw)

Abrir una ventana

Ahora que Dawn está disponible, usa GLFW para dibujar elementos en la pantalla. Esta biblioteca incluida en webgpu_glfw para tu comodidad te permite escribir código independiente de la plataforma para la administración de ventanas.

Abre una ventana llamada “WebGPU window” con una resolución de 512 x 512, actualiza el archivo main.cpp como se muestra a continuación. Ten en cuenta que glfwWindowHint() se usa aquí para no solicitar ninguna inicialización de la API de gráficos en particular.

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

Volver a compilar la app y ejecutarla como antes generará una ventana vacía. Estás progresando.

Captura de pantalla de una ventana vacía de macOS.
Una ventana vacía.

Obtener dispositivo de GPU

En JavaScript, navigator.gpu es tu punto de entrada para acceder a la GPU. En C++, debes crear manualmente una variable wgpu::Instance que se use con el mismo propósito. Para mayor comodidad, declara instance en la parte superior del archivo main.cpp y llama a wgpu::CreateInstance() dentro de main().


#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;


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

El acceso a la GPU es asíncrono debido a la forma de la API de JavaScript. En C++, crea dos funciones auxiliares llamadas GetAdapter() y GetDevice() que, respectivamente, muestren una función de devolución de llamada con wgpu::Adapter y 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));
}

Para facilitar el acceso, declara dos variables wgpu::Adapter y wgpu::Device en la parte superior del archivo main.cpp. Actualiza la función main() para llamar a GetAdapter() y asigna su devolución de llamada de resultado a adapter. Luego, llama a GetDevice() y asigna la devolución de llamada resultante a device antes de llamar a 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();
    });
  });
}

Dibuja un triángulo

La cadena de intercambio no se expone en la API de JavaScript, ya que el navegador se encarga de ella. En C++, debes crearlo de forma manual. Una vez más, para mayor comodidad, declara una variable wgpu::Surface en la parte superior del archivo main.cpp. Justo después de crear la ventana de GLFW en Start(), llama a la práctica función wgpu::glfw::CreateSurfaceForWindow() para crear un wgpu::Surface (similar a un lienzo HTML) y configúralo llamando a la nueva función auxiliar ConfigureSurface() en InitGraphics(). También debes llamar a surface.Present() para presentar la siguiente textura en el bucle while. Esto no tiene ningún efecto visible, ya que aún no se está procesando.

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

Ahora es un buen momento para crear la canalización de renderización con el siguiente código. Para facilitar el acceso, declara una variable wgpu::RenderPipeline en la parte superior del archivo main.cpp y llama a la función auxiliar CreateRenderPipeline() en 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();
}

Por último, envía los comandos de renderización a la GPU en la función Render() a los que se llama cada fotograma.

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

Volver a compilar la app con CMake y ejecutarla ahora genera el tan esperado triángulo rojo en una ventana. Tómate un descanso, te lo mereces.

Captura de pantalla de un triángulo rojo en una ventana de macOS.
Un triángulo rojo en una ventana de escritorio.

Cómo compilar en WebAssembly

Ahora, veamos los cambios mínimos que se requieren para ajustar tu base de código existente y dibujar este triángulo rojo en una ventana del navegador. Nuevamente, la app se basa en Emscripten, una herramienta para compilar programas C/C++ en WebAssembly, que tiene vinculaciones que implementan webgpu.h sobre la API de JavaScript.

Cómo actualizar la configuración de CMake

Una vez que instales Emscripten, actualiza el archivo de compilación CMakeLists.txt como se indica a continuación. El código destacado es lo único que debes cambiar.

  • set_target_properties se usa para agregar automáticamente el código "html" al archivo de destino. En otras palabras, generarás un archivo "app.html" .
  • Se requiere la opción USE_WEBGPU de vínculo de la app para habilitar la compatibilidad con WebGPU en Emscripten. Sin él, tu archivo main.cpp no podrá acceder al archivo webgpu/webgpu_cpp.h.
  • También se requiere la opción USE_GLFW de vínculo de la app para que puedas volver a usar tu código 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()

Actualiza el código

En Emscripten, la creación de un wgpu::surface requiere un elemento lienzo HTML. Para ello, llama a instance.CreateSurface() y especifica el selector #canvas para que coincida con el elemento de lienzo HTML apropiado en la página HTML que genera Emscripten.

En lugar de usar un bucle while, llama a emscripten_set_main_loop(Render) para asegurarte de que se llame a la función Render() a una velocidad de fluidez adecuada que se alinee correctamente con el navegador y el 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
}

Compila la app con Emscripten

El único cambio necesario para compilar la app con Emscripten es anteponer los comandos cmake con la secuencia de comandos de shell mágica emcmake. Esta vez, genera la app en una subcarpeta build-web y, luego, inicia un servidor HTTP. Por último, abre el navegador y 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
Captura de pantalla de un triángulo rojo en una ventana del navegador.
Un triángulo rojo en una ventana del navegador.

¿Qué sigue?

Esto es lo que puedes esperar en el futuro:

  • Mejoras en la estabilización de las APIs de webgpu.h y webgpu_cpp.h
  • Compatibilidad inicial de Dawn para iOS y Android.

Mientras tanto, informa los problemas de WebGPU para Emscripten y Dawn con sugerencias y preguntas.

Recursos

Si lo deseas, puedes explorar el código fuente de esta app.

Si quieres profundizar más en la creación de aplicaciones 3D nativas en C++ desde cero con WebGPU, consulta la documentación de Más información sobre WebGPU para C++ y los Ejemplos de WebGPU nativa de Daawn.

Si te interesa Rust, también puedes explorar la biblioteca de gráficos de wgpu basada en WebGPU. Revisa su demostración de hello-triangle.

Agradecimientos

Corentin Wallez, Kai Ninomiya y Rachel Andrew revisaron este artículo.

Foto de Marc-Olivier Jodoin en Unsplash.