Criar um app com a WebGPU

François Beaufort
François Beaufort

Para desenvolvedores da Web, a WebGPU é uma API gráfica da Web que fornece acesso unificado e rápido às GPUs. A WebGPU expõe recursos modernos de hardware e permite operações de renderização e computação em uma GPU, semelhantes a Direct3D 12, Metal e Vulkan.

Embora verdadeira, essa história está incompleta. A WebGPU é o resultado de um esforço colaborativo que inclui grandes empresas, como Apple, Google, Intel, Mozilla e Microsoft. Entre eles, alguns perceberam que a WebGPU poderia ser mais do que uma API JavaScript, mas uma API gráfica multiplataforma para desenvolvedores em ecossistemas diferentes, além da Web.

Para atender ao caso de uso principal, uma API JavaScript foi introduzida no Chrome 113. No entanto, outro projeto importante foi desenvolvido em conjunto: a API C webgpu.h. Esse arquivo de cabeçalho C lista todos os procedimentos e estruturas de dados disponíveis da WebGPU. Ele serve como uma camada de abstração de hardware independente de 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ê verá o mesmo triângulo vermelho que aparece na janela do navegador e na janela da área de trabalho, 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 de uma janela de computador no macOS.
O mesmo triângulo com tecnologia da WebGPU em uma janela do navegador e uma janela de computador.

Como funciona?

Para ver o aplicativo concluído, confira o repositório App multiplataforma WebGPU.

O app é um exemplo minimalista de C++ que mostra como usar a WebGPU na criação de apps da Web e para computador com uma única base de código. Internamente, 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 bindings implementando webgpu.h sobre a API JavaScript. Em plataformas específicas, como macOS ou Windows, esse projeto pode ser criado com o Dawn, a implementação da WebGPU multiplataforma do Chromium. É importante mencionar que wgpu-native, uma implementação do Rust do webgpu.h, também existe, mas não é usada neste documento.

Começar

Para começar, você precisa de um compilador C++ e o CMake para processar builds multiplataforma de maneira padrão. Dentro de 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 tem saída, porque você precisa de uma maneira de desenhar coisas na tela.

Amanhecer

Para desenhar o triângulo, você pode aproveitar a Dawn, a implementação da WebGPU multiplataforma 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 git ao seu repositório. Os comandos a seguir o buscam 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 dependerá dos destinos webgpu_cpp, webgpu_dawn e webgpu_glfw para que você possa usá-los no arquivo main.cpp posteriormente.
…
set(DAWN_FETCH_DEPENDENCIES ON)
add_subdirectory("dawn" EXCLUDE_FROM_ALL)
target_link_libraries(app PRIVATE webgpu_cpp webgpu_dawn webgpu_glfw)

Abrir uma janela

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

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

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

Recriar o app e executá-lo como antes resulta em uma janela vazia. Você está progredindo!

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

Acessar dispositivo da GPU

Em JavaScript, navigator.gpu é seu ponto de entrada para acessar a GPU. Em C++, você precisa criar manualmente uma variável wgpu::Instance usada para a mesma finalidade. Por 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 uma função auxiliar GetDevice() que use um argumento de função de callback e o chame com o wgpu::Device resultante.

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

Para facilitar o acesso, declare uma variável wgpu::Device na parte de cima do arquivo main.cpp e atualize a função main() para chamar GetDevice() e atribua o callback de resultado a device antes de chamar Start().

wgpu::Device device;
…

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

Desenhar um triângulo

A cadeia de troca não é exposta na API JavaScript porque o navegador cuida disso. Em C++, é preciso criá-la manualmente. Mais uma vez, por conveniência, declare uma variável wgpu::SwapChain na parte de cima do arquivo main.cpp. Logo depois de criar a janela GLFW em Start(), chame a útil função wgpu::glfw::CreateSurfaceForWindow() para criar um wgpu::Surface (semelhante a uma tela HTML) e use-o para configurar a cadeia de troca chamando a nova função auxiliar SetupSwapChain() em InitGraphics(). Também é necessário chamar swapChain.Present() para apresentar a próxima textura na repetição "while". Isso não tem efeito visível, porque ainda não há renderização acontecendo.

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

Agora é um bom momento 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 = 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();
}

Por fim, envie comandos de renderização para a GPU na função Render() chamada de cada 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);
}

A recriação do app com o CMake e a execução dele agora resulta 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 da área de trabalho.

Compilar para o WebAssembly

Agora, vamos conferir as mudanças mínimas necessárias para ajustar sua base de código e desenhar esse triângulo vermelho em uma janela do navegador. Novamente, o app é compilado com o Emscripten, uma ferramenta de compilação de programas C/C++ para o WebAssembly, que tem bindings implementando webgpu.h sobre a API JavaScript.

Atualizar configurações do CMake

Depois que o Emscripten estiver instalado, atualize o arquivo de build CMakeLists.txt desta forma. A única coisa que você precisa mudar é o código destacado.

  • 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, seu arquivo main.cpp não pode acessar o arquivo webgpu/webgpu_cpp.h.
  • A opção de link do app USE_GLFW também é necessária aqui 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 webgpu_cpp webgpu_dawn webgpu_glfw)
endif()

Atualizar o código

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

Em vez de usar uma repetição "while", chame emscripten_set_main_loop(Render) para garantir que a função Render() seja chamada em uma taxa uniforme adequada e alinhada 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};
  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
}

Criar o app com o Emscripten

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

A seguir

O que esperar no futuro:

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

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

Recursos

Fique à vontade para explorar o código-fonte desse app.

Se você quiser se aprofundar na criação de aplicativos 3D nativos em C++ do zero com a WebGPU, confira a documentação sobre WebGPU para C++ e Exemplos de WebGPU nativa da Dawn.

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

Agradecimentos

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

Foto de Marc-Olivier Jodoin no Unsplash.