Criar um app com a WebGPU

François Beaufort
François Beaufort

Para desenvolvedores da Web, a WebGPU é uma API de gráficos da Web que oferece acesso unificado e rápido a GPUs. O WebGPU expõe recursos de hardware modernos e permite renderização e operações de computação em uma GPU, semelhante ao Direct3D 12, Metal e Vulkan.

Embora seja verdade, essa história está incompleta. O WebGPU é o resultado de um esforço colaborativo, incluindo grandes empresas, como Apple, Google, Intel, Mozilla e Microsoft. Entre elas, alguns perceberam que a WebGPU poderia ser mais do que uma API JavaScript, mas uma API gráfica de várias plataformas para desenvolvedores em vários ecossistemas, além da Web.

Para atender ao caso de uso principal, uma API JavaScript foi introduzida no Chrome 113. No entanto, outro projeto significativo foi desenvolvido em conjunto com ela: a API C webgpu.h. Esse arquivo principal C lista todos os procedimentos e estruturas de dados disponíveis da WebGPU. Ela serve como uma camada de abstração de hardware independente da plataforma, permitindo que você crie aplicativos específicos da plataforma fornecendo uma interface consistente em diferentes plataformas.

Neste documento, você vai aprender a criar um pequeno app C++ usando a WebGPU que é executado na Web e em plataformas específicas. Alerta de spoiler: você vai receber o mesmo triângulo vermelho que aparece em uma janela do navegador e em uma janela de computador com ajustes mínimos na sua base de código.

Captura de tela de um triângulo vermelho com a tecnologia da WebGPU em uma janela do navegador e uma janela da área de trabalho no macOS.
O mesmo triângulo desenvolvido pela WebGPU em uma janela do navegador e uma janela de área de trabalho.

Como funciona?

Para conferir o aplicativo completo, confira o repositório do app multiplataforma WebGPU.

O app é um exemplo minimalista em C++ que mostra como usar a WebGPU para criar apps para computador e Web com uma única base de código. Em segundo plano, ele usa o webgpu.h da WebGPU como uma camada de abstração de hardware independente de plataforma usando um wrapper C++ chamado webgpu_cpp.h.

Na Web, o app é criado com o Emscripten, que tem vinculações que implementam o webgpu.h na API JavaScript. Em plataformas específicas, como macOS ou Windows, esse projeto pode ser criado com base na Dawn, a implementação da WebGPU entre plataformas do Chromium. Vale mencionar que a wgpu-native, uma implementação Rust do webgpu.h, também existe, mas não é usada neste documento.

Primeiros passos

Para começar, você precisa de um compilador C++ e do CMake para processar builds multiplataforma de maneira padrão. Em uma pasta dedicada, crie um arquivo de origem main.cpp e um arquivo de build CMakeLists.txt.

O arquivo main.cpp precisa conter uma função main() vazia por enquanto.

int main() {}

O arquivo CMakeLists.txt contém informações básicas sobre o projeto. A última linha especifica que o nome do executável é "app" e o código-fonte é 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")

Execute cmake -B build para criar arquivos de build em uma subpasta "build/" e cmake --build build para criar o app e gerar o arquivo executável.

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

# Run the app.
$ ./build/app

O app é executado, mas ainda não há saída, porque é necessário desenhar na tela.

Acessar Dawn

Para desenhar o triângulo, você pode aproveitar o Dawn, a implementação da WebGPU em várias plataformas do Chromium. Isso inclui a biblioteca C++ GLFW para desenhar na tela. Uma maneira de fazer o download do Dawn é adicioná-lo como um submódulo do Git ao seu repositório. Os comandos a seguir buscam o arquivo em uma subpasta "dawn/".

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

Em seguida, anexe ao arquivo CMakeLists.txt da seguinte maneira:

  • A opção DAWN_FETCH_DEPENDENCIES do CMake busca todas as dependências do Dawn.
  • A subpasta dawn/ está incluída no destino.
  • Seu app vai depender dos destinos dawn::webgpu_dawn, glfw e webgpu_glfw para que você possa usá-los no arquivo main.cpp depois.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE dawn::webgpu_dawn glfw webgpu_glfw)

Abrir uma janela

Agora que o Dawn está disponível, use o GLFW para desenhar coisas na tela. Por conveniência, essa biblioteca incluída em webgpu_glfw permite que você escreva um código independente da plataforma para o gerenciamento de janelas.

Para abrir uma janela chamada "Janela WebGPU" com uma resolução de 512x512, atualize o arquivo main.cpp conforme abaixo. glfwWindowHint() é usado aqui para solicitar a não inicialização de uma API de gráficos específica.

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

A recriação e a execução do app como antes agora resulta em uma janela vazia. Você está progredindo.

Captura de tela de uma janela vazia do macOS.
Uma janela vazia.

Conseguir dispositivo de GPU

Em JavaScript, navigator.gpu é o ponto de entrada para acessar a GPU. Em C++, é necessário criar manualmente uma variável wgpu::Instance que seja usada para o mesmo propósito. Para sua conveniência, declare instance na parte de cima do arquivo main.cpp e chame wgpu::CreateInstance() dentro de main().

…
#include <webgpu/webgpu_cpp.h>

wgpu::Instance instance;
…

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

O acesso à GPU é assíncrono devido ao formato da API JavaScript. Em C++, crie duas funções auxiliares chamadas GetAdapter() e GetDevice() que retornam, respectivamente, uma função de callback com um wgpu::Adapter e um 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 o acesso, declare duas variáveis wgpu::Adapter e wgpu::Device na parte de cima do arquivo main.cpp. Atualize a função main() para chamar GetAdapter() e atribuir o callback de resultado a adapter. Em seguida, chame GetDevice() e atribua o callback de resultado a device antes de chamar 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();
    });
  });
}

Desenhar um triângulo

A cadeia de troca não é exposta na API JavaScript, porque o navegador cuida disso. Em C++, você precisa criá-la manualmente. Mais uma vez, para sua conveniência, declare uma variável wgpu::Surface na parte de cima do arquivo main.cpp. Logo após criar a janela GLFW no Start(), chame a função wgpu::glfw::CreateSurfaceForWindow() útil para criar um wgpu::Surface (semelhante a uma tela HTML) e configure-o chamando a nova função auxiliar ConfigureSurface() em InitGraphics(). Você também precisa chamar surface.Present() para apresentar a próxima textura no loop while. Isso não tem efeito visível porque ainda não há renderização.

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

Agora é uma boa hora para criar o pipeline de renderização com o código abaixo. Para facilitar o acesso, declare uma variável wgpu::RenderPipeline na parte de cima do arquivo main.cpp e chame a função auxiliar CreateRenderPipeline() em 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 fim, envie comandos de renderização para a GPU na função Render() chamada em cada 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);
}

A recriação do app com o CMake e a execução dele agora resultam no tão esperado triângulo vermelho em uma janela. Faça uma pausa, você merece.

Captura de tela de um triângulo vermelho em uma janela do macOS.
Um triângulo vermelho em uma janela do computador.

Compilar para o WebAssembly

Vamos conferir as mudanças mínimas necessárias para ajustar a base de código atual e desenhar esse triângulo vermelho em uma janela do navegador. Novamente, o app é criado com base no Emscripten, uma ferramenta para compilar programas C/C++ para WebAssembly, que tem vinculações que implementam webgpu.h sobre a API JavaScript.

Atualizar as configurações do CMake

Depois que o Emscripten for instalado, atualize o arquivo de build CMakeLists.txt da seguinte maneira. O código destacado é a única coisa que você precisa mudar.

  • set_target_properties é usado para adicionar automaticamente a extensão de arquivo "html" ao arquivo de destino. Em outras palavras, você vai gerar um arquivo "app.html".
  • A opção de link do app USE_WEBGPU é necessária para ativar o suporte à WebGPU no Emscripten. Sem isso, o arquivo main.cpp não pode acessar o arquivo webgpu/webgpu_cpp.h.
  • A opção de link de app USE_GLFW também é necessária para que você possa reutilizar seu 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()

Atualizar o código

No Emscripten, a criação de um wgpu::surface requer um elemento de tela HTML. Para isso, chame instance.CreateSurface() e especifique o seletor #canvas para corresponder ao elemento de tela HTML apropriado na página HTML gerada pelo Emscripten.

Em vez de usar um loop while, chame emscripten_set_main_loop(Render) para garantir que a função Render() seja chamada em uma taxa adequada e suave que se alinhe corretamente com o navegador e o 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
}

Criar o app com o Emscripten

A única mudança necessária para criar o app com o Emscripten é adicionar os comandos cmake com o script de shell emcmake mágico. Dessa vez, gere o app em uma subpasta build-web e inicie um servidor HTTP. Por fim, abra o navegador e acesse 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 tela de um triângulo vermelho em uma janela do navegador.
Um triângulo vermelho em uma janela do navegador.

A seguir

Saiba o que esperar no futuro:

  • Melhorias na estabilização das APIs webgpu.h e webgpu_cpp.h.
  • Suporte inicial do Dawn para Android e iOS.

Enquanto isso, envie problemas da WebGPU para Emscripten e problemas do Dawn com sugestões e perguntas.

Recursos

Confira o código-fonte deste app.

Se você quiser se aprofundar na criação de aplicativos 3D nativos em C++ do zero com a WebGPU, consulte a documentação de Aprenda a WebGPU para C++ e os exemplos de WebGPU nativa do Dawn.

Se você tiver interesse no Rust, também poderá explorar a biblioteca gráfica wgpu baseada na WebGPU. Confira a demonstração hello-triangle.

Agradecimentos

Este artigo foi revisado por Corentin Wallez, Kai Ninomiya e Rachel Andrew.

Foto de Marc-Olivier Jodoin no Unsplash.