Criar um app com a WebGPU

François Beaufort
François Beaufort

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

Embora verdadeiro, essa história está incompleta. A WebGPU é o resultado de um processo incluindo grandes empresas, como Apple, Google, Intel, Mozilla e da Microsoft. Entre elas, alguns perceberam que a WebGPU poderia ser mais do que uma API JavaScript, mas um conjunto de elementos gráficos API para desenvolvedores em vários ecossistemas, além da Web.

Para atender ao caso de uso principal, foi usada uma API JavaScript lançados no Chrome 113. No entanto, outro processo foi desenvolvido junto com ele: o webgpu.h C API. Este 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, o que permite que você crie aplicativos específicos da plataforma, fornecendo uma interface consistente em diferentes plataformas.

Neste documento, você aprenderá a criar um pequeno app em C++ usando a WebGPU que pode ser executada na Web e em plataformas específicas. Spoiler: você vai encontrar o mesmo triângulo vermelho que aparece na janela do navegador e em uma 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 uma janela da área de trabalho no macOS.
O mesmo triângulo que usa a tecnologia da WebGPU em uma janela do navegador e uma janela da área de trabalho.

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 para criar apps da Web e de área de trabalho usando 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 por um wrapper C++ chamado webgpu_cpp.h.

Na Web, o app é criado com o Emscripten, que tem vinculações para implementar webgpu.h com a API JavaScript. Em plataformas específicas, como macOS ou Windows, esse projeto pode ser criado com base na Dawn, a implementação WebGPU multiplataforma do Chromium. O wgpu-native, uma implementação do Rust do webgpu.h, também existe, mas não é usado 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 uma 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 um "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.

Amanhecer

Para desenhar seu triângulo, use o Dawn, a implementação 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 seguintes comandos o buscam em uma "dawn/" subpasta.

$ 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/ é 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 códigos independentes de plataforma para gerenciamento de janelas.

Para abrir uma janela chamada "Janela WebGPU" com uma resolução de 512 x 512, atualize o arquivo main.cpp conforme mostrado abaixo. Observe que glfwWindowHint() é usado aqui para solicitar nenhuma inicialização específica da API grá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.

Acessar dispositivo GPU

Em JavaScript, navigator.gpu é o ponto de entrada para acessar a GPU. Em C++, você precisa criar manualmente uma variável wgpu::Instance que seja usada para a mesma finalidade. Para facilitar, 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 uma função de callback com um wgpu::Adapter e um wgpu::Device, respectivamente.

#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 atribua o callback resultante a adapter. Em seguida, chame GetDevice() e atribua o callback resultante 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 faz isso. Em C++, você precisa criá-la manualmente. Mais uma vez, por conveniência, declare uma variável wgpu::Surface na parte superior 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(). Também é necessário chamar surface.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.

#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 é 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 = 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 à GPU na função Render() com o nome de 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 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 de área de trabalho.

Compilar no WebAssembly

Vamos dar uma olhada nas mudanças mínimas necessárias para ajustar sua base de código e desenhar esse triângulo vermelho em uma janela do navegador. Mais uma vez, o app foi criado com a Emscripten, uma ferramenta para compilar programas em C/C++ para o WebAssembly, que tem vinculações para implementar webgpu.h com 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 alterar.

  • set_target_properties é usado para adicionar automaticamente o "html" ao arquivo de destino. Em outras palavras, você vai gerar um "app.html" .
  • A opção de link de 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 do app USE_GLFW também é necessária aqui para que você possa reutilizar o 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 pela 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 suave adequada, 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};
  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 é incluir o script de shell emcmake magical no início dos comandos cmake. Desta 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 predefinido para Android e iOS.

Enquanto isso, informe os problemas da WebGPU relacionados ao Emscripten e aos problemas do Dawn com sugestões e perguntas.

Recursos

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

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

Se você tiver interesse no Rust, também poderá explorar a biblioteca gráfica 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.