Seu primeiro app WebGPU

1. Introdução

O logotipo da WebGPU consiste em vários triângulos azuis que formam um "W" estilizado

Última atualização:13/04/2023

O que é WebGPU?

WebGPU é uma API nova e moderna para acessar os recursos da sua GPU em apps da Web.

API moderna

Antes do WebGPU, havia o WebGL, que oferecia um subconjunto dos recursos do WebGPU. Ele possibilitou uma nova classe de conteúdo avançado na Web, e os desenvolvedores criaram coisas incríveis com ele. No entanto, ela foi baseada na API OpenGL ES 2.0, lançada em 2007, com base na API OpenGL ainda mais antiga. Nesse período, as GPUs evoluíram significativamente, e as APIs nativas usadas para interagir com elas também evoluíram com Direct3D 12, Metal e Vulkan.

A WebGPU traz os avanços dessas APIs modernas para a plataforma web. Ele se concentra na ativação de recursos da GPU de várias plataformas, apresentando uma API que parece natural na Web e menos detalhada do que algumas das APIs nativas que ela foi criada.

Renderização

As GPUs costumam ser associadas à renderização de gráficos rápidos e detalhados, e a WebGPU não é exceção. Ele tem os recursos necessários para oferecer suporte a muitas das técnicas de renderização mais conhecidas atualmente nas GPUs de computadores e dispositivos móveis, além de oferecer um caminho para novos recursos serem adicionados no futuro, à medida que os recursos de hardware continuarem a evoluir.

Computação

Além da renderização, a WebGPU libera o potencial da sua GPU para executar cargas de trabalho de uso geral altamente paralelas. Esses sombreadores de computação podem ser usados de maneira independente, sem qualquer componente de renderização, ou como uma parte totalmente integrada do pipeline de renderização.

No codelab de hoje, você aprenderá a aproveitar os recursos de renderização e computação da WebGPU para criar um projeto introdutório simples.

O que você vai criar

Neste codelab, você criará o Game of Life da Conway usando a WebGPU. Esse app vai:

  • Use os recursos de renderização do WebGPU para desenhar gráficos 2D simples.
  • Use os recursos de computação do WebGPU para realizar a simulação.

Uma captura de tela do produto final deste codelab

O Jogo da vida é o que é conhecido como autômato celular, em que uma grade de células muda de estado ao longo do tempo com base em algum conjunto de regras. No Jogo da Vida, as células ficam ativas ou inativas, dependendo de quantas células vizinhas estiverem ativas, o que leva a padrões interessantes que flutuam conforme você observa.

O que você vai aprender

  • Como configurar o WebGPU e configurar uma tela.
  • Como desenhar geometria simples em 2D.
  • Como usar sombreadores de vértice e fragmento para modificar o que está sendo desenhado.
  • Como usar sombreadores de computação para realizar uma simulação simples.

O foco deste codelab é a introdução dos conceitos fundamentais por trás da WebGPU. Ele não se destina a ser uma revisão abrangente da API e também não abrange (ou exige) tópicos relacionados com frequência, como matemática com matriz 3D.

O que é necessário

  • Uma versão recente do Chrome (113 ou mais recente) no ChromeOS, macOS ou Windows WebGPU é uma API entre navegadores e plataformas, mas ainda não foi enviada para todos os lugares.
  • Conhecimento sobre HTML, JavaScript e Chrome DevTools.

A familiaridade com outras APIs de gráficos, como WebGL, Metal, Vulkan ou Direct3D, não é obrigatória, mas se você tiver alguma experiência com elas, provavelmente notará muitas semelhanças com a WebGPU que podem ajudar a iniciar o aprendizado.

2. Começar a configuração

Buscar o código

Este codelab não tem nenhuma dependência e orienta você em todas as etapas necessárias para criar o app WebGPU. Portanto, você não precisa de nenhum código para começar. No entanto, alguns exemplos funcionais que podem servir como pontos de verificação estão disponíveis em https://glitch.com/edit/#!/your-first-webgpu-app. Você pode conferi-los e consultá-los quando precisar.

Use o console do desenvolvedor.

A WebGPU é uma API bastante complexa, com muitas regras que impõem o uso adequado. E, o que é pior, por causa do funcionamento da API, ela não consegue gerar exceções típicas do JavaScript para muitos erros, dificultando a identificação exata da origem do problema.

Você enfrentará problemas ao desenvolver com a WebGPU, especialmente como iniciante, e isso não é um problema. Os desenvolvedores por trás da API estão cientes dos desafios de trabalhar com o desenvolvimento de GPU e trabalharam duro para garantir que, sempre que seu código WebGPU causar um erro, você receberá mensagens muito detalhadas e úteis no console do desenvolvedor, que ajudam a identificar e corrigir o problema.

Manter o console aberto enquanto trabalha em qualquer aplicativo da Web é sempre útil, mas isso se aplica especialmente aqui.

3. Inicializar WebGPU

Comece com um <canvas>

A WebGPU pode ser usada sem mostrar nada na tela, se você quer apenas usá-la para fazer cálculos. Mas se quiser renderizar algo, como vamos fazer no codelab, você precisa de uma tela. Portanto, esse é um bom começo.

Crie um novo documento HTML com um único elemento <canvas>, bem como uma tag <script> em que consultamos o elemento canvas. Ou use o 00-starter-page.html da falha.

  • Crie um arquivo index.html com o seguinte código:

index.html

<!doctype html>

<html>
  <head>
    <meta charset="utf-8">
    <title>WebGPU Life</title>
  </head>
  <body>
    <canvas width="512" height="512"></canvas>
    <script type="module">
      const canvas = document.querySelector("canvas");

      // Your WebGPU code will begin here!
    </script>
  </body>
</html>

Solicitar um adaptador e um dispositivo

Agora você pode entrar nos bits da WebGPU! Primeiro, considere APIs como o WebGPU podem levar algum tempo para se propagar por todo o ecossistema da Web. Como resultado, uma boa primeira medida de precaução é verificar se o navegador do usuário pode usar WebGPU.

  1. Para verificar se o objeto navigator.gpu, que serve como ponto de entrada para WebGPU, existe, adicione o seguinte código:

index.html

if (!navigator.gpu) {
  throw new Error("WebGPU not supported on this browser.");
}

O ideal é informar ao usuário se a WebGPU não está disponível fazendo com que a página volte para um modo que não use WebGPU. (em vez de usar WebGL?) No entanto, para os fins deste codelab, basta gerar um erro para interromper a execução do código.

Depois que você souber que o WebGPU é compatível com o navegador, a primeira etapa para o inicializar é solicitar um GPUAdapter. Você pode pensar em um adaptador como a representação da WebGPU de uma peça específica de hardware de GPU no dispositivo.

  1. Para acessar um adaptador, use o método navigator.gpu.requestAdapter(). Ela retorna uma promessa, então é mais conveniente chamá-la com await.

index.html

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found.");
}

Se nenhum adaptador apropriado for encontrado, o valor de adapter retornado poderá ser null, então convém manipular essa possibilidade. Isso pode acontecer se o navegador do usuário for compatível com WebGPU, mas o hardware da GPU não tiver todos os recursos necessários para usar a WebGPU.

Na maioria das vezes, não há problema em simplesmente deixar o navegador escolher um adaptador padrão, como você faz aqui, mas para necessidades mais avançadas, há argumentos que podem ser transmitidos para requestAdapter() que especificam se você quer usar hardware de baixo consumo ou de alto desempenho em dispositivos com várias GPUs (como alguns laptops).

Quando você tiver um adaptador, a última etapa antes de começar a trabalhar com a GPU é solicitar um GPUDevice. O dispositivo é a interface principal por meio da qual ocorre a maior interação com a GPU.

  1. Receba o dispositivo chamando adapter.requestDevice(), que também retorna uma promessa.

index.html

const device = await adapter.requestDevice();

Assim como em requestAdapter(), há opções que podem ser passadas para usos mais avançados, como ativar recursos de hardware específicos ou solicitar limites mais altos, mas, para seus objetivos, os padrões funcionam bem.

Configurar o Canvas

Agora que você tem um dispositivo, há mais uma coisa a fazer se quiser usá-lo para mostrar qualquer coisa na página: configure a tela a ser usada com o dispositivo que acabou de criar.

  • Para fazer isso, primeiro solicite um GPUCanvasContext da tela chamando canvas.getContext("webgpu"). Essa é a mesma chamada que você usaria para inicializar contextos Canvas 2D ou WebGL, usando os tipos de contexto 2d e webgl, respectivamente. O context retornado precisa estar associado ao dispositivo usando o método configure(), desta forma:

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

Há algumas opções que podem ser transmitidas aqui, mas as mais importantes são a device com que você vai usar o contexto e a format, que é o formato de textura que o contexto precisa usar.

Texturas são os objetos que o WebGPU usa para armazenar dados de imagens, e cada textura tem um formato que permite que a GPU saiba como esses dados são dispostos na memória. Os detalhes de como a memória da textura funciona estão além do escopo deste codelab. O importante é que o contexto do canvas forneça texturas para o código, e o formato que você usa pode ter impacto na eficiência com que a tela exibe essas imagens. Diferentes tipos de dispositivos apresentam melhor desempenho quando usam diferentes formatos de textura e, se você não usar o formato preferido do dispositivo, pode haver cópias extras de memória nos bastidores antes que a imagem possa ser exibida como parte da página.

Felizmente, você não precisa se preocupar muito com isso porque o WebGPU informa qual formato usar para sua tela. Em quase todos os casos, você quer transmitir o valor retornado chamando navigator.gpu.getPreferredCanvasFormat(), conforme mostrado acima.

Limpar a tela

Agora que você tem um dispositivo e a tela foi configurada com ele, comece a usá-lo para alterar o conteúdo da tela. Para começar, apague-o com uma cor sólida.

Para fazer isso, ou praticamente qualquer outra coisa na WebGPU, é preciso fornecer alguns comandos para a GPU instruindo o que fazer.

  1. Para fazer isso, peça para o dispositivo criar um GPUCommandEncoder, que fornece uma interface para gravar comandos da GPU.

index.html

const encoder = device.createCommandEncoder();

Os comandos que você quer enviar para a GPU estão relacionados à renderização (neste caso, a limpeza da tela). A próxima etapa é usar o encoder para iniciar um Pass de renderização.

As passagens de renderização ocorrem quando todas as operações de desenho na WebGPU acontecem. Cada uma começa com uma chamada beginRenderPass(), que define as texturas que recebem a saída dos comandos de desenho realizados. Os usos mais avançados podem fornecer várias texturas, chamadas de anexos, com várias finalidades, como armazenar a profundidade da geometria renderizada ou fornecer antialias. No entanto, para este app, você só precisa de um.

  1. Veja a textura do contexto da tela que você criou anteriormente chamando context.getCurrentTexture(), que retorna uma textura com largura e altura em pixels correspondentes aos atributos width e height da tela e o format especificado quando você chamou context.configure().

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
     view: context.getCurrentTexture().createView(),
     loadOp: "clear",
     storeOp: "store",
  }]
});

A textura é fornecida como a propriedade view de um colorAttachment. Os cartões de renderização exigem que você forneça um GPUTextureView em vez de um GPUTexture, que informa em quais partes da textura ela será renderizada. Isso só é importante para casos de uso mais avançados. Aqui, chame createView() sem argumentos na textura, indicando que você quer que a passagem de renderização use toda a textura.

Também é necessário especificar o que você quer que o passe de renderização faça com a textura quando ela começa e quando termina:

  • Um valor loadOp de "clear" indica que você quer que a textura seja apagada quando a passagem de renderização for iniciada.
  • Um valor storeOp de "store" indica que, quando a passagem de renderização é concluída, você quer que os resultados de qualquer desenho feito durante a passagem de renderização sejam salvos na textura.

Depois que o passe de renderização for iniciado, você não fará mais nada. Pelo menos por enquanto. O ato de iniciar o passe de renderização com loadOp: "clear" é suficiente para limpar a visualização da textura e a tela.

  1. Encerre o cartão de renderização adicionando a seguinte chamada imediatamente após beginRenderPass():

index.html

pass.end();

É importante saber que simplesmente fazer essas chamadas não faz com que a GPU realmente faça nada. Eles estão apenas gravando comandos para a GPU fazer mais tarde.

  1. Para criar um GPUCommandBuffer, chame finish() no codificador de comandos. O buffer de comando é um identificador opaco para os comandos gravados.

index.html

const commandBuffer = encoder.finish();
  1. Envie o buffer de comando para a GPU usando o queue do GPUDevice. A fila executa todos os comandos da GPU, garantindo que a execução esteja bem ordenada e sincronizada corretamente. O método submit() da fila recebe uma matriz de buffers de comando, mas, nesse caso, você só tem um.

index.html

device.queue.submit([commandBuffer]);

Depois que você envia um buffer de comando, ele não pode ser usado novamente. Portanto, não é necessário mantê-lo. Se você quiser enviar mais comandos, precisará criar outro buffer de comando. É por isso que é bastante comum ver essas duas etapas recolhidas em uma, como é feito nas páginas de amostra deste codelab:

index.html

// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);

Depois de enviar os comandos para a GPU, permita que o JavaScript retorne o controle ao navegador. Nesse ponto, o navegador percebe que você alterou a textura atual do contexto e atualiza a tela para exibir essa textura como uma imagem. Se você quiser atualizar o conteúdo da tela novamente depois disso, será necessário gravar e enviar um novo buffer de comando, chamando context.getCurrentTexture() novamente para receber uma nova textura para um passe de renderização.

  1. Recarregue a página. Observe que a tela é preenchida com preto. Parabéns! Isso significa que você criou seu primeiro app WebGPU.

Uma tela preta que indica que a WebGPU foi usada com êxito para limpar o conteúdo da tela.

Escolha uma cor.

Para ser honesto, os quadrados pretos são bem chatos. Então, reserve um momento antes de passar para a próxima seção para personalizá-la um pouco.

  1. Na chamada device.beginRenderPass(), adicione uma nova linha com um clearValue ao colorAttachment, desta forma:

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),
    loadOp: "clear",
    clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
    storeOp: "store",
  }],
});

O clearValue instrui o cartão de renderização a usar a cor ao realizar a operação clear no início do cartão. O dicionário transmitido contém quatro valores: r para vermelho, g para verde, b para azul e a para alfa (transparência). Cada valor pode variar de 0 a 1 e, juntos, eles descrevem o valor desse canal de cor. Por exemplo:

  • { r: 1, g: 0, b: 0, a: 1 } está em vermelho vivo.
  • { r: 1, g: 0, b: 1, a: 1 } está em roxo brilhante.
  • { r: 0, g: 0.3, b: 0, a: 1 } está em verde escuro.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } está em cinza médio.
  • { r: 0, g: 0, b: 0, a: 0 } é o preto transparente padrão.

O código de exemplo e as capturas de tela neste codelab usam um azul escuro, mas fique à vontade para escolher a cor que quiser.

  1. Depois de escolher a cor, atualize a página. Você verá a cor escolhida na tela.

Uma tela pode ficar azul-clara para demonstrar como alterar a cor clara padrão.

4. Desenhar geometria

Ao final desta seção, o aplicativo desenhará uma geometria simples na tela: um quadrado colorido. Você receberá um aviso agora que parecerá muito trabalhoso para esse tipo de saída simples, mas isso acontece porque a WebGPU foi projetada para renderizar muitas geometrias de maneira muito eficiente. Um efeito colateral dessa eficiência é que fazer coisas relativamente simples pode parecer estranhamente difícil, mas essa é a expectativa se você estiver usando uma API como a WebGPU. Você quer fazer algo um pouco mais complexo.

Entenda como as GPUs são desenhadas

Antes de mais alterações de código, vale a pena fazer uma visão geral rápida, simplificada e de alto nível de como as GPUs criam as formas que você vê na tela. Pule para a seção "Como definir vértices" se você já estiver familiarizado com as noções básicas de como a renderização da GPU funciona.

Ao contrário de uma API como o Canvas 2D, que tem muitas formas e opções prontas para uso, a GPU processa apenas alguns tipos diferentes de formas (ou primitivos, como são chamados pela WebGPU): pontos, linhas e triângulos. Neste codelab, você usará apenas triângulos.

As GPUs funcionam quase que exclusivamente com triângulos, pois eles têm muitas propriedades matemáticas interessantes que facilitam o processamento de forma previsível e eficiente. Quase tudo o que você desenhar com a GPU precisa ser dividido em triângulos antes de ser desenhado, e esses triângulos precisam ser definidos pelos cantos.

Esses pontos, ou vértices, são fornecidos em termos de valores X, Y e (para conteúdo 3D) que definem um ponto em um sistema de coordenadas cartesiano definido pela WebGPU ou por APIs semelhantes. A estrutura do sistema de coordenadas é mais fácil de entender em termos de como ela se relaciona com a tela de sua página. Independentemente da largura ou da altura da tela, a borda esquerda estará sempre em -1 no eixo X e a borda direita estará sempre em +1 no eixo X. Da mesma forma, a borda inferior é sempre -1 no eixo Y e a borda superior é +1 no eixo Y. Isso significa que (0, 0) é sempre o centro da tela, (-1, -1) é sempre o canto inferior esquerdo e (1, 1) é sempre o canto superior direito. Isso é conhecido como espaço de corte.

Gráfico simples que visualiza o espaço normalizado do Google Maps Coordinate para dispositivos.

Inicialmente, os vértices são definidos nesse sistema de coordenadas. Portanto, as GPUs usam pequenos programas chamados sombreadores de vértices para executar os cálculos necessários para transformar os vértices em espaços de corte, bem como quaisquer outros cálculos necessários para desenhar os vértices. Por exemplo, o sombreador pode aplicar alguma animação ou calcular a direção do vértice até uma fonte de luz. Esses sombreadores foram escritos por você, o desenvolvedor da WebGPU, e fornecem uma quantidade incrível de controle sobre como a GPU funciona.

A partir daí, a GPU usa todos os triângulos compostos por esses vértices transformados e determina quais pixels na tela são necessários para desenhá-los. Em seguida, ele executa outro pequeno programa chamado sombreador de fragmento, que calcula a cor de cada pixel. Esse cálculo pode ser tão simples quanto voltar para verde ou tão complexo quanto calcular o ângulo da superfície em relação à luz solar abandonada de outras superfícies, filtrados por neblina e modificados de acordo com a metálica da superfície. Tudo está sob seu controle, o que pode ser empoderador e sobrecarregado.

Os resultados dessas cores do pixel são então acumulados em uma textura, que pode então ser mostrada na tela.

Definir vértices

Como mencionado anteriormente, a simulação do "The Game of Life" é mostrada como uma grade de células. Seu aplicativo precisa de uma maneira de visualizar a grade, distinguindo células ativas de células inativas. A abordagem usada por este codelab será desenhar quadrados coloridos nas células ativas e deixar as células inativas em branco.

Isso significa que será necessário fornecer à GPU quatro pontos diferentes, um para cada um dos quatro cantos do quadrado. Por exemplo, um quadrado desenhado no centro da tela, puxado das bordas, tem coordenadas de canto como este:

Gráfico normalizado de dispositivo que mostra coordenadas para os cantos de um quadrado

Para alimentar essas coordenadas com a GPU, é necessário colocar os valores em um TypedArray. Se você ainda não estiver familiarizado com ele, TypedArrays é um grupo de objetos JavaScript que permite alocar blocos de memória contíguos e interpretar cada elemento da série como um tipo de dados específico. Por exemplo, em um Uint8Array, cada elemento na matriz é um único byte não assinado. Os TypedArrays são excelentes para enviar e receber dados com APIs sensíveis ao layout de memória, como WebAssembly, WebAudio e, obviamente, WebGPU.

Para o exemplo do quadrado, como os valores são fracionários, um Float32Array é apropriado.

  1. Crie uma matriz que contenha todas as posições de vértice no diagrama, colocando a seguinte declaração de matriz em seu código. Um bom local para colocá-lo está na parte superior, logo abaixo da chamada de context.configure().

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8,
   0.8, -0.8,
   0.8,  0.8,
  -0.8,  0.8,
]);

O espaçamento e o comentário não afetam os valores. Eles são apenas para sua conveniência e para facilitar a leitura. Isso ajuda a ver que cada par de valores compõe as coordenadas X e Y de um vértice.

Mas há um problema. As GPUs funcionam em termos de triângulos, lembram? Isso significa que você precisa fornecer os vértices em grupos de três. Você tem um grupo de quatro. A solução é repetir dois dos vértices para criar dois triângulos que compartilham uma borda pelo meio do quadrado.

Um diagrama que mostra como os quatro vértices do quadrado serão usados para formar dois triângulos.

Para formar o quadrado do diagrama, é necessário listar os vértices (-0,8, -0,8) e (0,8, 0,8) duas vezes, uma para o triângulo azul e outra para o vermelho. Você também pode optar por dividir o quadrado com os outros dois cantos. Não faz diferença.

  1. Atualize sua matriz vertices anterior para ter esta aparência:

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8, // Triangle 1 (Blue)
   0.8, -0.8,
   0.8,  0.8,

  -0.8, -0.8, // Triangle 2 (Red)
   0.8,  0.8,
  -0.8,  0.8,
]);

Embora o diagrama mostre a separação entre os dois triângulos para maior clareza, as posições dos vértices são exatamente as mesmas e a GPU as renderiza sem lacunas. Ele será renderizado como um quadrado único e sólido.

Criar um buffer de vértice

A GPU não pode desenhar vértices com dados de uma matriz JavaScript. As GPUs costumam ter a própria memória altamente otimizada para renderização. Portanto, todos os dados que você quer que a GPU use enquanto ela desenha precisam ser colocados nessa memória.

Para muitos valores, incluindo dados de vértices, a memória do GPU é gerenciada por objetos GPUBuffer. Um buffer é um bloco de memória facilmente acessível à GPU e sinalizado para determinados fins. Você pode pensar um pouco como um TypedArray visível para GPU.

  1. Para criar um buffer para armazenar seus vértices, adicione a seguinte chamada ao device.createBuffer() após a definição da matriz vertices.

index.html

const vertexBuffer = device.createBuffer({
  label: "Cell vertices",
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

A primeira coisa a observar é que você deve atribuir um rótulo ao buffer. Cada objeto WebGPU que você cria pode receber um rótulo opcional, e você com certeza quer fazer isso. O rótulo é qualquer string que você quiser, desde que ajude a identificar o objeto. Se você tiver problemas, esses rótulos serão usados nas mensagens de erro que a GPU da Web produzir para ajudar a entender o que houve de errado.

Em seguida, forneça um tamanho para o buffer em bytes. Você precisa de um buffer com 48 bytes, o que é determinado multiplicando o tamanho de um flutuante de 32 bits ( 4 bytes) pelo número de pontos flutuantes na sua matriz vertices (12). Felizmente, TypedArrays já calcula o byteLength de você, e você pode usá-lo ao criar o buffer.

Por fim, é necessário especificar o uso do buffer. Essa é uma ou mais sinalizações GPUBufferUsage, com várias sinalizações combinadas com o operador | ( bitwise OR). Nesse caso, você especifica que quer que o buffer seja usado para dados de vértice (GPUBufferUsage.VERTEX) e que também queira poder copiar dados para ele (GPUBufferUsage.COPY_DST).

O objeto de buffer que é retornado a você é opaco. Não é possível inspecionar (facilmente) os dados que ele contém. Além disso, a maioria dos atributos é imutável. Não é possível redimensionar um GPUBuffer depois que ele é criado nem alterar as sinalizações de uso. O que você pode mudar são os conteúdos da memória.

Quando o buffer é criado inicialmente, a memória que ele contém é inicializada como zero. Há várias maneiras de alterar o conteúdo, mas o mais fácil é chamar device.queue.writeBuffer() com um TypedArray que você queira copiar.

  1. Para copiar os dados do vértice na memória do buffer, adicione o seguinte código:

index.html

device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);

Definir o layout do vértice

Agora você tem um buffer com dados de vértices, mas no que diz respeito à GPU é apenas um blob de bytes. Você precisará fornecer um pouco mais de informações se quiser desenhar algo com ele. Você precisa saber mais sobre a estrutura dos dados do vértice na WebGPU.

index.html

const vertexBufferLayout = {
  arrayStride: 8,
  attributes: [{
    format: "float32x2",
    offset: 0,
    shaderLocation: 0, // Position, see vertex shader
  }],
};

Isso pode parecer um pouco confuso à primeira vista, mas é relativamente fácil de entender.

A primeira coisa que você vai dar é o arrayStride. Este é o número de bytes que a GPU precisa pular no buffer ao procurar o próximo vértice. Cada vértice do seu quadrado é composto por dois números de ponto flutuante de 32 bits. Como mencionado anteriormente, um flutuante de 32 bits tem 4 bytes, portanto, dois flutuantes são 8 bytes.

Em seguida, temos a propriedade attributes, que é uma matriz. Os atributos são informações individuais codificadas em cada vértice. Seus vértices contêm apenas um atributo (a posição do vértice), mas os casos de uso mais avançados frequentemente têm vértices com vários atributos, como a cor de um vértice ou a direção para a qual a superfície da geometria está apontando. No entanto, isso está fora do escopo deste codelab.

No seu único atributo, você primeiro define o format dos dados. Isso vem de uma lista de tipos de GPUVertexFormat que descrevem cada tipo de dados de vértices que a GPU pode entender. Os vértices têm dois flutuantes de 32 bits cada, portanto, use o formato float32x2. Se os dados de vértices forem compostos por quatro números inteiros não assinados de 16 bits, por exemplo, use uint16x4. Vê o padrão?

Em seguida, o offset descreve quantos bytes no vértice esse atributo específico inicia. Você só precisa se preocupar com isso se o buffer tiver mais de um atributo, que não será mostrado neste codelab.

Por fim, você tem o shaderLocation. Esse é um número arbitrário entre 0 e 15 e deve ser único para cada atributo que você definir. Ele vincula esse atributo a uma entrada específica no sombreador de vértice, sobre o que você aprenderá na próxima seção.

Observe que, embora você defina esses valores agora, ainda não os está transferindo para a API WebGPU em nenhum lugar. Isso está se aproximando, mas é mais fácil pensar sobre esses valores no momento em que você define os vértices. Portanto, eles serão configurados agora para uso posterior.

Começar com sombreadores

Agora você tem os dados que quer renderizar, mas ainda precisa informar à GPU exatamente como processá-los. Grande parte disso acontece com sombreadores.

Os sombreadores são pequenos programas que você escreve e executa na sua GPU. Cada sombreador opera em um estágio diferente dos dados: processamento de Vertex, processamento de Fragment ou Compute geral. Como eles estão na GPU, eles são estruturados com mais rigidez que seu JavaScript médio. Mas essa estrutura permite que eles sejam executados de forma muito rápida e, em suma, em paralelo.

Os sombreadores na WebGPU são escritos em uma linguagem de sombreamento chamada WGSL (WebGPU Shading Language). O WGSL é, de maneira sintática, um pouco parecido com o Rust, com recursos destinados a tornar tipos comuns de trabalho de GPU (como matemática de vetor e matriz) mais fáceis e rápidos. Ensinar a linguagem de sombreamento está muito além do escopo deste codelab, mas esperamos que você possa aprender algumas noções básicas ao seguir alguns exemplos simples.

Os sombreadores são passados para WebGPU como strings.

  • Crie um local para inserir o código do sombreador copiando o seguinte no código abaixo do vertexBufferLayout:

index.html

const cellShaderModule = device.createShaderModule({
  label: "Cell shader",
  code: `
    // Your shader code will go here
  `
});

Para criar os sombreadores, chame device.createShaderModule() e forneça uma label e um code opcionais do WGSL como uma string. (Use acentos graves para permitir strings com várias linhas.) Depois que você adiciona um código WGSL válido, a função retorna um objeto GPUShaderModule com os resultados compilados.

Definir o sombreador de vértice

Comece com o sombreador de vértice porque é aí que a GPU também começa.

Um sombreador de vértice é definido como uma função, e a GPU chama essa função uma vez para cada vértice no vertexBuffer. Como a vertexBuffer tem seis posições (vértices), a função que você define é chamada seis vezes. Sempre que é chamada, uma posição diferente da vertexBuffer é transmitida para a função como um argumento, e a função da sombra do vértice retorna uma posição correspondente no espaço de corte.

É importante entender que elas também não serão chamadas em ordem sequencial. Em vez disso, as GPUs se destacam na execução em paralelo de sombreadores como esses, potencialmente processando centenas (ou até milhares) de vértices ao mesmo tempo! Essa é uma grande parte do que é responsável por uma velocidade incrível de GPU, mas ela tem limitações. Para garantir o carregamento em paralelo extremo, os sombreadores de vértice não podem se comunicar entre si. Cada invocação de sombreador só pode ver os dados de um único vértice por vez, e só pode gerar valores para um único vértice.

Em WGSL, uma função de sombreador de vértice pode ter o nome que você quiser, mas ela precisa ter o atributo @vertex na frente para indicar o estágio do sombreador que ele representa. WGSL indica funções com a palavra-chave fn, usa parênteses para declarar argumentos e usa chaves para definir o escopo.

  1. Crie uma função @vertex vazia, como esta:

index.html (código createShaderModule)

@vertex
fn vertexMain() {

}

No entanto, isso não é válido, já que um sombreador de vértice precisa retornar pelo menos a posição final do vértice em processamento no espaço de corte. Isso é sempre dado como um vetor 4-dimensional. Os vetores são uma coisa tão comum para usar em sombreadores que são tratados como primitivos de primeira classe na linguagem, com seus próprios tipos como vec4f para um vetor de quatro dimensões. Há tipos semelhantes para vetores 2D (vec2f) e 3D (vec3f).

  1. Para indicar que o valor retornado é a posição necessária, marque-o com o atributo @builtin(position). Um símbolo -> é usado para indicar que a função retorna isso.

index.html (código createShaderModule)

@vertex
fn vertexMain() -> @builtin(position) vec4f {

}

Obviamente, se a função tiver um tipo de retorno, será necessário retornar um valor no corpo da função. Você pode construir um novo vec4f para retornar, usando a sintaxe vec4f(x, y, z, w). Os valores x, y e z são todos números de ponto flutuante que, no valor de retorno, indicam onde o vértice está no espaço de corte.

  1. Retorne um valor estático de (0, 0, 0, 1) e, tecnicamente, você tem um sombreador de vértice válido, embora um que nunca exiba nada, já que a GPU reconhece que os triângulos produzidos por ele são apenas um único ponto e depois o descarta.

index.html (código createShaderModule)

@vertex
fn vertexMain() -> @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}

O que você quer fazer é usar os dados do buffer criado e declarar um argumento para a função com um atributo @location() e um tipo que corresponda ao descrito no vertexBufferLayout. Como você especificou um shaderLocation de 0, marque o argumento com @location(0) no código WGSL. Você também definiu o formato como float32x2, que é um vetor 2D. Portanto, no WGSL, seu argumento é um vec2f. Você pode nomeá-lo como quiser, mas, como eles representam suas posições de vértice, um nome como pos parece natural.

  1. Mude a função do sombreador para o seguinte código:

index.html (código createShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1);
}

E agora você precisa retornar a essa posição. Como a posição é um vetor 2D e o tipo de retorno é um vetor 4D, será necessário alterá-lo um pouco. O que você quer fazer é colocar os dois componentes do argumento de posição e colocá-los nos dois primeiros componentes do vetor de retorno, deixando os dois últimos componentes como 0 e 1, respectivamente.

  1. Retorne a posição correta, informando explicitamente quais componentes de posição serão usados:

index.html (código createShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 1);
}

No entanto, como esses tipos de mapeamentos são muito comuns em sombreadores, você também pode transmitir o vetor de posição como o primeiro argumento em uma forma abreviada conveniente e significa a mesma coisa.

  1. Reescreva a instrução return com o seguinte código:

index.html (código createShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos, 0, 1);
}

Esse é seu sombreador de vértice inicial. É muito simples, simplesmente passar a posição de modo inalterado, mas é bom o suficiente para começar.

Definir o sombreador de fragmentos

Em seguida, está o sombreador de fragmentos. Os sombreadores de fragmentos funcionam de maneira muito semelhante aos sombreadores de vértices. No entanto, em vez de serem invocados para cada vértice, eles são invocados para cada pixel desenhado.

Os sombreadores de fragmentos são sempre chamados após os sombreadores de vértice. A GPU usa a saída dos sombreadores de vértice e a triangula, criando triângulos com conjuntos de três pontos. Em seguida, ele rasteriza cada um desses triângulos ao descobrir quais pixels dos anexos de cores de saída são incluídos nesse triângulo e, em seguida, chama o sombreador de fragmentos uma vez para cada um desses pixels. O sombreador de fragmentos retorna uma cor, normalmente calculada a partir de valores enviados a ele pelo sombreador de vértices e recursos, como texturas, que a GPU grava no anexo de cores.

Assim como os sombreadores de vértice, os sombreadores de fragmentos são executados de maneira massivamente paralela. Eles são um pouco mais flexíveis do que os sombreadores de vértice em termos de entradas e saídas, mas você pode considerá-los para retornar apenas uma cor para cada pixel de cada triângulo.

Uma função de sombreador de fragmentos WGSL é indicada com o atributo @fragment e também retorna um vec4f. Nesse caso, o vetor representa uma cor, não uma posição. O valor de retorno precisa receber um atributo @location para indicar em que colorAttachment da chamada de beginRenderPass a cor retornada é gravada. Como você só tinha um anexo, a localização será 0.

  1. Crie uma função @fragment vazia, como esta:

index.html (código createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {

}

Os quatro componentes do vetor retornado são os valores de cor vermelho, verde, azul e alfa, que são interpretados exatamente da mesma forma que clearValue definido anteriormente em beginRenderPass. Portanto, vec4f(1, 0, 0, 1) é vermelho vivo, o que parece uma cor decente para seu quadrado. Você pode definir a cor como quiser.

  1. Defina o vetor de cor retornado da seguinte forma:

index.html (código createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}

Esse é um sombreador de fragmentos completo. Não é um assunto muito interessante, apenas define cada pixel de cada triângulo como vermelho, mas isso é suficiente por enquanto.

Só para recapitular, depois de adicionar o código de sombreador detalhado acima, sua chamada createShaderModule ficará assim:

index.html

const cellShaderModule = device.createShaderModule({
  label: 'Cell shader',
  code: `
    @vertex
    fn vertexMain(@location(0) pos: vec2f) ->
      @builtin(position) vec4f {
      return vec4f(pos, 0, 1);
    }

    @fragment
    fn fragmentMain() -> @location(0) vec4f {
      return vec4f(1, 0, 0, 1);
    }
  `
});

Criar um pipeline de renderização

Um módulo de sombreador não pode ser usado para renderização por conta própria. Em vez disso, você precisa usá-lo como parte de um GPURenderPipeline, criado chamando device.createRenderPipeline(). O pipeline de renderização controla como a geometria é desenhada, incluindo coisas como quais sombreadores são usados, como interpretar dados em buffers de vértice, que tipo de geometria precisa ser renderizada (linhas, pontos, triângulos...) e muito mais.

O pipeline de renderização é o objeto mais complexo em toda a API, mas não se preocupe. A maioria dos valores que você pode transmitir são opcionais, e você só precisa fornecer alguns para começar.

  • Crie um pipeline de renderização como este:

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: "auto",
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

Todo pipeline precisa de um layout que descreva os tipos de entradas (exceto buffers de vértice) que o pipeline precisa, mas você não tem nenhum. Você pode transmitir "auto" por enquanto, e o pipeline cria o próprio layout dos sombreadores.

Em seguida, você precisa fornecer detalhes sobre a etapa vertex. O module é o GPUShaderModule que contém o sombreador de vértice, e o entryPoint fornece o nome da função no código do sombreador que é chamado para cada invocação de vértice. Você pode ter várias funções @vertex e @fragment em um único módulo de sombreador. Os buffers são uma matriz de objetos GPUVertexBufferLayout que descrevem como seus dados são compactados nos buffers de vértice com que você usa esse pipeline. Felizmente, você já definiu isso anteriormente no vertexBufferLayout. É aqui que você transmite.

Por fim, você tem detalhes sobre o cenário fragment. Isso também inclui um module e um entryPoint do sombreador, como o estágio de vértice. A última parte é definir o targets com que esse pipeline é usado. Essa é uma matriz de dicionários que fornecem detalhes, como a textura format, dos anexos de cores para os quais o pipeline é enviado. Esses detalhes precisam corresponder às texturas fornecidas no colorAttachments de todos os cartões de renderização usados com esse pipeline. Seu passe de renderização usa texturas do contexto da tela e o valor que você salvou em canvasFormat para o formato. Portanto, você passa o mesmo formato aqui.

Ele nem mesmo está perto de todas as opções que podem ser especificadas ao criar um pipeline de renderização, mas é suficiente para as necessidades deste codelab.

Desenhar o quadrado

Com isso, agora você tem tudo de que precisa para desenhar seu quadrado.

  1. Para desenhar o quadrado, volte para o par de chamadas encoder.beginRenderPass() e pass.end() e adicione estes novos comandos:

index.html

// After encoder.beginRenderPass()

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices

// before pass.end()

Isso fornece ao WebGPU todas as informações necessárias para desenhar seu quadrado. Primeiro, use setPipeline() para indicar com qual pipeline você quer desenhar. Isso inclui os sombreadores usados, o layout dos dados de vértices e outros dados de estado relevantes.

Em seguida, chame setVertexBuffer() com o buffer que contém os vértices do seu quadrado. Você o chama com 0, porque esse buffer corresponde ao 0o elemento na definição de vertex.buffers do pipeline atual.

Por último, você faz a chamada draw(), que pode parecer estranhamente simples após todas as configurações. A única coisa que você precisa transmitir é o número de vértices que ele deve renderizar, que é extraído dos buffers de vértice definidos e interpretado com o pipeline definido atualmente. Você poderia codificá-lo para 6, mas calculá-lo na matriz de vértices (12 pontos flutuantes / 2 coordenadas por vértice == 6 vértices) significa que, se você decidir substituir o quadrado por, por exemplo, um círculo, haverá menos atualizações à mão.

  1. Atualize sua tela e (finalmente) veja os resultados de todo o seu trabalho: um grande quadrado colorido.

Um único quadrado vermelho renderizado com WebGPU

5. Desenhar grade

Primeiro, reserve um momento para parabenizar você mesmo! Muitas vezes, a primeira parte da geometria na tela é uma das etapas mais difíceis na maioria das APIs de GPU. Tudo o que você fizer aqui pode ser feito em etapas menores, facilitando a verificação do seu progresso.

Nesta seção, você aprenderá a:

  • Como passar variáveis (chamadas de uniformes) ao sombreador a partir do JavaScript.
  • Como usar uniformes para mudar o comportamento de renderização.
  • Como usar a criação de instâncias para desenhar muitas variantes diferentes da mesma geometria.

Definir a grade

Para renderizar uma grade, é preciso saber uma informação fundamental sobre ela. Quantas células ele contém, em largura e altura? Você decide como desenvolvedor, mas, para facilitar, trate a grade como um quadrado (mesma largura e altura) e use um tamanho que seja uma potência de dois. Isso facilitará alguns cálculos mais tarde. Você quer torná-lo maior com o tempo, mas, para o restante desta seção, defina seu tamanho de grade para 4x4, pois isso facilita a demonstração de alguns dos cálculos usados nesta seção. Amplie a estratégia depois!

  • Defina o tamanho da grade adicionando uma constante ao topo do código JavaScript.

index.html

const GRID_SIZE = 4;

Em seguida, é necessário atualizar a forma como você renderiza seu quadrado para que ele caiba GRID_SIZE vezes GRID_SIZE deles na tela. Isso significa que o quadrado precisa ser muito menor e ter vários.

Uma forma de fazer isso é tornando o buffer do vértice significativamente maior e definindo GRID_SIZE vezes GRID_SIZE em quadrados dentro do tamanho e da posição corretos. O código para isso não seria muito ruim. Só um pouquinho de repetição e matemática. Mas isso também não está fazendo o melhor uso da GPU e usando mais memória do que o necessário para conseguir o efeito. Esta seção analisa uma abordagem mais adequada para GPU.

Criar um buffer uniforme

Primeiro, você precisa comunicar ao sombreador o tamanho que escolheu para o sombreador, já que ele usa isso para alterar a forma como as coisas são exibidas. É possível codificar o tamanho no sombreador, mas isso significa que, sempre que você quiser alterar o tamanho da grade, será necessário recriar o sombreador e renderizar o pipeline, o que é caro. Uma maneira melhor é fornecer o tamanho da grade ao sombreador como uniformes.

Você aprendeu anteriormente que um valor diferente do buffer do vértice é transmitido para cada invocação de um sombreador de vértice. Um uniforme é um valor de um buffer que é o mesmo para cada invocação. Eles são úteis para comunicar valores comuns a uma geometria (como a posição), um frame completo de animação (como o horário atual) ou mesmo a toda a vida útil do app (como uma preferência do usuário).

  • Crie um buffer uniforme adicionando o seguinte código:

index.html

// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
  label: "Grid Uniforms",
  size: uniformArray.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);

Isso deve parecer bem familiar, porque é quase o mesmo código usado para criar o buffer do vértice anteriormente. Isso ocorre porque os uniformes são comunicados à API WebGPU por meio dos mesmos objetos GPUBuffer que os vértices têm, com a principal diferença de que o usage desta vez inclui GPUBufferUsage.UNIFORM em vez de GPUBufferUsage.VERTEX.

Acessar uniformes em um sombreador

  • Defina um uniforme adicionando o seguinte código:

index.html (chamada createShaderModule)

// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos / grid, 0, 1);
}

// ...fragmentMain is unchanged

Isso define um uniforme no sombreador chamado grid, que é um vetor flutuante 2D que corresponde à matriz que você acabou de copiar para o buffer uniforme. Ele também especifica que o uniforme está vinculado a @group(0) e @binding(0). Você aprenderá o que esses valores significam em um momento.

Em seguida, em outro lugar no código do sombreador, você poderá usar o vetor de grade como quiser. Neste código, você divide a posição do vértice pelo vetor de grade. Como pos é um vetor 2D e grid é um vetor 2D, o WGSL executa uma divisão por componente. Em outras palavras, o resultado é o mesmo que dizer vec2f(pos.x / grid.x, pos.y / grid.y).

Esses tipos de operações vetoriais são muito comuns em sombreadores de GPU, já que muitas técnicas de renderização e computação dependem deles.

Isso significa que, se você usar o tamanho de grade de 4, o quadrado renderizado será um quarto do tamanho original. Isso é perfeito para encaixar quatro deles em uma linha ou coluna.

Criar um grupo de contas vinculadas

Declarar o uniforme no sombreador não o conecta ao buffer criado. Para fazer isso, você precisa criar e definir um grupo de vinculação.

Um grupo de vinculação é uma coleção de recursos que você quer tornar acessíveis ao seu sombreador ao mesmo tempo. Ele pode incluir vários tipos de buffers, como o buffer uniforme, e outros recursos, como texturas e amostras, que não são abordados aqui, mas são partes comuns de técnicas de renderização do WebGPU.

  • Crie um grupo de vinculação com seu buffer uniforme adicionando o seguinte código após a criação do buffer uniforme e do pipeline de renderização:

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  }],
});

Além do padrão label, você também precisa de um layout que descreva os tipos de recursos que esse grupo de vinculação contém. Isso é algo que você vai aprofundar em uma etapa futura, mas, por enquanto, você pode pedir o layout do grupo de vinculação para o pipeline porque criou o pipeline com layout: "auto". Isso faz com que o pipeline crie automaticamente layouts de grupos de vinculação a partir das vinculações declaradas no código do sombreador. Nesse caso, você a pedirá para getBindGroupLayout(0), em que 0 corresponde ao @group(0) que você digitou no sombreador.

Depois de especificar o layout, você fornece uma matriz de entries. Cada entrada é um dicionário com pelo menos os seguintes valores:

  • binding, que corresponde ao valor @binding() inserido no sombreador. Nesse caso, 0.
  • resource, que é o recurso real que você quer expor à variável no índice de vinculação especificado. Nesse caso, seu buffer uniforme.

A função retorna um GPUBindGroup, que é um identificador opaco e imutável. Não é possível alterar os recursos para os quais um grupo de vinculação aponta depois de ter sido criado, embora você possa alterar o conteúdo desses recursos. Por exemplo, se você alterar o buffer uniforme para conter um novo tamanho de grade, isso será refletido por chamadas de desenho futuras usando esse grupo de vinculação.

Vincular o grupo de vinculação

Agora que o grupo de vinculações foi criado, você ainda precisa instruir a WebGPU a usá-lo ao desenhar. Felizmente, isso é muito simples.

  1. Volte para o cartão de renderização e adicione esta nova linha antes do método draw():

index.html

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);

pass.setBindGroup(0, bindGroup); // New line!

pass.draw(vertices.length / 2);

O 0 transmitido como o primeiro argumento corresponde ao @group(0) no código do sombreador. Você está dizendo que cada @binding que faz parte de @group(0) usa os recursos nesse grupo de vinculação.

Agora o buffer uniforme é exposto ao sombreador.

  1. Atualize a página para ver algo assim:

Um pequeno quadrado vermelho no centro de um fundo azul escuro.

Oba! Agora seu square está com um quarto do tamanho anterior. Isso não é muito, mas mostra que o uniforme foi aplicado e que o sombreador agora pode acessar o tamanho da grade.

Manipular geometria no sombreador

Agora que você pode referenciar o tamanho da grade no sombreador, é possível começar a trabalhar para manipular a geometria que está renderizando para se ajustar ao padrão de grade desejado. Para fazer isso, considere exatamente o que você quer alcançar.

É necessário dividir conceitualmente a tela em células individuais. Para manter a convenção de que o eixo X aumenta à medida que você se move para a direita e que o eixo Y aumenta à medida que você se move para cima, digamos que a primeira célula esteja no canto inferior esquerdo da tela. Isso fornece um layout semelhante a este, com sua geometria quadrada atual no meio:

Uma ilustração da grade conceitual do espaço Coordinate normal do dispositivo será dividida ao visualizar cada célula com a geometria quadrada atualmente renderizada em seu centro.

Seu desafio é encontrar um método no sombreador que permita posicionar a geometria quadrada em qualquer uma dessas células com base nas coordenadas das células.

Primeiro, você pode ver que seu quadrado não está bem alinhado com nenhuma das células, porque foi definido para cercar o centro da tela. O ideal é que o quadrado seja deslocado por meia célula para que fique bem alinhado dentro delas.

Uma maneira de corrigir isso é atualizar o buffer do vértice do quadrado. Ao deslocar os vértices de modo que o canto inferior direito fique em, por exemplo, (0,1, 0,1) em vez de (-0,8, -0,8), você moveria esse quadrado para se alinhar melhor com os limites das células. No entanto, como você tem controle total sobre como os vértices são processados no sombreador, também é fácil simplesmente usar o código do sombreador.

  1. Altere o módulo de sombreador de vértice com o seguinte código:

index.html (chamada createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Add 1 to the position before dividing by the grid size.
  let gridPos = (pos + 1) / grid;

  return vec4f(gridPos, 0, 1);
}

Isso move cada vértice para cima e para a esquerda por um (que, lembre-se, é metade do espaço de corte) antes de dividi-lo pelo tamanho da grade. O resultado é um quadrado bem alinhado em grade logo após a origem.

Conceito de tela dividido conceitualmente em uma grade de 4 x 4 com um quadrado vermelho na célula (2, 2)

Em seguida, como o sistema de coordenadas da sua tela coloca (0, 0) no centro e (-1, -1) no canto inferior esquerdo, e você deseja que (0, 0) fique no canto inferior esquerdo, converta a posição da geometria em (-1, -1) depois de dividir pelo tamanho da grade para movê-la para esse canto.

  1. Traduza a posição da sua geometria desta maneira:

index.html (chamada createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Subtract 1 after dividing by the grid size.
  let gridPos = (pos + 1) / grid - 1;

  return vec4f(gridPos, 0, 1);
}

E agora seu quadrado está bem posicionado na célula (0, 0)!

Conceitualmente, a tela é dividida em uma grade de 4 x 4 com um quadrado vermelho na célula (0, 0).

E se você quiser colocá-lo em uma célula diferente? Descubra isso declarando um vetor cell em seu sombreador e preenchendo-o com um valor estático como let cell = vec2f(1, 1).

Se você adicionar isso à gridPos, a - 1 será desfeita no algoritmo. Portanto, não é isso que você quer fazer. Em vez disso, mova o quadrado apenas uma unidade de grade (um quarto da tela) para cada célula. Parece que você precisa fazer outra divisão por grid!

  1. Altere o posicionamento da grade da seguinte forma:

index.html (chamada createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1); // Cell(1,1) in the image above
  let cellOffset = cell / grid; // Compute the offset to cell
  let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!

  return vec4f(gridPos, 0, 1);
}

Se você atualizar agora, verá o seguinte:

É uma visualização da tela dividida conceitualmente em uma grade de 4 x 4 com um quadrado vermelho centralizado entre as células (0, 0), células (0, 1), células (1, 0) e células (1, 1).

Hum. Não era exatamente o que você queria.

A razão é que, como as coordenadas da tela vão de -1 a +1, na verdade são 2 unidades. Isso significa que, se você quiser mover um vértice um quarto da tela, terá que mover 0,5 unidade. Esse é um erro fácil a ser cometido ao definir as coordenadas da GPU. Felizmente, a correção também é fácil.

  1. Multiplique seu deslocamento por 2, assim:

index.html (chamada createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

Assim, você terá exatamente o que deseja.

Conceitualmente, a tela é dividida em uma grade de 4 x 4 com um quadrado vermelho na célula (1, 1).

A captura de tela tem esta aparência:

Captura de tela de um quadrado vermelho em um fundo azul escuro. O quadrado vermelho desenhado na mesma posição descrita no diagrama anterior, mas sem a sobreposição de grade.

Além disso, agora é possível definir cell como qualquer valor dentro dos limites da grade e, em seguida, atualizar para ver a renderização do quadrado no local desejado.

Instâncias de desenho

Agora que você pode colocar o quadrado onde quiser com um pouco de matemática, a próxima etapa é renderizar um quadrado em cada célula da grade.

Uma maneira de fazer isso é gravar coordenadas de célula em um buffer uniforme e chamar draw uma vez para cada quadrado na grade, atualizando o uniforme sempre. No entanto, isso seria muito lento, já que a GPU precisa esperar que a nova coordenada seja gravada pelo JavaScript todas as vezes. Um dos segredos para conseguir um bom desempenho com a GPU é minimizar o tempo gasto esperando por outras partes do sistema.

Em vez disso, você pode usar uma técnica chamada de instanciação. A instanciação é uma maneira de dizer à GPU para desenhar várias cópias da mesma geometria com uma única chamada para draw, o que é muito mais rápido do que chamar draw uma vez para cada cópia. Cada cópia da geometria é chamada de instância.

  1. Para informar à GPU que você quer instâncias suficientes do quadrado para preencher a grade, adicione um argumento à chamada de desenho existente:

index.html

pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

Isso informa ao sistema que você quer que ele desenhe os seis vértices (vertices.length / 2) do quadrado 16 (GRID_SIZE * GRID_SIZE). No entanto, se você atualizar a página, ainda verá o seguinte:

Uma imagem idêntica ao diagrama anterior para indicar que nada mudou.

Sabe por quê? É porque você desenha 16 desses quadrados no mesmo lugar. É necessário ter alguma lógica adicional no sombreador que reposiciona a geometria por instância.

No sombreador, além dos atributos de vértice, como pos, provenientes do buffer do vértice, também é possível acessar o que são conhecidos como valores integrados do WGSL. Esses são valores calculados pela WebGPU, e um desses valores é o instance_index. O instance_index é um número não assinado de 32 bits de 0 a number of instances - 1 que pode ser usado como parte da lógica do sombreador. O valor dele é o mesmo para cada vértice processado que faça parte da mesma instância. Isso significa que o sombreador de vértices é chamado seis vezes com um instance_index de 0, uma vez para cada posição no buffer do vértice. Depois, mais seis vezes com instance_index de 1, mais seis com instance_index de 2 e assim por diante.

Para ver isso em ação, é necessário adicionar o instance_index integrado às entradas do sombreador. Faça isso da mesma forma que a posição. No entanto, em vez de marcá-la com um atributo @location, use @builtin(instance_index) e nomeie o argumento como quiser. Você pode chamá-lo de instance para corresponder ao código de exemplo. Em seguida, use-o como parte da lógica do sombreador.

  1. Use instance no lugar das coordenadas da célula:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance); // Save the instance_index as a float
  let cell = vec2f(i, i);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

Se você atualizar agora, verá que realmente tem mais de um quadrado. Mas não é possível ver todas as 16.

Quatro quadrados vermelhos em uma linha diagonal do canto inferior esquerdo ao canto superior direito contra um fundo azul escuro.

Isso ocorre porque as coordenadas da célula que você gera são (0, 0), (1, 1), (2, 2). Para criar a grade que você quer, é necessário transformar o instance_index para que cada índice seja mapeado para uma célula exclusiva da grade, desta forma:

Conceitualmente, a visualização da tela é dividida em uma grade de 4 x 4 com cada célula correspondente a um índice de instância linear.

A matemática é razoavelmente simples. Para o valor X de cada célula, você quer o módulo do instance_index e a largura da grade, que podem ser executados no WGSL com o operador %. E para o valor Y de cada célula, você quer que a instance_index seja dividida pela largura da grade, descartando todo o restante fracionário. É possível fazer isso com a função floor() do WGSL.

  1. Altere os cálculos da seguinte forma:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance);
  // Compute the cell coordinate from the instance_index
  let cell = vec2f(i % grid.x, floor(i / grid.x));

  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

Depois de fazer essa atualização no código, você terá a grade de quadrados tão aguardada.

Quatro linhas de quatro colunas de quadrados vermelhos em um fundo azul escuro.

  1. Agora que está funcionando, volte e aumente o tamanho da grade.

index.html

const GRID_SIZE = 32;

32 linhas de 32 colunas de quadrados vermelhos em um fundo azul escuro.

Tadeu! Você pode deixar essa grade realmente realmente grande, e sua GPU média não apresenta nenhum problema. Você não verá mais os quadrados individuais antes de encontrar gargalos de desempenho da GPU.

6. Crédito extra: torne-o mais colorido.

Agora você pode pular para a próxima seção, já que criou a base para o restante do codelab. Embora a grade de quadrados com a mesma cor possa ser atendida, não é exatamente legal, não é? Felizmente, você pode deixar tudo um pouco mais claro com um pouco mais de código de matemática e sombreador.

Usar estruturas em sombreadores

Até agora, você transmitiu uma parte dos dados do sombreador de vértice: a posição transformada. Mas você pode retornar muito mais dados do sombreador de vértices e depois usá-los no sombreador de fragmentos.

A única maneira de transmitir dados do sombreador de vértice é retorná-lo. Um sombreador de vértices é sempre necessário para retornar uma posição. Portanto, se você quiser retornar outros dados, será necessário colocá-los em uma estrutura. As estruturas no WGSL são tipos de objetos nomeados que contêm uma ou mais propriedades nomeadas. As propriedades também podem ser marcadas com atributos como @builtin e @location. Você as declara fora de qualquer função e, em seguida, pode transmitir instâncias delas para dentro e para fora das funções, conforme necessário. Por exemplo, considere o sombreador de vértice atual:

index.html (chamada createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return  vec4f(gridPos, 0, 1);
}
  • Expresse o mesmo usando structs para a entrada e a saída da função:

index.html (chamada createShaderModule)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  return output;
}

Isso exige que você consulte a posição de entrada e o índice da instância com input. O struct retornado primeiro precisa ser declarado como uma variável e ter as propriedades individuais definidas. Nesse caso, ele não faz muita diferença e, na verdade, faz com que o sombreador funcione um pouco mais. No entanto, à medida que os sombreadores ficam mais complexos, o uso de estruturas pode ser uma ótima maneira de ajudar a organizar seus dados.

Transmitir dados entre as funções de vértice e fragmento

Lembre-se de que a função @fragment é a mais simples possível:

index.html (chamada createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1);
}

Você não está aceitando entradas e está transmitindo uma cor sólida (vermelha) como a saída. No entanto, se o sombreador souber mais sobre a geometria que está colorindo, você pode usar esses dados extras para tornar as coisas um pouco mais interessantes. Por exemplo, e se você quiser alterar a cor de cada quadrado com base na coordenada de célula? A etapa @vertex sabe qual célula está sendo renderizada. Você só precisa transmiti-la para a etapa @fragment.

Para transmitir dados entre os estágios de vértice e fragmento, é necessário incluí-los em uma estrutura de saída com um @location de nossa escolha. Como você quer transmitir a coordenada de célula, adicione-a à estrutura VertexOutput de antes e defina-a na função @vertex antes de retornar.

  1. Mude o valor de retorno do sombreador de vértice, desta forma:

index.html (chamada createShaderModule)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
  @location(0) cell: vec2f, // New line!
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. Na função @fragment, receba o valor adicionando um argumento com o mesmo @location. Os nomes não precisam ser iguais, mas é mais fácil acompanhar as coisas se eles tiverem.

index.html (chamada createShaderModule)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. Como alternativa, use uma estrutura:

index.html (chamada createShaderModule)

struct FragInput {
  @location(0) cell: vec2f,
};

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. Outra alternativa**,** no código, ambas as funções são definidas no mesmo módulo de sombreador, é reutilizar a estrutura de saída do estágio @vertex. Isso facilita a passagem de valores porque os nomes e locais são naturalmente consistentes.

index.html (chamada createShaderModule)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}

Independentemente do padrão escolhido, o resultado é que você tem acesso ao número da célula na função @fragment e pode usá-lo para influenciar a cor. Com qualquer um dos códigos acima, a saída será semelhante a esta:

Uma grade de quadrados onde a coluna mais à esquerda é verde, a linha inferior é vermelha e todos os outros quadrados são amarelos.

Definitivamente há mais cores, mas elas não são exatamente bonitas. Você pode se perguntar por que apenas as linhas esquerda e inferior são diferentes. Isso ocorre porque os valores de cor retornados da função @fragment esperam que cada canal esteja no intervalo de 0 a 1, e todos os valores fora desse intervalo são fixados nele. Por outro lado, os valores das células variam de 0 a 32 em cada eixo. O que vemos aqui é que a primeira linha e a primeira coluna atingem imediatamente o valor 1 no canal de cor vermelha ou verde, e todas as células depois disso se prendem ao mesmo valor.

Se você quiser uma transição mais suave entre as cores, precisa retornar um valor fracionário para cada canal de cores, idealmente começando em zero e terminando em um ao longo de cada eixo, o que significa ainda mais uma divisão por grid.

  1. Mude o sombreador de fragmento desta forma:

index.html (chamada createShaderModule)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell/grid, 0, 1);
}

Atualize a página e veja que o novo código oferece um gradiente de cores muito melhor em toda a grade.

Uma grade de quadrados que passa de preto para vermelho, verde e amarelo em cantos diferentes.

Embora isso seja certamente uma melhoria, agora há um canto escuro no lado esquerdo inferior, em que a grade fica preta. Quando você começar a fazer a simulação do jogo, uma seção difícil de ver na grade ocultará o que está acontecendo. Seria ótimo esclarecer isso.

Felizmente, você tem um canal de cores não utilizado (azul) para usar. O ideal é que o azul seja mais brilhante, onde as outras cores são mais escuras, e depois esmaeça à medida que as outras cores crescem em intensidade. A maneira mais fácil de fazer isso é fazer com que o canal comece em um e subtraia um dos valores de célula. Pode ser c.x ou c.y. Experimente as duas opções e escolha a que preferir.

  1. Adicione cores mais claras ao sombreador de fragmentos, desta forma:

Chamada createShaderModule

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  let c = input.cell / grid;
  return vec4f(c, 1-c.x, 1);
}

O resultado parece bom.

Uma grade de quadrados que passa de vermelho para verde e de azul para amarelo em cantos diferentes.

Esta não é uma etapa crítica. Mas, como fica melhor, ele está incluído no arquivo de origem do checkpoint, e as outras capturas de tela neste codelab refletem essa grade mais colorida.

7. Gerenciar estado da célula

Em seguida, você precisa controlar quais células na grade são renderizadas, com base em algum estado armazenado na GPU. Isso é importante para a simulação final.

Tudo o que você precisa é de um sinal de ativação para cada célula, de modo que as opções que permitem armazenar uma grande matriz de quase todos os tipos de valor funcionam. Você pode achar que esse é outro caso de uso para buffers uniformes! Embora você possa fazer esse trabalho, ele é mais difícil porque buffers uniformes são limitados, não suportam matrizes de tamanho dinâmico (é necessário especificar o tamanho da matriz no sombreador) e não podem ser gravados por sombreadores de computação. Esse último item é o mais problemático, porque você quer fazer a simulação do Game of Life na GPU em um sombreador de computação.

Felizmente, há outra opção de buffer que evita todas essas limitações.

Criar um buffer de armazenamento

Buffers de armazenamento são buffers de uso geral que podem ser lidos e gravados em sombreadores de computação e lidos em sombreadores de vértice. Elas podem ser muito grandes e não precisam de um tamanho declarado específico em um sombreador, o que as torna muito mais comuns na memória geral. É isso que você usa para armazenar o estado da célula.

  1. Para criar um buffer de armazenamento para o estado da célula, use o que, no momento, provavelmente está começando a ser um snippet de código de criação de buffer com aparência familiar:

index.html

// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);

// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
  label: "Cell State",
  size: cellStateArray.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

Assim como acontece com os vértices e buffers uniformes, chame device.createBuffer() com o tamanho apropriado e, em seguida, especifique um uso de GPUBufferUsage.STORAGE.

É possível preencher o buffer da mesma forma que antes, preenchendo o TypedArray do mesmo tamanho com valores e, em seguida, chamando device.queue.writeBuffer(). Para ver o efeito do buffer na grade, comece preenchendo-o com algo previsível.

  1. Ative cada terceira célula com o seguinte código:

index.html

// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);

Ler o buffer de armazenamento no sombreador

Em seguida, atualize o sombreador para analisar o conteúdo do buffer de armazenamento antes de renderizar a grade. Isso é muito semelhante à forma como os uniformes foram adicionados anteriormente.

  1. Atualize o sombreador com o seguinte código:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!

Primeiro, adicione o ponto de vinculação, que fica logo abaixo do uniforme da grade. Você quer manter o mesmo @group que o uniforme grid, mas o número @binding precisa ser diferente. O tipo var é storage, para refletir o diferente tipo de buffer e, em vez de um único vetor, o tipo que você fornece para o cellState é uma matriz de valores u32, para corresponder ao Uint32Array em JavaScript.

Em seguida, no corpo da função @vertex, consulte o estado da célula. Como o estado é armazenado em uma matriz plana no buffer de armazenamento, você pode usar instance_index para procurar o valor da célula atual.

Como desativar uma célula se o estado disser que ela está inativa? Como os estados ativo e inativo da matriz são 1 ou 0, você pode dimensionar a geometria pelo estado ativo. Ao dimensioná-lo para 1, a geometria fica inalterada e dimensioná-lo por 0 faz com que ela seja recolhida em um único ponto, que a GPU descarta.

  1. Atualize o código do sombreador para dimensionar a posição pelo estado ativo da célula. O valor do estado precisa ser transmitido para um f32 para atender aos requisitos de segurança de tipo do WGSL:

index.html

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> VertexOutput {
  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let state = f32(cellState[instance]); // New line!

  let cellOffset = cell / grid * 2;
  // New: Scale the position by the cell's active state.
  let gridPos = (pos*state+1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell;
  return output;
}

Adicionar o buffer de armazenamento ao grupo de vinculação

Antes de ver o estado da célula entrar em vigor, adicione o buffer de armazenamento a um grupo de vinculação. Como ele faz parte do mesmo @group do buffer uniforme, adicione-o também ao mesmo grupo de vinculação no código JavaScript.

  • Adicione o buffer de armazenamento, como este:

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  },
  // New entry!
  {
    binding: 1,
    resource: { buffer: cellStateStorage }
  }],
});

Verifique se o binding da nova entrada corresponde ao @binding() do valor correspondente no sombreador.

Com isso, você poderá atualizar e ver o padrão aparecer na grade.

Listras diagonais de quadrados coloridos que vão do canto inferior esquerdo para o canto superior direito sobre um fundo azul escuro.

Usar o padrão do buffer do pingue-pongue

A maioria das simulações, como a que você está criando, geralmente usa pelo menos duas cópias do estado delas. Em cada etapa da simulação, eles leem uma cópia do estado e gravam na outra. Em seguida, na próxima etapa, vire-o e leia o estado em que ele escreveu antes. Isso é comumente chamado de padrão de ping pong porque a versão mais atualizada do estado oscila entre as cópias do estado.

Por que isso é necessário? Veja um exemplo simplificado: imagine que você está escrevendo uma simulação muito simples em que você move os blocos ativos diretamente para cada célula a cada etapa. Para manter a compreensão, você define os dados e a simulação em JavaScript:

// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];

function simulate() {
  for (let i = 0; i < state.length-1; ++i) {
    if (state[i] == 1) {
      state[i] = 0;
      state[i+1] = 1;
    }
  }
}

simulate(); // Run the simulation for one step.

Mas se você executar esse código, a célula ativa se move até o fim da matriz em uma etapa. Sabe por quê? Como você continua atualizando o estado no lugar, move a célula ativa para a direita e depois olha para a próxima célula e... oi! Ativo! Mova-o para a direita novamente. O fato de você alterar os dados ao mesmo tempo que os observa corrompe os resultados.

Usando o padrão de pingue-pongue, você garante que sempre realize a próxima etapa da simulação usando apenas os resultados da última etapa.

// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];

function simulate(in, out) {
  out[0] = 0;
  for (let i = 1; i < in.length; ++i) {
     out[i] = in[i-1];
  }
}

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA);
  1. Use esse padrão no próprio código atualizando a alocação do buffer de armazenamento para criar dois buffers idênticos:

index.html

// Create two storage buffers to hold the cell state.
const cellStateStorage = [
  device.createBuffer({
    label: "Cell State A",
    size: cellStateArray.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  }),
  device.createBuffer({
    label: "Cell State B",
     size: cellStateArray.byteLength,
     usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  })
];
  1. Para ajudar a visualizar a diferença entre os dois buffers, preencha-os com dados diferentes:

index.html

// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. Para mostrar os diferentes buffers de armazenamento na renderização, atualize os grupos de vinculação para que tenham também duas variantes diferentes:

index.html

const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
   device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }],
  })
];

Configurar um loop de renderização

Até agora, você só fez um desenho por atualização da página, mas agora quer mostrar a atualização de dados ao longo do tempo. Para isso, você precisa de um loop de renderização simples.

Um loop de renderização é um loop sem repetição que desenha seu conteúdo na tela em um determinado intervalo. Muitos jogos e outros conteúdos que querem fazer uma animação suave usam a função requestAnimationFrame() para programar callbacks na mesma taxa que a tela é atualizada (60 vezes a cada segundo).

Esse aplicativo também pode usar isso, mas nesse caso você provavelmente quer que as atualizações ocorram em etapas mais longas para que você possa acompanhar mais facilmente o que a simulação está fazendo. Gerencie o loop para que você possa controlar a taxa de atualização da simulação.

  1. Primeiro, escolha uma taxa para atualização em nossa simulação (200 ms é bom, mas você pode ir mais devagar ou mais rápido, se desejar) e, em seguida, acompanhar quantas etapas de simulação foram concluídas.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. Em seguida, mova todo o código usado atualmente para renderização em uma nova função. Programe essa função para se repetir no intervalo desejado com setInterval(). Verifique se a função também atualiza a contagem de passos e use-a para escolher qual dos dois grupos de vinculação será vinculado.

index.html

// Move all of our rendering code into a function
function updateGrid() {
  step++; // Increment the step count

  // Start a render pass
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
      storeOp: "store",
    }]
  });

  // Draw the grid.
  pass.setPipeline(cellPipeline);
  pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
  pass.setVertexBuffer(0, vertexBuffer);
  pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

  // End the render pass and submit the command buffer
  pass.end();
  device.queue.submit([encoder.finish()]);
}

// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);

Agora, ao executar o app, você verá que a tela vai e volta entre os dois buffers de estado criados.

Listras diagonais de quadrados coloridos que vão do canto inferior esquerdo para o canto superior direito sobre um fundo azul escuro. Listras verticais de quadrados coloridos contra um fundo azul escuro.

Com isso, você concluiu o processamento. Você está pronto para exibir a saída da simulação do Game of Life criada na próxima etapa, em que finalmente começa a usar sombreadores de computação.

Obviamente, os recursos de renderização do WebGPU são muito mais do que a pequena parte que você analisou aqui, mas o restante está além do escopo deste codelab. Esperamos que ele ofereça uma amostra suficiente de como a renderização da WebGPU funciona, mas ajuda a explorar técnicas mais avançadas, como a renderização em 3D, mais fácil de entender.

8. Executar a simulação

Agora, para a última parte do quebra-cabeça: realizar a simulação do jogo da vida em um sombreador de computação.

Por fim, use sombreadores de computação.

Você aprendeu de maneira abstrata sobre sombreadores de computação neste codelab, mas o que exatamente eles são?

Um sombreador de computação é semelhante aos sombreadores de vértice e fragmento, já que são projetados para executar com paralelismo extremo na GPU, mas, ao contrário dos outros dois estágios de sombreador, eles não têm um conjunto específico de entradas e saídas. Você está lendo e gravando dados exclusivamente de fontes escolhidas, como buffers de armazenamento. Isso significa que, em vez de executar uma vez para cada vértice, instância ou pixel, é preciso informar quantas invocações da função de sombreador você quer. Em seguida, ao executar o sombreador, você é informado sobre a invocação que está sendo processada e pode decidir quais dados vai acessar e quais operações realizará a partir dela.

Os sombreadores de computação precisam ser criados em um módulo de sombreadores, assim como os sombreadores de vértice e fragmento. Portanto, adicione-os ao código para começar. Como você pode imaginar, dada a estrutura dos outros sombreadores implementados, a função principal do sombreador de computação precisa ser marcada com o atributo @compute.

  1. Crie um sombreador de computação com o seguinte código:

index.html

// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
  label: "Game of Life simulation shader",
  code: `
    @compute
    fn computeMain() {

    }`
});

Como as GPUs são usadas com frequência em gráficos 3D, os sombreadores de computação são estruturados de forma que você pode solicitar que o sombreador seja invocado um número específico de vezes ao longo dos eixos X, Y e Z. Isso permite que você envie o trabalho que está em conformidade com uma grade 2D ou 3D, o que é ótimo para seu caso de uso. Você quer chamar esse sombreador GRID_SIZE vezes GRID_SIZE vezes, uma vez para cada célula da simulação.

Devido à natureza da arquitetura de hardware da GPU, essa grade é dividida em grupos de trabalho. Um grupo de trabalho tem um tamanho X, Y e Z e, embora os tamanhos possam ser 1 cada, geralmente há benefícios de desempenho em tornar os grupos de trabalho um pouco maiores. Para o sombreador, escolha um tamanho de grupo de trabalho um pouco arbitrário de oito para oito. Isso é útil para acompanhar em seu código JavaScript.

  1. Defina uma constante para o tamanho do grupo de trabalho, desta forma:

index.html

const WORKGROUP_SIZE = 8;

Também é necessário adicionar o tamanho do grupo de trabalho à própria função do sombreador, o que você faz usando literais de modelo do JavaScript para poder usar com facilidade a constante que acabou de definir.

  1. Adicione o tamanho do grupo de trabalho à função de sombreador desta forma:

index.html (chamada createShaderModule) do Compute

@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {

}

Isso informa ao sombreador que o trabalho feito com essa função é feito em grupos (8 x 8 x 1). Qualquer eixo que você não definir será padronizado como 1. No entanto, é necessário especificar pelo menos o eixo X.

Assim como nos outros estágios de sombreador, há uma variedade de valores @builtin que podem ser aceitos como entrada na função de sombreador de computação para informar a invocação em que você está e decidir o trabalho que precisa fazer.

  1. Adicione um valor @builtin, como este:

index.html (chamada createShaderModule) do Compute

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

Você transmite o global_invocation_id integrado, que é um vetor tridimensional de números inteiros não assinados que informa onde você está na grade de invocações do sombreador. Você executa esse sombreador uma vez para cada célula da grade. Você recebe números como (0, 0, 0), (1, 0, 0), (1, 1, 0)... até (31, 31, 0), o que significa que é possível tratá-lo como o índice de células em que operará.

Os sombreadores de computação também podem usar uniformes, que você usa da mesma forma que os sombreadores de vértice e fragmento.

  1. Use um uniforme com seu sombreador de computação para informar o tamanho da grade, desta forma:

index.html (chamada createShaderModule) do Compute

@group(0) @binding(0) var<uniform> grid: vec2f; // New line

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

Assim como no sombreador de vértices, você também expõe o estado das células como um buffer de armazenamento. Mas, neste caso, você precisa de dois deles. Como os sombreadores de computação não têm uma saída necessária, como uma posição de vértice ou cor de fragmento, gravar valores em um buffer de armazenamento ou textura é a única maneira de tirar resultados de um sombreador de computação. Use o método de ping-pong que você aprendeu anteriormente. Você tem um buffer de armazenamento que alimenta o estado atual da grade e outro que grava o novo estado da grade.

  1. Exponha o estado de entrada e saída da célula como buffers de armazenamento, como este:

index.html (chamada createShaderModule) do Compute

@group(0) @binding(0) var<uniform> grid: vec2f;

// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

O primeiro buffer de armazenamento é declarado com var<storage>, o que o torna somente leitura, mas o segundo é declarado com var<storage, read_write>. Isso permite ler e gravar no buffer, usando esse buffer como a saída para o sombreador de computação. Não há modo de armazenamento somente gravação no WebGPU.

Em seguida, você precisa ter uma maneira de mapear o índice de células na matriz de armazenamento linear. Isso é o oposto do que você fez no sombreador de vértice, em que você pegou o instance_index linear e o mapeou em uma célula de grade 2D. Lembre-se: seu algoritmo foi de vec2f(i % grid.x, floor(i / grid.x)).

  1. Escreva uma função para ir na outra direção. Ele usa o valor Y da célula, multiplica-o pela largura da grade e adiciona o valor X da célula.

index.html (chamada createShaderModule) do Compute

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

// New function
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

Por fim, para ver se está funcionando, implemente um algoritmo muito simples: se uma célula estiver ativada no momento, ela será desativada e vice-versa. Ainda não é o Game of Life, mas basta mostrar que o sombreador de computação está funcionando.

  1. Adicione o algoritmo simples, como este:

index.html (chamada createShaderModule) do Compute

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines. Flip the cell state every step.
  if (cellStateIn[cellIndex(cell.xy)] == 1) {
    cellStateOut[cellIndex(cell.xy)] = 0;
  } else {
    cellStateOut[cellIndex(cell.xy)] = 1;
  }
}

Isso é tudo para o sombreador de computação. Mas antes de ver os resultados, você precisa fazer mais algumas alterações.

Usar Bind Group e layouts de pipeline

Você pode notar que o sombreador acima usa as mesmas entradas (uniformes e buffers de armazenamento) do pipeline de renderização. Então, você pode achar que pode simplesmente usar os mesmos grupos de vinculação e terminar, certo? A boa notícia é que você pode. Basta um pouco mais de configuração manual para fazer isso.

Sempre que você criar um grupo de vinculação, será necessário fornecer um GPUBindGroupLayout. Anteriormente, esse layout era chamado chamando getBindGroupLayout() no pipeline de renderização, que, por sua vez, o criava automaticamente porque você forneceu layout: "auto" quando o criou. Essa abordagem funciona bem quando você usa apenas um único pipeline, mas se você tiver vários pipelines que queiram compartilhar recursos, será necessário criar o layout explicitamente e fornecê-lo ao grupo e aos pipelines de vinculação.

Para entender o motivo, considere isso: nos pipelines de renderização, você usa um único buffer uniforme e um único buffer de armazenamento, mas, no sombreador de computação que você acabou de escrever, é necessário um segundo buffer de armazenamento. Como os dois sombreadores usam os mesmos valores de @binding para o primeiro e igual buffer de armazenamento, é possível compartilhá-los entre pipelines, e o pipeline de renderização ignora o segundo buffer de armazenamento, que ele não usa. Você quer criar um layout que descreva todos os recursos presentes no grupo de vinculação, não apenas os usados por um pipeline específico.

  1. Para criar esse layout, chame device.createBindGroupLayout():

index.html

// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
  label: "Cell Bind Group Layout",
  entries: [{
    binding: 0,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: {} // Grid uniform buffer
  }, {
    binding: 1,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: { type: "read-only-storage"} // Cell state input buffer
  }, {
    binding: 2,
    visibility: GPUShaderStage.COMPUTE,
    buffer: { type: "storage"} // Cell state output buffer
  }]
});

Isso é semelhante à estrutura de criação do próprio grupo de vinculação, em que você descreve uma lista de entries. A diferença é que você descreve que tipo de recurso a entrada precisa ser e como ela é usada, em vez de fornecer o próprio recurso.

Em cada entrada, você fornece o número binding para o recurso, que (como você aprendeu quando criou o grupo de vinculação) corresponde ao valor @binding nos sombreadores. Você também fornece a visibility, que são sinalizações GPUShaderStage que indicam quais estágios de sombreador podem usar o recurso. Você quer que o primeiro e o primeiro buffer de armazenamento sejam acessíveis nos sombreadores de vértice e computação, mas o segundo buffer de armazenamento só precisa estar acessível nos sombreadores de computação. Você também pode tornar os recursos acessíveis aos sombreadores de fragmento com essas sinalizações, mas não é necessário fazer isso aqui.

Por fim, indique o tipo de recurso que está sendo usado. Essa é uma chave de dicionário diferente, dependendo do que você precisa expor. Aqui, os três recursos são buffers. Use a chave buffer para definir as opções de cada um. Outras opções incluem itens como texture ou sampler, mas isso não é necessário aqui.

No dicionário do buffer, defina opções como qual type do buffer é usado. O padrão é "uniform", então você pode deixar o dicionário vazio para a vinculação 0. Porém, você precisa definir pelo menos o buffer: {} para que a entrada seja identificada como um buffer. A vinculação 1 recebe um tipo de "read-only-storage" porque você não a usa com acesso ao read_write no sombreador, e a vinculação 2 tem um tipo de "storage" porque você a usa com acesso read_write.

Depois que o bindGroupLayout for criado, será possível transmiti-lo ao criar os grupos de vinculação em vez de consultar o grupo do pipeline. Isso significa que você precisa adicionar uma nova entrada de buffer de armazenamento a cada grupo de vinculação para corresponder ao layout que acabou de definir.

  1. Atualize a criação do grupo de vinculação da seguinte maneira:

index.html

// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: bindGroupLayout, // Updated Line
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[1] }
    }],
  }),
  device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: bindGroupLayout, // Updated Line

    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
];

Agora que o grupo de vinculação foi atualizado para usar esse layout explícito, você precisa atualizar o pipeline de renderização para usar a mesma coisa.

  1. Crie um GPUPipelineLayout.

index.html

const pipelineLayout = device.createPipelineLayout({
  label: "Cell Pipeline Layout",
  bindGroupLayouts: [ bindGroupLayout ],
});

Um layout de pipeline é uma lista de layouts de grupos de vinculação (neste caso, você tem um) que um ou mais pipelines usam. A ordem dos layouts de grupos de vinculação na matriz precisa corresponder aos atributos @group nos sombreadores. Isso significa que bindGroupLayout está associado a @group(0).

  1. Com o layout do pipeline, atualize o pipeline de renderização para usá-lo em vez de "auto".

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: pipelineLayout, // Updated!
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

Criar o pipeline de computação

Assim como precisa de um pipeline de renderização para usar os sombreadores de vértice e fragmento, você precisa de um pipeline de computação para usar o sombreador de computação. Felizmente, os pipelines de computação são muito menos complicados do que os pipelines de renderização, porque eles não têm estado para definir, apenas o sombreador e o layout.

  • Crie um pipeline de computação com o seguinte código:

index.html

// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
  label: "Simulation pipeline",
  layout: pipelineLayout,
  compute: {
    module: simulationShaderModule,
    entryPoint: "computeMain",
  }
});

Você transmite o novo pipelineLayout em vez de "auto", assim como no pipeline de renderização atualizado, que garante que o pipeline de renderização e o pipeline de computação possam usar os mesmos grupos de vinculação.

Cartões de computação

Isso leva você ao ponto de realmente usar o pipeline de computação. Como você faz a renderização em uma passagem de renderização, provavelmente vai precisar fazer o trabalho de computação em uma passagem de computação. O trabalho de computação e renderização pode acontecer no mesmo codificador de comandos. Portanto, você quer embaralhar um pouco a função updateGrid.

  1. Mova a criação do codificador para a parte superior da função e inicie uma transmissão de computação com ela (antes do step++).

index.html

// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();

const computePass = computeEncoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count

// Start a render pass...

Assim como os pipelines de computação, os cartões de computação são muito mais simples de iniciar do que os pipelines de renderização, porque você não precisa se preocupar com anexos.

Você quer fazer a passagem de computação antes da passagem de renderização, porque ela permite que o passe de renderização use imediatamente os resultados mais recentes da passagem de computação. Esse também é o motivo para você incrementar a contagem de step entre as passagens, de modo que o buffer de saída do pipeline de computação se torne o buffer de entrada do pipeline de renderização.

  1. Em seguida, defina o pipeline e o grupo de vinculação dentro da passagem de computação, usando o mesmo padrão para alternar entre os grupos de vinculação e a renderização que você faz.

index.html

const computePass = computeEncoder.beginComputePass();

// New lines
computePass.setPipeline(simulationPipeline),
computePass.setBindGroup(0, bindGroups[step % 2]);

computePass.end();
  1. Por fim, em vez de desenhar como uma passagem de renderização, você envia o trabalho para o sombreador de computação, informando quantos grupos de trabalho você quer executar em cada eixo.

index.html

const computePass = computeEncoder.beginComputePass();

computePass.setPipeline(simulationPipeline),
computePass.setBindGroup(0, bindGroups[step % 2]);

// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);

computePass.end();

Algo muito importante aqui é que o número transmitido para dispatchWorkgroups() não é o número de invocações. Em vez disso, é o número de grupos de trabalho a serem executados, conforme definido pelo @workgroup_size no sombreador.

Se você quiser que o sombreador seja executado 32x32 vezes para cobrir toda a grade e o tamanho do grupo de trabalho for 8x8, será necessário enviar grupos de trabalho 4x4 (4 * 8 = 32). É por isso que você divide o tamanho da grade pelo tamanho do grupo de trabalho e transmite esse valor para dispatchWorkgroups().

Agora, você pode atualizar a página novamente e verá que a grade se inverte a cada atualização.

Listras diagonais de quadrados coloridos que vão do canto inferior esquerdo para o canto superior direito sobre um fundo azul escuro. Listras diagonais de quadrados coloridos que se estendem da esquerda para a direita em um fundo azul escuro. A inversão da imagem anterior.

Implementar o algoritmo do Game of Life

Antes de atualizar o sombreador de computação para implementar o algoritmo final, volte ao código que está inicializando o conteúdo do buffer de armazenamento e atualize-o para produzir um buffer aleatório em cada carregamento de página. Padrões regulares não são pontos de partida de Game of Life muito interessantes. Você pode randomizar os valores da forma que desejar, mas há uma maneira fácil de começar que oferece resultados razoáveis.

  1. Para iniciar cada célula em um estado aleatório, atualize a inicialização de cellStateArray para o seguinte código:

index.html

// Set each cell to a random state, then copy the JavaScript array
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
  cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

Agora você pode implementar a lógica da simulação do Game of Life. Depois de tudo o que foi preciso para chegar aqui, o código do sombreador pode ser muito desagradável.

Primeiro, você precisa saber em uma célula determinada quantos de seus vizinhos estão ativos. Você não se importa com quais estão ativos, apenas a contagem.

  1. Para facilitar o recebimento de dados de células vizinhas, adicione uma função cellActive que retorne o valor cellStateIn da coordenada especificada.

index.html (chamada createShaderModule) do Compute

fn cellActive(x: u32, y: u32) -> u32 {
  return cellStateIn[cellIndex(vec2(x, y))];
}

A função cellActive retornará uma se a célula estiver ativa. Portanto, adicionar o valor de retorno de chamar cellActive para as oito células ao redor dará a você quantas células vizinhas estarão ativas.

  1. Encontre o número de vizinhos ativos, desta forma:

index.html (chamada createShaderModule) do Compute

fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines:
  // Determine how many active neighbors this cell has.
  let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                        cellActive(cell.x+1, cell.y) +
                        cellActive(cell.x+1, cell.y-1) +
                        cellActive(cell.x, cell.y-1) +
                        cellActive(cell.x-1, cell.y-1) +
                        cellActive(cell.x-1, cell.y) +
                        cellActive(cell.x-1, cell.y+1) +
                        cellActive(cell.x, cell.y+1);

No entanto, isso leva a um pequeno problema: o que acontece quando a célula que você está verificando está fora da borda? De acordo com sua lógica cellIndex() agora, ela transborda para a linha seguinte ou anterior ou sai da borda do buffer.

Para o Jogo da Vida, uma forma comum e fácil de resolver isso é fazer com que as células na borda da grade tratem as células na borda oposta da grade como suas vizinhas, criando um tipo de efeito circundante.

  1. Compatibilidade com grade circular com uma pequena alteração na função cellIndex().

index.html (chamada createShaderModule) do Compute

fn cellIndex(cell: vec2u) -> u32 {
  return (cell.y % u32(grid.y)) * u32(grid.x) +
         (cell.x % u32(grid.x));
}

Ao usar o operador % para unir a célula X e Y quando ela se estender além do tamanho da grade, você garante que nunca acessará fora dos limites do buffer de armazenamento. Com isso, a contagem de activeNeighbors é previsível.

Em seguida, aplique uma destas quatro regras:

  • Qualquer célula com menos de dois vizinhos fica inativa.
  • Qualquer célula ativa com dois ou três vizinhos permanece ativa.
  • Qualquer célula inativa com exatamente três vizinhos fica ativa.
  • Qualquer célula com mais de três vizinhos fica inativa.

É possível fazer isso com uma série de instruções "if", mas o WGSL também é compatível com instruções "switch", que são uma boa opção para essa lógica.

  1. Implemente a lógica do Game of Life, desta forma:

index.html (chamada createShaderModule) do Compute

let i = cellIndex(cell.xy);

// Conway's game of life rules:
switch activeNeighbors {
  case 2: { // Active cells with 2 neighbors stay active.
    cellStateOut[i] = cellStateIn[i];
  }
  case 3: { // Cells with 3 neighbors become or stay active.
    cellStateOut[i] = 1;
  }
  default: { // Cells with < 2 or > 3 neighbors become inactive.
    cellStateOut[i] = 0;
  }
}

Para referência, a chamada final do módulo de sombreador de computação agora tem esta aparência:

index.html

const simulationShaderModule = device.createShaderModule({
  label: "Life simulation shader",
  code: `
    @group(0) @binding(0) var<uniform> grid: vec2f;

    @group(0) @binding(1) var<storage> cellStateIn: array<u32>;
    @group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

    fn cellIndex(cell: vec2u) -> u32 {
      return (cell.y % u32(grid.y)) * u32(grid.x) +
              (cell.x % u32(grid.x));
    }

    fn cellActive(x: u32, y: u32) -> u32 {
      return cellStateIn[cellIndex(vec2(x, y))];
    }

    @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
    fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
      // Determine how many active neighbors this cell has.
      let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                            cellActive(cell.x+1, cell.y) +
                            cellActive(cell.x+1, cell.y-1) +
                            cellActive(cell.x, cell.y-1) +
                            cellActive(cell.x-1, cell.y-1) +
                            cellActive(cell.x-1, cell.y) +
                            cellActive(cell.x-1, cell.y+1) +
                            cellActive(cell.x, cell.y+1);

      let i = cellIndex(cell.xy);

      // Conway's game of life rules:
      switch activeNeighbors {
        case 2: {
          cellStateOut[i] = cellStateIn[i];
        }
        case 3: {
          cellStateOut[i] = 1;
        }
        default: {
          cellStateOut[i] = 0;
        }
      }
    }
  `
});

E... é isso! Pronto! Atualize sua página e veja seu automatão de celular recém-criado crescer.

Captura de tela de um exemplo de estado da simulação do Game of Life, com células coloridas renderizadas em um fundo azul escuro.

9. Parabéns!

Você criou uma versão da simulação clássica de jogo da vida da Conway que é totalmente executada na GPU usando a API WebGPU.

Qual é a próxima etapa?

Leia mais

Documentos de referência