Para los desarrolladores web, WebGPU es una API de gráficos web que proporciona acceso unificado y rápido a las GPU. WebGPU expone capacidades de hardware modernas y permite operaciones de renderización y procesamiento en una GPU, de manera similar a Direct3D 12, Metal y Vulkan.
Si bien es cierto, la historia está incompleta. WebGPU es el resultado de un esfuerzo colaborativo, que incluye a grandes empresas, como Apple, Google, Intel, Mozilla y Microsoft. Por ejemplo, algunos se dieron cuenta de que WebGPU podía ser más que una API de JavaScript, sino una API multiplataforma de gráficos para desarrolladores de diversos ecosistemas distintos de la Web.
Para entregar el caso de uso principal, se presentó una API de JavaScript en Chrome 113. Sin embargo, se desarrolló otro proyecto importante junto con él: la API de C de webgpu.h. En este archivo de encabezado C, se enumeran todos los procedimientos y las estructuras de datos disponibles de WebGPU. Sirve como una capa de abstracción de hardware independiente de la plataforma que te permite compilar aplicaciones específicas de la plataforma proporcionando una interfaz coherente en diferentes plataformas.
En este documento, aprenderás a escribir una app de C++ pequeña con WebGPU que se ejecute en la Web y en plataformas específicas. Alerta de spoilers, verás el mismo triángulo rojo que aparece en una ventana del navegador y en una ventana de escritorio con una cantidad mínima de ajustes en tu base de código.
¿Cómo funciona?
Para ver la aplicación completa, consulta el repositorio de app multiplataforma de WebGPU.
La app es un ejemplo minimalista de C++ que muestra cómo usar WebGPU para compilar apps web y de escritorio a partir de una sola base de código. 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 aplicación se compiló con 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. Es importante mencionar que wgpu-native, una implementación de Rust de webgpu.h, que también existe, pero que no se usa en este documento.
Comenzar
Para comenzar, necesitas un compilador de C++ y CMake para manejar compilaciones multiplataforma de manera estándar. Dentro de una carpeta dedicada, crea un archivo fuente main.cpp
y un archivo de compilación CMakeLists.txt
.
Por el momento, el archivo main.cpp
debe contener una función main()
vacía.
int main() {}
El archivo CMakeLists.txt
contiene información básica sobre el proyecto. La última línea especifica que el nombre del 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 subcarpeta “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 resultados, ya que necesitas una forma de dibujar elementos en la pantalla.
Obtener el amanecer
Para dibujar el triángulo, puedes aprovechar Dawn, la implementación multiplataforma de WebGPU de Chromium. Esto incluye la biblioteca GLFW C++ para dibujar en la pantalla. Una forma de descargar Dawn es agregarlo como un submódulo de Git a tu repositorio. Los siguientes comandos lo recuperan en una subcarpeta “dawn/”.
$ git init
$ git submodule add https://dawn.googlesource.com/dawn
Luego, anexa los datos 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 objetivos
webgpu_cpp
,webgpu_dawn
,glfw
ywebgpu_glfw
para que puedas usarlos en el archivomain.cpp
más adelante.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn glfw webgpu_glfw)
Abrir una ventana
Ahora que Dawn está disponible, usa GLFW para dibujar elementos en la pantalla. Esta biblioteca se incluye en webgpu_glfw
para tu comodidad y te permite escribir código independiente de la plataforma para la administración de ventanas.
Para abrir una ventana llamada "ventana de WebGPU" 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 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 ahora generará una ventana vacía. ¡Estás progresando!
Obtener dispositivo GPU
En JavaScript, navigator.gpu
es el punto de entrada para acceder a la GPU. En C++, debes crear manualmente una variable wgpu::Instance
que se use con el mismo fin. 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 muestren respectivamente 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 asignar su devolución de llamada de resultados a adapter
. Luego, llama a GetDevice()
y asigna su devolución de llamada de resultado 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();
});
});
}
Dibujar un triángulo
La cadena de intercambio no se expone en la API de JavaScript porque el navegador se encarga de ello. En C++, debes crearla manualmente. 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 un efecto visible, ya que aún no se está realizando la renderización.
#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 comandos de renderización a la GPU en la función Render()
llamada a 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 dará como resultado el esperado triángulo rojo en una ventana. Toma un descanso, te lo mereces.
Compila en WebAssembly
Ahora, veamos los cambios mínimos necesarios para ajustar tu base de código existente y dibujar este triángulo rojo en una ventana del navegador. Nuevamente, la app se compiló con Emscripten, una herramienta para compilar programas C/C++ para WebAssembly, que tiene vinculaciones que implementan webgpu.h sobre la API de JavaScript.
Cómo actualizar la configuración de CMake
Una vez que Emscripten esté instalado, 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 la extensión de archivo "html" al archivo de destino. En otras palabras, generarás un archivo "app.html".- Se requiere la opción de vínculo de app
USE_WEBGPU
para habilitar la compatibilidad con WebGPU en Emscripten. Sin este permiso, el archivomain.cpp
no podrá acceder al archivowebgpu/webgpu_cpp.h
. - La opción de vínculo de app
USE_GLFW
también es obligatoria para que puedas reutilizar 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 webgpu_cpp webgpu_dawn glfw webgpu_glfw)
endif()
Actualiza el código
En Emscripten, crear un wgpu::surface
requiere un elemento de lienzo HTML. Para ello, llama a instance.CreateSurface()
y especifica el selector #canvas
de modo que coincida con el elemento de lienzo HTML adecuado en la página HTML generada por 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()
con una frecuencia fluida 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 de 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
¿Qué sigue?
Esto es lo que puedes esperar en el futuro:
- Se realizaron mejoras en la estabilización de las APIs de webgpu.h y webgpu_cpp.h.
- Compatibilidad inicial para iOS y Android de Dawn.
Mientras tanto, informa los problemas de WebGPU para Emscripten y los problemas de Dawn con sugerencias y preguntas.
Recursos
No dudes en explorar el código fuente de esta app.
Si quieres obtener más información sobre la creación de aplicaciones 3D nativas en C++ desde cero con WebGPU, consulta la documentación sobre WebGPU para C++ y los ejemplos de WebGPU nativa de Dawn.
Si te interesa Rust, también puedes explorar la biblioteca de gráficos de wgpu basada en WebGPU. Observa 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.