Tu primera app de WebGPU

1. Introducción

El logotipo de WebGPU consta de varios triángulos azules que forman una “W” estilizada.

Última actualización: 28/08/2023

¿Qué es WebGPU?

WebGPU es una nueva API moderna para acceder a las funciones de tu GPU en apps web.

API moderna

Antes de WebGPU, existía WebGL, que ofrecía un subconjunto de funciones de WebGPU. Permitió una nueva clase de contenido web enriquecido y los desarrolladores crearon contenidos asombrosos con él. Sin embargo, se basaba en la API de OpenGL ES 2.0, que se lanzó en 2007, que, a su vez, se basaba en la API OpenGL, que era aún más antigua. Las GPU evolucionaron mucho en ese momento y las APIs nativas que se usaban para interactuar con ellas también evolucionaron con Direct3D 12, Metal y Vulkan.

WebGPU incorpora los avances de estas APIs modernas a la plataforma web. Se enfoca en habilitar las funciones de GPU de manera multiplataforma, al mismo tiempo, presenta una API que se adapta de forma natural a la Web y es menos verbosa que algunas de las APIs nativas en las que se basa.

Renderización

Las GPU suelen asociarse con renderizaciones más rápidas y gráficos más detallados, por lo que la WebGPU no es una excepción. Incluye las funciones necesarias para admitir muchas de las técnicas de renderización más populares de la actualidad en las GPU de computadoras de escritorio y móviles, y ofrece una ruta para agregar nuevas funciones en el futuro a medida que las capacidades del hardware sigan evolucionando.

Procesamiento

Además de la renderización, WebGPU libera el potencial de tu GPU para realizar cargas de trabajo de uso general altamente paralelas. Estos sombreadores de cómputos se pueden usar de manera independiente, sin ningún componente de renderización o como una parte estrechamente integrada de tu canalización de renderización.

En el codelab de hoy, aprenderás a aprovechar las capacidades de renderización y procesamiento de WebGPU para crear un proyecto introductorio sencillo.

Qué compilarás

En este codelab, compilarás el Juego de la vida de Conway con WebGPU. Tu app hará lo siguiente:

  • Usar las capacidades de renderización de WebGPU para dibujar gráficos simples en 2D
  • Usar las capacidades de procesamiento de WebGPU para realizar la simulación

Una captura de pantalla del producto final de este codelab

El Juego de la Vida es lo que se conoce como un autómata celular, en el que una cuadrícula de celdas cambia de estado con el tiempo en función de un conjunto de reglas. En el Juego de la vida, las celdas se vuelven activas o inactivas según la cantidad de celdas cercanas que estén activas, lo que genera interesantes patrones que fluctúan a medida que miras el contenido.

Qué aprenderás

  • Cómo configurar WebGPU y configurar un lienzo
  • Cómo dibujar una geometría simple en 2D
  • Cómo usar vértices y sombreadores de fragmentos para modificar lo que se dibuja
  • Cómo usar sombreadores de cómputos para realizar una simulación simple

Este codelab se enfoca en la introducción de los conceptos fundamentales de WebGPU. No tiene por objeto ser una revisión integral de la API ni abarca (ni requiere) temas relacionados con frecuencia, como matemáticas de matrices 3D.

Requisitos

  • Una versión reciente de Chrome (113 o una versión posterior) en ChromeOS, macOS o Windows. WebGPU es una API multiplataforma entre navegadores, pero aún no se envió a todas partes
  • Conocimiento de HTML, JavaScript y Herramientas para desarrolladores de Chrome

No es obligatorio conocer otras APIs gráficas, como WebGL, Metal, Vulkan o Direct3D, pero si tienes alguna experiencia con ellas, es probable que notes numerosas similitudes con WebGPU que pueden ayudarte a iniciar tu aprendizaje.

2. Prepárate

Obtén el código

Este codelab no tiene dependencias y te guiará a través de cada paso necesario con el objetivo de crear la app de WebGPU, por lo que no necesitas ningún código para empezar. Sin embargo, puedes encontrar algunos ejemplos que funcionan como puntos de control en https://glitch.com/edit/#!/your-first-webgpu-app. Puedes consultarlos y usarlos como referencia si no puedes continuar.

Usa la consola para desarrolladores

WebGPU es una API bastante compleja con muchas reglas que garantizan un uso adecuado. Es más, debido al funcionamiento de la API, no puede generar excepciones típicas de JavaScript para muchos errores, lo que dificulta la identificación exacta del origen del problema.

Te encontrarás con problemas cuando desarrolles con WebGPU, sobre todo si eres principiante y está bien. Los desarrolladores responsables de la API conocen los desafíos de trabajar con el desarrollo de GPU y se esforzaron por garantizar que cada vez que tu código de WebGPU cause un error, recibas mensajes muy detallados y útiles en la consola para desarrolladores que te ayudarán a identificar y solucionar el problema.

Siempre es útil mantener la consola abierta mientras se trabaja en cualquier aplicación web, pero se aplica en especial aquí.

3. Inicializa WebGPU

Empieza con un <canvas>

Puedes usar WebGPU sin mostrar nada en la pantalla si solo deseas usarlo para hacer cálculos. Pero si deseas renderizar lo que sea, como lo haremos en el codelab, necesitas un lienzo. Es un buen punto de partida.

Crea un documento HTML nuevo con un solo elemento <canvas> y una etiqueta <script> en la que consultemos el elemento de lienzo. (O usar 00-starter-page.html de error).

  • Crea un archivo index.html con el siguiente 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>

Solicita un adaptador y un dispositivo

Ya puedes acceder a los bits de WebGPU. Primero, debes considerar que las APIs, como WebGPU, pueden tardar un poco en propagarse por todo el ecosistema web. Como resultado, un primer paso de precaución es comprobar si el navegador del usuario puede usar WebGPU.

  1. Para comprobar si existe el objeto navigator.gpu, que sirve como punto de entrada para WebGPU o existe, agrega el siguiente código:

index.html

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

Lo mejor es informar al usuario si WebGPU no está disponible y que la página vuelva a un modo que no use WebGPU. (¿Tal vez podría usar WebGL en su lugar?) Para los propósitos de este codelab, sin embargo, basta con arrojar un error con el objetivo de detener la ejecución del código.

Cuando sepas que el navegador admite WebGPU, el primer paso para inicializar WebGPU en tu app es solicitar un GPUAdapter. Puedes pensar en un adaptador como la representación de WebGPU de una pieza específica de hardware de GPU en tu dispositivo.

  1. Para obtener un adaptador, usa el método navigator.gpu.requestAdapter(). Muestra una promesa, por lo que es más conveniente llamarla con await.

index.html

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

Si no se encuentran adaptadores adecuados, el valor de adapter que devuelve puede ser null, así que te recomendamos controlar esa posibilidad. Esto puede suceder si el navegador del usuario es compatible con WebGPU, pero el hardware de la GPU no tiene todas las funciones necesarias para usar WebGPU.

La mayoría de las veces, es correcto dejar que el navegador elija un adaptador predeterminado, como lo haces aquí. Sin embargo, para necesidades más avanzadas, hay argumentos que se pueden pasar a requestAdapter() que especifican si deseas usar hardware de bajo consumo o de alto rendimiento en dispositivos con varias GPU (como algunas laptops).

Cuando tengas un adaptador, el último paso para empezar a trabajar con la GPU es solicitar un GPUDevice. El dispositivo es la interfaz principal a través de la que se produce la mayor interacción con la GPU.

  1. Para obtener el dispositivo, llama a adapter.requestDevice(), que también muestra una promesa.

index.html

const device = await adapter.requestDevice();

Igual que con requestAdapter(), hay opciones que se pueden pasar aquí para usos más avanzados, como habilitar funciones de hardware específicas o solicitar límites más altos, pero para tus fines, los valores predeterminados funcionan bien.

Configura el lienzo

Ahora que tienes un dispositivo, puedes hacer lo siguiente si quieres usarlo con el objetivo de mostrar lo que quieras en la página: configura el lienzo para que se use con el dispositivo que acabas de crear.

  • Para ello, primero solicita un objeto GPUCanvasContext del lienzo con una llamada a canvas.getContext("webgpu"). (Es la misma llamada que usarías para inicializar contextos de Canvas 2D o WebGL, con los tipos de contexto 2d y webgl, respectivamente). El objeto context que muestra debe estar asociado con el dispositivo a través del método configure(), de esta manera:

index.html

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

Hay algunas opciones que se pueden pasar aquí, pero las más importantes son el device con el que usarás el contexto y el format, que es el formato de textura que el contexto debe usar.

Las texturas son los objetos que WebGPU usa para almacenar datos de imágenes. Cada textura tiene un formato que le permite a la GPU saber cómo se disponen esos datos en la memoria. Los detalles de cómo funciona la memoria de textura exceden el alcance de este codelab. Lo importante que debes saber es que el contexto de lienzo ofrece texturas para que tu código lo dibuje y el formato que uses puede tener un impacto en la eficiencia con la que el lienzo muestra esas imágenes. Los diferentes tipos de dispositivos tienen un mejor rendimiento cuando se usan diferentes formatos de textura y, si no usas el formato preferido del dispositivo, es posible que se produzcan copias de memoria adicionales detrás de escena para que la imagen pueda mostrarse como parte de la página.

Por suerte, no tienes que preocuparte mucho por nada de eso, ya que WebGPU te dice qué formato usar para tu lienzo. En casi todos los casos, lo que se desea es pasar el valor que se muestra con la llamada a navigator.gpu.getPreferredCanvasFormat(), como se mostró anteriormente.

Borra el lienzo

Ahora que tienes un dispositivo y se configuró el lienzo, puedes empezar a usarlo para cambiar el contenido. Para empezar, bórralo con un color sólido.

Para hacer eso (o prácticamente todo lo que se hace en la WebGPU) debes proporcionar algunos comandos a la GPU que le indiquen lo que debe hacer.

  1. Para ello, haz que el dispositivo cree un GPUCommandEncoder, que proporcione una interfaz con el objetivo de grabar comandos de la GPU.

index.html

const encoder = device.createCommandEncoder();

Los comandos que deseas enviar a la GPU están relacionados con el procesamiento (en este caso, cuando se borra el lienzo). Por lo tanto, el siguiente paso es usar encoder para iniciar un pase de renderización.

Los pases de renderización se producen cuando ocurren todas las operaciones de dibujo en WebGPU. Cada una se inicia con una llamada beginRenderPass(), que define las texturas que recibe el resultado de cualquier comando de dibujo realizado. Los usos más avanzados pueden proporcionar varias texturas, llamadas adjuntos, con diversos fines, como almacenar la profundidad de la geometría renderizada o proporcionar suavizado. Sin embargo, para esta app, solo necesitas una.

  1. Obtén la textura del contexto del lienzo que creaste antes a través de una llamada a context.getCurrentTexture(), que muestra una textura con un ancho y una altura de píxeles que coincide con los atributos width y height del lienzo y el format especificado cuando llamaste a context.configure().

index.html

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

La textura se proporciona como la propiedad view de un colorAttachment. Los pases de renderización requieren que proporciones un GPUTextureView en lugar de un GPUTexture, que le indica en qué partes de la textura debe renderizarse. Esto solo es importante en casos de uso más avanzados, por lo que aquí llamas a createView() sin argumentos en la textura, lo que indica que quieres que el pase de renderización use toda la textura.

También debes especificar qué quieres que haga el pase de renderización con la textura cuando se inicie y cuando finalice:

  • Un valor loadOp de "clear" indica que deseas que se borre la textura cuando empieza el pase de renderización.
  • Un valor storeOp de "store" indica que, una vez que finalice el pase de renderización, se guardarán los resultados de cualquier dibujo durante el pase de renderización en la textura.

Una vez que empiece el pase de renderización, no debes hacer nada. Al menos por ahora. El acto de iniciar el pase de renderización con loadOp: "clear" es suficiente para borrar la vista de textura y el lienzo.

  1. Para finalizar el pase de renderización, agrega la siguiente llamada justo después de beginRenderPass():

index.html

pass.end();

Es importante saber que el simple hecho de realizar estas llamadas no implica que la GPU realice ninguna acción. Solo graban comandos para que la GPU los ejecute más tarde.

  1. Para crear un GPUCommandBuffer, llama a finish() en el codificador de comandos. El búfer de comandos es un controlador opaco para los comandos grabados.

index.html

const commandBuffer = encoder.finish();
  1. Envía el búfer de comandos a la GPU con el queue de GPUDevice. La cola realiza todos los comandos de la GPU, lo que garantiza que su ejecución sea bien ordenada y esté sincronizada de forma adecuada. El método submit() de la cola toma un array de búferes de comandos, aunque, en este caso, solo tienes uno.

index.html

device.queue.submit([commandBuffer]);

Cuando envías un búfer de comandos, no se puede usar de nuevo, por lo que no es necesario esperar. Si desea enviar más comandos, deberá compilar otro búfer de comandos. Por ello es habitual ver que esos dos pasos se contraen en uno, como se hace en las páginas de muestra de este codelab:

index.html

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

Después de enviar los comandos a la GPU, permite que JavaScript muestre el control al navegador. En ese momento, el navegador detecta que cambiaste la textura actual del contexto y actualiza el lienzo para mostrar esa textura como una imagen. Si deseas volver a actualizar el contenido del lienzo después de eso, debes grabar y enviar un nuevo búfer de comandos y volver a llamar a context.getCurrentTexture() para obtener una nueva textura de un pase de renderización.

  1. Vuelve a cargar la página. Observa que el lienzo se rellena de negro. ¡Felicitaciones! Esto significa que acabas de crear correctamente tu primera app WebGPU.

Un lienzo negro que indica que WebGPU se usó correctamente para borrar el contenido del lienzo.

Elige un color

A decir verdad, los cuadrados negros son bastante aburridos. Así que, tómate un momento antes de continuar con la siguiente sección para personalizarlo un poco.

  1. En la llamada device.beginRenderPass(), agrega una línea nueva con un clearValue al colorAttachment, como se muestra a continuación:

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",
  }],
});

El valor clearValue le indica al pase de renderización qué color debe usar cuando realiza la operación clear al inicio del pase. El diccionario que se pasa contiene cuatro valores: r para rojo, g para verde, b para azul y a para alfa (transparencia). Cada valor puede variar de 0 a 1 y juntos describen el valor de ese canal de color. Por ejemplo:

  • { r: 1, g: 0, b: 0, a: 1 } es el color rojo brillante.
  • { r: 1, g: 0, b: 1, a: 1 } es el color púrpura brillante.
  • { r: 0, g: 0.3, b: 0, a: 1 } es el color verde oscuro.
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } es el color gris medio.
  • { r: 0, g: 0, b: 0, a: 0 } es el color negro predeterminado y transparente.

El código de ejemplo y las capturas de pantalla de este codelab usan un color azul oscuro, pero puedes elegir el color que quieras.

  1. Después de elegir el color, vuelve a cargar la página. Deberías ver el color que elegiste en el lienzo.

Un lienzo de color azul oscuro para demostrar cómo cambiar el color predeterminado.

4. Dibuja la geometría

Al final de esta sección, tu app dibujará geometría simple en el lienzo: un cuadrado de color. Ten en cuenta que te parecerá mucho trabajo para un resultado tan simple, pero eso se debe a que WebGPU está diseñada con el objetivo de renderizar grandes cantidades de geometría de forma muy eficiente. Un efecto secundario de esta eficiencia consiste en que hacer algo relativamente sencillo puede parecer muy difícil, pero esa es la expectativa si decides usar una API como WebGPU, es recomendable realizar una acción un poco más compleja.

Comprende cómo se diseñan las GPU

Antes de que se realicen más cambios en el código, vale la pena hacer una descripción general muy rápida, simplificada y de alto nivel de cómo las GPU crean las formas que ves en pantalla. (Si estás familiarizado con los conceptos básicos del funcionamiento del renderizado en la GPU, puedes pasar directamente a la sección Define los vértices).

A diferencia de una API como Canvas 2D que tiene muchas formas y opciones listas para usar, tu GPU realmente se ocupa de distintos tipos de formas (o primitivas como las denomina WebGPU): puntos, líneas y triángulos. Para los fines de este codelab, solo usarás triángulos.

Las GPU funcionan casi de forma exclusiva con triángulos porque tienen muchas propiedades matemáticas que hacen que sean fáciles de procesar de forma predecible y eficiente. Casi todo lo que dibujes con la GPU se debe dividir en triángulos antes de que la GPU pueda dibujarlo y esos triángulos deben definirse por sus puntos de esquina.

Estos puntos o vértices, se expresan en términos de valores X, Y y (para los contenidos 3D) Z que definen un punto en un sistema de coordenadas cartesianas definido por WebGPU o APIs similares. La estructura del sistema de coordenadas es más fácil de entender en términos de cómo se relaciona con el lienzo de tu página. Independientemente del ancho o alto del lienzo, el borde izquierdo siempre estará en -1, en el eje X y el borde derecho, siempre en +1, en el eje X. Del mismo modo, el borde inferior siempre es -1 en el eje Y y el borde superior es +1 en el eje Y. Eso significa que (0, 0) siempre es el centro del lienzo, (-1, -1) siempre es la esquina inferior izquierda y (1, 1) siempre es la esquina superior derecha. Esto se conoce como Espacio de recorte.

Un grafo sencillo en el que se visualiza el espacio de coordenadas normalizadas del dispositivo.

En un principio, los vértices no se suelen definir en este sistema de coordenadas, por lo que las GPU dependen de pequeños programas denominados sombreadores de vértices para realizar los cálculos necesarios con el objetivo de transformar los vértices en espacios de recorte, así como cualquier otro cálculo necesario para dibujar los vértices. Por ejemplo, el sombreador puede aplicar alguna animación o calcular la dirección del vértice a una fuente de luz. Estos sombreadores están escritos por ti, el desarrollador de GPU y proporcionan un increíble nivel de control sobre su funcionamiento.

Desde ahí, la GPU toma todos los triángulos formados por estos vértices transformados y determina los píxeles que se necesitan en la pantalla para dibujarlos. Luego, ejecuta otro programa pequeño que escribes, llamado sombreador de fragmentos que calcula el color de cada píxel. Ese cálculo puede ser tan simple como mostrar el verde o tan complejo como calcular el ángulo de la superficie en relación con la luz solar que rebota sobre otras superficies cercanas, filtrado por la niebla y modificado por la superficie metálica de la superficie. Todo está bajo tu control, lo que puede ser tan estimulante como abrumador.

Los resultados de esos colores de píxeles se acumulan en una textura que luego se puede mostrar en la pantalla.

Define los vértices

Como se mencionó antes, la simulación del Juego de la Vida se muestra como una cuadrícula de celdas. Tu app necesita una forma de visualizar la cuadrícula y distinguir las celdas activas de las inactivas. El enfoque que se usará en este codelab será dibujar cuadrados de colores en las celdas activas y dejar vacías las celdas inactivas.

Esto significa que deberás proporcionar a la GPU cuatro puntos diferentes, uno para cada una de las cuatro esquinas del cuadrado. Por ejemplo, un cuadrado dibujado en el centro del lienzo, extraído de los bordes, tiene coordenadas de esquina como la siguiente:

Un grafo de coordenadas normalizado del dispositivo que muestra las coordenadas de las esquinas de un cuadrado

Para ingresar esas coordenadas en la GPU, es necesario colocar los valores en un archivo TypedArray. Si aún no los conoces, los TypedArrays son un grupo de objetos de JavaScript que te permiten asignar bloques contiguos de memoria y que interpretan cada elemento de la serie como un tipo de datos específico. Por ejemplo, en un objeto Uint8Array, cada elemento del array es un solo byte sin firma. Los TypedArrays son ideales para enviar y recibir datos con APIs sensibles al diseño de la memoria, como WebAssembly, WebAudio y (por supuesto) WebGPU.

Para el ejemplo del cuadrado, como los valores son fraccionarios, es apropiado un Float32Array.

  1. Crea un array que contenga todas las posiciones de los vértices del diagrama. Para ello, coloca la siguiente declaración de array en tu código. Un buen lugar para ubicarlo es cerca de la parte superior, justo debajo de la llamada 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,
]);

Ten en cuenta que el espacio y el comentario no tienen ningún efecto en los valores. Es solo para tu comodidad y con el objetivo de que sea más legible. Te ayuda a ver que cada par de valores constituye las coordenadas Y y X de un vértice.

Pero hay un problema. Las GPU funcionan en términos de triángulos, ¿recuerdas? Eso significa que debes proporcionar los vértices en grupos de tres. Tienes un grupo de cuatro. La solución es repetir dos de los vértices para crear dos triángulos que compartan una arista a través del medio del cuadrado.

Un diagrama que muestre cómo se usarán los cuatro vértices del cuadrado para formar dos triángulos.

Para formar el cuadrado del diagrama, debes enumerar los vértices (-0.8, -0.8) y (0.8, 0.8) dos veces, una para el triángulo azul y otra para el rojo. (También puedes elegir dividir el cuadrado con las otras dos esquinas. No hace ninguna diferencia).

  1. Actualiza tu array anterior de vertices para que se vea de la siguiente manera:

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,
]);

Aunque en el diagrama se muestra una separación entre los dos triángulos para mayor claridad, las posiciones de los vértices son exactamente las mismas y la GPU las renderiza sin espacios. Se renderizará como un cuadrado único y sólido.

Crea un búfer de vértices

La GPU no puede dibujar vértices con datos de un array de JavaScript. Las GPU suelen tener su propia memoria que está altamente optimizada para el renderizado, por lo que cualquier dato que quieras que la GPU use mientras dibuja debe estar en esa memoria.

Para muchos valores, incluidos los datos de vértices, la memoria de la GPU se administra a través de objetos GPUBuffer. Un búfer es un bloque de memoria al que la GPU puede acceder con facilidad y que se marca para determinados fines. Puedes pensar en él un poco como un TypedArray visible en la GPU.

  1. Para crear un búfer que contenga tus vértices, agrega la siguiente llamada a device.createBuffer() después de la definición de tu array vertices.

index.html

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

Lo primero que debes observar es que le asignas al búfer una etiqueta. Cada objeto WebGPU que crees puede recibir una etiqueta opcional y sin duda querrás hacerlo. La etiqueta es cualquier cadena que desees, siempre que te ayude a identificar el objeto. Si surge algún problema, esas etiquetas se usan en los mensajes de error que WebGPU produce para ayudarte a comprender qué fue lo que falló.

A continuación, proporciona un tamaño para el búfer en bytes. Necesitas un búfer con 48 bytes, que se determina multiplicando el tamaño de un número de punto flotante de 32 bits (4 bytes) por la cantidad de flotantes en el array vertices (12). Por suerte, TypedArrays ya calcula el byteLength de modo que puedes usarlo cuando crees el búfer.

Por último, debes especificar el uso del búfer. Esta es una o más de las marcas GPUBufferUsage, que se combinan con el operador | (OR a nivel de bits). En este caso, especifica que quieres que el búfer se use para datos de vértices (GPUBufferUsage.VERTEX) y que también puedas copiar datos en él (GPUBufferUsage.COPY_DST).

El objeto del búfer que se le muestra es opaco. No puedes inspeccionar (fácilmente) los datos que contiene. Además, la mayoría de sus atributos son inmutables, no puedes cambiar el tamaño de un GPUBuffer después de crearlo ni cambiar las marcas de uso. Lo que puedes cambiar es el contenido de su memoria.

Cuando se crea el búfer al principio, la memoria que contiene se inicializa en cero. Existen varias formas de cambiar su contenido, pero la más sencilla es llamar a device.queue.writeBuffer() con un TypedArray que quieras copiar.

  1. Para copiar los datos de vértices en la memoria del búfer, agrega el siguiente código:

index.html

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

Define el diseño de vértices

Ahora tienes un búfer con datos de vértices, pero en lo que respecta a la GPU, solo se trata de un BLOB de bytes. Debes proporcionar un poco más de información si lo que quieres es dibujar algo con él. Es necesario que puedas indicarle a WebGPU más información sobre la estructura de los datos de vértices.

index.html

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

Esto puede ser un poco confuso a primera vista, pero es bastante fácil de desglosar.

Lo primero que debes dar es el arrayStride. Esta es la cantidad de bytes que la GPU tiene que saltar hacia delante en el buffer cuando busca el siguiente vértice. Cada vértice del cuadrado está formado por dos números de punto flotante de 32 bits. Como se mencionó antes, un número de punto flotante de 32 bits es de 4 bytes, por lo que dos números de punto flotante son de 8 bytes.

A continuación, se encuentra la propiedad attributes, que es un array. Los atributos son datos individuales codificados en cada vértice. Los vértices solo contienen un atributo (la posición del vértice), pero los casos de uso más avanzados suelen tener vértices con varios atributos, como el color de un vértice o la dirección a la que apunta la superficie geométrica. Sin embargo, está fuera del alcance de este codelab.

En tu atributo único, primero define el format de los datos. Proviene de una lista de tipos de formato GPUVertexFormat que describen cada tipo de datos de vértices que la GPU puede comprender. Tus vértices tienen dos números de puntos flotantes de 32 bits cada uno, por lo que debes usar el formato float32x2. Si, en cambio, tus datos de vértices se componen de cuatro números enteros sin firmar de 16 bits por ejemplo, debes usar uint16x4 en su lugar. ¿Ves el patrón?

A continuación, el offset describe cuántos bytes empiezan en el vértice de este atributo en particular. Solo debes preocuparte por esto si el búfer tiene más de un atributo, que no aparecerá durante este codelab.

Por último, esta es la shaderLocation. Este es un número arbitrario entre 0 y 15 y debe ser único para cada atributo que defina. Vincula este atributo a una entrada específica del sombreador de vértices, sobre la que obtendrás información en la siguiente sección.

Ten en cuenta que, aunque ahora se definen estos valores, en la actualidad no se pasan a la API de WebGPU. Eso es lo que sigue, pero es más fácil pensar en estos valores en el momento en que se definen los vértices, así que se configuran ahora para usarlos más adelante.

Primeros pasos con los sombreadores

Ahora tienes los datos que debes renderizar, pero debes indicarle a la GPU exactamente cómo procesarlos. En gran parte, eso sucede con los sombreadores.

Los sombreadores son pequeños programas que escribes y que se ejecutan en tu GPU. Cada sombreador opera en una etapa diferente de los datos: procesamiento de Vertex, de fragmentos o procesamiento general. Debido a que están en la GPU, se estructuran de manera más rígida que tu JavaScript promedio. Pero esa estructura les permite ejecutarse muy rápido y, lo que es más importante, en paralelo.

Los sombreadores en WebGPU se escriben en un lenguaje de sombreado llamado WGSL (WebGPU Shading Language). WGSL es, sintácticamente, algo parecido a Rust, con funciones diseñadas para facilitar y agilizar los tipos comunes de GPU (como matemáticas de vectores y matrices). El alcance de este codelab no incluye la enseñanza de todo el lenguaje de sombreado, pero esperamos que aprendas algunos de los conceptos básicos a medida que recorras algunos ejemplos sencillos.

Los sombreadores se pasan a WebGPU como cadenas.

  • Crea un lugar para escribir el código del sombreador. Para ello, copia lo siguiente en el código debajo de vertexBufferLayout:

index.html

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

Para crear los sombreadores, llama a device.createShaderModule(), para el cual se proporciona un label opcional y code WGSL como una cadena. (Ten en cuenta que aquí se usan acentos graves para permitir cadenas de varias líneas). Una vez que agregas un código WGSL válido, la función muestra un objeto GPUShaderModule con los resultados compilados.

Define el sombreador de vértices

Empieza con el sombreador de vértices, ya que allí también se inicia la GPU.

Un sombreador de vértices se define como una función y la GPU llama a esa función una vez por cada vértice de tu vertexBuffer. Dado que vertexBuffer tiene seis posiciones (vértices), la función que definas se llamará seis veces. Cada vez que se llama, se pasa por una posición diferente de vertexBuffer a la función como argumento y es el trabajo de la función de sombreador de vértices mostrar la posición correspondiente en el espacio de recorte.

También es importante entender que tampoco se llamarán necesariamente en orden secuencial. En cambio, las GPU se destacan en la ejecución de sombreadores como estos en paralelo, lo que podría generar cientos (o incluso miles) de vértices al mismo tiempo. Esto es en gran parte responsable de la velocidad increíble de las GPU, pero tiene limitaciones. Para garantizar la paralelización extrema, los sombreadores de vértices no pueden comunicarse entre sí. Cada invocación del sombreador solo puede ver datos de un vórtice a la vez solo puede generar valores para un único vértice.

En WGSL, se puede asignar el nombre que desees a una función de sombreador de vértices, pero debe tener el atributo @vertex delante para indicar qué etapa del sombreador representa. WGSL denota funciones con la palabra clave fn, usa paréntesis con el objetivo de declarar argumentos y llaves para definir el alcance.

  1. Crea una función @vertex vacía de esta manera:

index.html (código createShaderModule)

@vertex
fn vertexMain() {

}

Sin embargo, eso no es válido, ya que un sombreador de vértices debe mostrar al menos la posición final del vértice que se procesa en el espacio de recorte. Esto siempre se proporciona como un vector de 4 dimensiones. Los vectores son tan comunes que se usan en sombreadores que se tratan como primitivos de primera clase en el lenguaje, con sus propios tipos, como vec4f para un vector de 4 dimensiones. También hay tipos similares para vectores 2D (vec2f) y vectores 3D (vec3f).

  1. Para indicar que el valor que se muestra es la posición requerida, márcalo con el atributo @builtin(position). Se usa un símbolo -> para indicar que esto es lo que muestra la función.

index.html (código createShaderModule)

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

}

Por supuesto, si la función tiene un tipo de datos que se devuelve, en realidad debes mostrar un valor en el cuerpo de la función. Puedes construir un vec4f nuevo para mostrar, con la sintaxis vec4f(x, y, z, w). Los valores x, y y z son números de punto flotante que, en el valor de retorno, indican en qué lugar se encuentra el vértice en el espacio de recorte.

  1. Devuelve un valor estático de (0, 0, 0, 1) técnicamente, tienes un sombreador de vértices válido, aunque uno que nunca muestra nada, ya que la GPU reconoce que los triángulos que produce son solo un punto y, luego, lo descartan.

index.html (código createShaderModule)

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

Te recomendamos usar los datos del búfer que creaste. Para ello, declara un argumento para tu función con un atributo y un tipo de@location() que coincida con lo que describiste en vertexBufferLayout. Especificaste un shaderLocation de 0, por lo que en el código WGSL, marca el argumento con @location(0). También definiste el formato como un float32x2, que es un vector de 2D, por lo que en WGSL tu argumento es un vec2f. Puedes asignarles el nombre que desees, pero como estos representan tus posiciones de vértice, un nombre como pos parece natural.

  1. Cambia la función de sombreador al siguiente código:

index.html (código createShaderModule)

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

Y ahora debes mostrar esa posición. Debido a que la posición es un vector 2D y el tipo de datos que se devuelve es un vector 4D, debes modificarlo un poco. Lo que debes hacer es tomar los dos componentes del argumento de posición y colocarlos en los primeros dos componentes del vector de retorno, dejando los últimos dos componentes como 0 y 1, respectivamente.

  1. Muestra la posición correcta que indica de forma explícita qué componentes de posición usar:

index.html (código createShaderModule)

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

Sin embargo, como estos tipos de asignaciones son tan comunes en sombreadores, también puedes pasar el vector de posición como el primer argumento en una forma abreviada conveniente y significa lo mismo.

  1. Vuelve a escribir la sentencia return con el siguiente código:

index.html (código createShaderModule)

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

Ese es el sombreador inicial de vértices. Es muy simple, pasa la posición de forma eficaz sin cambios, pero es bueno para empezar.

Define el sombreador de fragmentos

El siguiente es el sombreador de fragmentos. Los sombreadores de fragmentos funcionan de forma muy similar a los sombreadores de vértices, pero, en lugar de invocarse para cada vértice, se invocan para cada píxel que se dibuja.

Los sombreadores de fragmentos siempre se llaman después de sombreadores de vértices. La GPU toma la salida de los sombreadores de vértices y la triangula, lo que crea triángulos a partir de conjuntos de tres puntos. Luego, para determinar los píxeles de los adjuntos de color de salida incluidos en ese triángulo genera la trama cada uno de esos triángulos y, a continuación, llama al sombreador de fragmentos una vez para cada uno de esos píxeles. El sombreador de fragmentos muestra un color, que por lo general se calcula a partir de los valores que se le envían desde el sombreador de vértices y elementos como texturas, que la GPU escribe en el adjunto de color.

Al igual que los sombreadores de vértices, los sombreadores de fragmentos se ejecutan de forma masiva y paralela. Son un poco más flexibles que los sombreadores de vértices en términos de sus entradas y salidas, pero puedes considerar que solo muestren un color por cada píxel de cada triángulo.

Una función de sombreador de fragmentos de WGSL se denota con el atributo @fragment y también muestra un vec4f. Sin embargo, en este caso, el vector representa un color, no una posición. El valor que devuelve debe tener un atributo @location para indicar a qué colorAttachment de la llamada beginRenderPass se escribe el color que se muestra. Como solo tenías un archivo adjunto, la ubicación es 0.

  1. Crea una función @fragment vacía de esta manera:

index.html (código createShaderModule)

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

}

Los cuatro componentes del vector que se muestran son los valores de color rojo, verde, azul y alfa, que se interpretan de la misma manera que el clearValue que configuraste en beginRenderPass anteriormente. Por lo tanto vec4f(1, 0, 0, 1) es de color rojo brillante, que parece un color adecuado para tu cuadrado. Puedes elegir el color que desees.

  1. Configura el vector de color que se muestra de la siguiente manera:

index.html (código createShaderModule)

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

Y ese es un sombreador de fragmentos completo. No es muy interesante, solo pone todos los píxeles de cada triángulo en rojo, pero por ahora es suficiente.

En resumen, después de agregar el código de sombreador que se detalló antes, tu llamada a createShaderModule ahora se verá de la siguiente manera:

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

Crea una canalización de renderizaciones

No se puede usar un módulo de sombreador para renderizarlo. En su lugar, debes usarlo como parte de una GPURenderPipeline, que se crea a través de una llamada device.createRenderPipeline(). La canalización de renderizaciones controla cómo se dibuja la geometría, incluidos elementos como los sombreadores, cómo interpretar los datos en los búferes de vértices, qué tipo de geometría debe renderizarse (líneas, puntos, triángulos, etc.) y mucho más.

La canalización de renderizaciones es el objeto más complejo de toda la API, pero no te preocupes. La mayoría de los valores que puedes pasar son opcionales y solo debes proporcionar algunos para empezar.

  • Crea una canalización de renderización como la siguiente:

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

Cada canalización necesita un layoutque describa los tipos de entrada (aparte de los búferes de vértices) que necesita la canalización, pero en realidad no tiene ninguno. Por suerte, puedes pasar "auto" por ahora y la canalización compila su propio diseño a partir de los sombreadores.

Luego, debes proporcionar los detalles sobre la etapa vertex. El module es el GPUShaderModule que contiene tu sombreador de vértices y entryPoint asigna el nombre de la función en el código del sombreador que se llama para cada invocación del vértice. (Puedes tener varias funciones @vertex y @fragment en un único módulo de sombreador) Los búferes son un array de objetos GPUVertexBufferLayout que describen cómo tus datos se empaquetan en los búferes de vértices con los que se usa esta canalización. Por suerte, ya definiste esto antes en tuvertexBufferLayout. Aquí es donde la pasas.

Por último, tienes los detalles sobre la etapa fragment. Esto también incluye un módulo del sombreador y entryPoint, como en la etapa de vértice. El último bit es definir los targets con el que se usa esta canalización. Este es un array de diccionarios que proporcionan detalles, como el format de textura, de los adjuntos de color a los que genera la canalización. Estos detalles deben coincidir con las texturas que se proporcionan en los colorAttachments de cualquier pase de renderización con el que se use esta canalización. Tu pase de renderización usa texturas del contexto de lienzo y usa el valor que guardaste en canvasFormat para su formato, por lo que pasas el mismo formato aquí.

Ni siquiera está cerca de todas las opciones que puedes especificar cuando creas una canalización de renderizado, pero es suficiente para las necesidades de este codelab.

Dibuja el cuadrado

Ya tienes todo lo que necesitas para dibujar tu cuadrado.

  1. Para dibujar el cuadrado, vuelve al par de llamadas encoder.beginRenderPass() y pass.end() y, a continuación, agrega estos nuevos comandos entre ellos:

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

Esto le proporciona a WebGPU toda la información necesaria para dibujar el cuadrado. Primero, usa setPipeline() para indicar con qué canalización se debe dibujar. Esto incluye los sombreadores que se usan, el diseño de los datos de vértices y otros datos de estado relevantes.

Luego, llama a setVertexBuffer() con el búfer que contiene los vértices del cuadrado. Lo llamarás con 0 porque este búfer corresponde al elemento 0 en la definición vertex.buffers de la canalización actual.

Y, por último, realiza la llamada draw() que parece extrañamente simple después de toda la configuración anterior. Lo único que debes pasar es la cantidad de vértices que se deben renderizar, los cuales se extraen de los búferes de vértices configurados actualmente y se interpretan con la canalización configurada en ese momento. Podrías codificarlo en 6, pero calcularlo a partir del array de vértices (12 números de puntos flotantes/2 coordenadas por vértice == 6 vértices) significa que, si alguna vez decidiste reemplazar el cuadrado con, por ejemplo, un círculo, hay menos que actualizar manualmente.

  1. Actualiza la pantalla y (por último) observa los resultados de todo tu arduo trabajo: un cuadrado grande de color.

Un único cuadrado rojo renderizado con WebGPU

5. Dibuja una cuadrícula

En primer lugar, tómate un momento para felicitarte. Uno de los pasos más difíciles con la mayoría de las APIs de GPU es conseguir que los primeros fragmentos de geometría aparezcan en pantalla. Todo lo que hagas aquí se puede hacer en pasos más pequeños, lo que facilita la verificación de tu progreso a medida que avanzas.

En esta sección, aprenderás lo siguiente:

  • Cómo pasar variables (llamadas uniformes) al sombreador desde JavaScript.
  • Cómo usar uniformes para cambiar el comportamiento de la renderización.
  • Cómo usar la instancia para dibujar muchas variantes diferentes de la misma geometría.

Define una cuadrícula

Para renderizar una cuadrícula, es necesario conocer una información fundamental sobre ella. ¿Cuántas celdas contiene, en ancho y en altura? Depende de ti como desarrollador, pero para simplificar un poco la tarea, trata la cuadrícula como un cuadrado (el mismo ancho y la altura) y usa un tamaño que sea la potencia de dos. (Eso facilita algunos cálculos más adelante). Con el tiempo querrás hacerla más grande, pero para el resto de esta sección, establece el tamaño de cuadrícula en 4x4, ya que facilita la demostración de algunos de los cálculos que se usan en esta sección. Escala después.

  • Para definir el tamaño de la cuadrícula, agrega una constante a la parte superior de tu código JavaScript.

index.html

const GRID_SIZE = 4;

A continuación, debes actualizar la forma en que renderizas tu cuadrado para que pueda caber GRID_SIZE veces GRID_SIZE de ellos en el lienzo. Eso significa que el cuadrado debe ser mucho más pequeño y debe haber muchos.

Ahora bien, una forma que podrías enfocar esto es aumentar el tamaño del búfer de vértices de manera significativa y definir GRID_SIZE veces GRID_SIZE cuadrados dentro de él con el tamaño y la posición correctos. De hecho, el código no estaría tan mal. Solo un par de bucles y matemáticas. Sin embargo, eso tampoco implica un buen uso de la GPU y una mayor cantidad de memoria de la necesaria para lograr el efecto. En esta sección, se analiza un enfoque más optimizado de la GPU.

Crea un búfer uniforme

Primero, debes comunicarle al sombreador el tamaño de cuadrícula que elegiste, ya que lo usa para cambiar la forma en que se muestran los elementos. Podrías codificar el tamaño en el sombreador, pero eso significa que, cada vez que quieras cambiar el tamaño de la cuadrícula, tendrás que volver a crear el sombreador y renderizar la canalización, lo que es costoso. Una mejor manera de proporcionar el tamaño de cuadrícula al sombreador como uniformes.

Ya aprendiste que se pasa un valor diferente del búfer de vértices a cada invocación de un sombreador de vértices. Un uniforme es un valor de un búfer que es igual para cada invocación. Son útiles para comunicar valores comunes de una geometría (como su posición), un fotograma completo de animación (como la hora actual) o incluso toda la vida útil de la app (como las preferencias del usuario).

  • Para crear un búfer uniforme, agrega el siguiente 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);

Esto le parecerá muy familiar, porque es casi el mismo código que usó antes para crear el búfer de vértices. Esto se debe a que los uniformes se comunican a la API de WebGPU a través de los mismos objetos GPUBuffer que los vértices, con la diferencia principal de que esta vez usage incluye GPUBufferUsage.UNIFORM en lugar de GPUBufferUsage.VERTEX.

Accede a los uniformes en un sombreador

  • Para definir un uniforme, agrega el siguiente código:

index.html (llamada 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 

Esto define un uniforme en tu sombreador llamado grid, que es un vector de número de punto flotante 2D que coincide con el array que acabas de copiar en el búfer uniforme. También especifica que el uniforme está vinculado a @group(0) y @binding(0). Aprenderás lo que significan esos valores en un momento.

Luego, en cualquier otra parte del código del sombreador, puedes usar el vector de cuadrícula según sea necesario. En este código, se divide la posición del vértice por el vector de cuadrícula. Dado que pos es un vector 2D y grid es un vector 2D, WGSL realiza una división por componentes. En otras palabras, el resultado es el mismo que decir vec2f(pos.x / grid.x, pos.y / grid.y).

Estos tipos de operaciones vectoriales son muy comunes en los sombreadores de GPU, ya que muchas técnicas de renderización y procesamiento dependen de ellos.

Esto significa que, si usas un tamaño de cuadrícula de 4, el cuadrado que renderizas sería un cuarto de su tamaño original. Es perfecto si deseas colocar cuatro de ellos en una fila o columna.

Crea un grupo de vinculaciones

Sin embargo, declarar el uniforme en el sombreador no lo conecta con el búfer que creaste. Para ello, debes crear y configurar un grupo de vinculaciones.

Un grupo de vinculaciones es una colección de recursos que deseas que tu sombreador tenga acceso al mismo tiempo. Puede incluir varios tipos de búferes, como el búfer uniforme, además de otros recursos, como texturas y muestras, que no se tratan aquí y son parte comunes de las técnicas de renderización de WebGPU.

  • Para crear un grupo de vinculaciones con tu búfer uniforme, agrega el siguiente código después de crear el búfer uniforme y la canalización de renderización:

index.html

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

Además de tu label estándar, también necesitas un layout que describa qué tipos de recursos contiene este grupo de vinculaciones. Esto es un tema en el que profundizaremos en un paso más adelante, pero por el momento puedes pedirle a tu canalización el diseño del grupo de vinculaciones porque creaste la canalización con el layout: "auto". Esto hace que la canalización cree diseños de grupos de vinculaciones automáticamente a partir de las vinculaciones que declaraste en el código del sombreador. En este caso, debes pedirle a getBindGroupLayout(0), en el que 0 corresponde al @group(0) que escribiste en el sombreador.

Después de especificar el diseño, debes proporcionar un array de entries. Cada entrada es un diccionario tiene, al menos, los siguientes valores:

  • binding, que corresponde al valor de @binding() que escribiste en el sombreador. En este caso, 0.
  • resource, que es el recurso real que deseas exponer a la variable en el índice de vinculación especificado. En este caso, el búfer uniforme.

La función muestra un GPUBindGroup, que es un controlador inmutable y opaco. No puedes cambiar los recursos a los que apunta un grupo de vinculaciones después de su creación, aunque puedes cambiar los contenidos de estos recursos. Por ejemplo, si cambias el búfer uniforme para que contenga un nuevo tamaño de cuadrícula, esto se reflejará en las futuras llamadas de dibujo que usen este grupo de vinculaciones.

Vincula el grupo de vinculaciones

Ahora que el grupo se creó, aún debes decirle a WebGPU que lo use cuando dibuja. Por suerte, esto es bastante simple.

  1. Regresa al pase de renderización y agrega esta línea nueva antes del método draw():

index.html

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

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

pass.draw(vertices.length / 2);

El 0 que se pasa como primer argumento corresponde a @group(0) en el código del sombreador. Dices que cada @binding que es parte de @group(0) usa los recursos de este grupo de vinculaciones.

Ahora, el búfer uniforme se expone a tu sombreador.

  1. Actualiza la página y, luego, deberías ver lo siguiente:

Un pequeño cuadrado rojo en el centro de un fondo azul oscuro.

¡Hip, hip, hurra! Ahora, tu cuadrado es un cuarto del tamaño que tenía antes. Esto no es mucho, pero muestra que tu uniforme se aplicó y que el sombreador ahora puede acceder al tamaño de la cuadrícula.

Manipula la geometría en el sombreador

Ahora que puedes hacer referencia al tamaño de la cuadrícula en el sombreador, puedes empezar a manipular la geometría que renderizas para que se adapte al patrón de cuadrícula deseado. Para ello, considera exactamente lo que deseas lograr.

Debes dividir tu lienzo de forma conceptual en celdas individuales. Para mantener la convención de que el eje X aumenta a medida que te mueves hacia la derecha y el eje Y aumenta a medida que te mueves hacia arriba, supongamos que la primera celda se encuentra en la esquina inferior izquierda del lienzo. Esto te da un diseño similar al siguiente, con tu geometría cuadrada actual en el centro:

Ilustración de la cuadrícula conceptual en la que se dividirá el espacio de coordenadas normalizadas del dispositivo cuando se visualice cada celda con la geometría cuadrada renderizada actualmente en su centro.

Tu desafío es encontrar un método en el sombreador que te permita posicionar la geometría cuadrada en cualquiera de esas celdas según las coordenadas de la celda.

Primero, puedes ver que el cuadrado no está bien alineado con ninguna de las celdas porque se definió para rodear el centro del lienzo. Sería conveniente que el cuadrado se desplazara media celda para que quedara bien alineado dentro de ellos.

Una forma de solucionar esto es actualizar el búfer del vértice del cuadrado. Si mueves los vértices de modo que la esquina inferior derecha esté, por ejemplo, (0.1, 0.1) en lugar de (-0.8, -0.8), deberías mover este cuadrado para alinearlo mejor con los límites de la celda. Sin embargo, como tienes control total sobre cómo se procesan los vértices en tu sombreador, es muy fácil desplazarlos con el código del sombreador.

  1. Modifica el módulo de sombreador de vértices con el siguiente código:

index.html (llamada 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);
}

Esto mueve cada vértice hacia arriba y hacia la derecha de a uno (que, recuerda, es la mitad del espacio de recorte) antes de dividirlo por el tamaño de la cuadrícula. El resultado es un cuadrado alineado con la cuadrícula justo fuera del origen.

Una visualización del lienzo conceptualmente dividido en una cuadrícula de 4x4 con un cuadrado rojo en la celda (2, 2)

A continuación, debido a que el sistema de coordenadas de tu lienzo se ubica (0, 0) en el centro y (-1, -1) en la esquina inferior izquierda y quieres que (0, 0) esté en la parte inferior izquierda, debes trasladar la posición de tu geometría (-1, -1) después de dividirla por el tamaño de la cuadrícula con el objetivo de moverla a esa esquina.

  1. Traslada la posición de la geometría de la siguiente manera:

index.html (llamada 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); 
}

Y ahora tu cuadrado está bien posicionado en la celda (0, 0).

Una visualización del lienzo conceptualmente dividido en una cuadrícula de 4x4 con un cuadrado rojo en la celda (0, 0)

¿Qué pasa si deseas colocarlo en una celda diferente? Compruébalo a través de la declaración de un vector de cell en el sombreador y propágalo con un valor estático como let cell = vec2f(1, 1).

Si lo agregas a gridPos, se deshace el - 1 en el algoritmo, por lo que no es lo que quieres. En su lugar, te recomendamos mover el cuadrado solo una unidad de cuadrícula (un cuarto del lienzo) para cada celda. Parece que necesitas hacer otra división por grid.

  1. Cambia el posicionamiento de la cuadrícula de la siguiente manera:

index.html (llamada 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);
}

Si ahora actualizas, verás lo siguiente:

Una visualización del lienzo conceptualmente dividido en una cuadrícula de 4x4 con un cuadrado rojo centrado entre celda y celda (0, 0), celda (0, 1), celda (1, 0) y celda (1, 1)

Mmm. No es exactamente lo que querías.

Esto se debe a que, como las coordenadas del lienzo van de -1 a +1, en realidad son 2 unidades. Esto significa que si desea mover un cuarto de vértice del lienzo, debe moverlo a 0.5 unidades. Es un error fácil de cometer cuando se razona con coordenadas GPU. Por suerte, la solución es igual de fácil.

  1. Multiplica tu desplazamiento por 2 de la siguiente manera:

index.html (llamada 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);
}

Esto te da exactamente lo que deseas.

Una visualización del lienzo conceptualmente dividido en una cuadrícula de 4x4 con un cuadrado rojo en la celda (1, 1)

La captura de pantalla se ve de la siguiente manera:

Captura de pantalla de un cuadrado rojo sobre fondo azul oscuro. El cuadrado rojo se dibuja en la misma posición que en el diagrama anterior, pero sin la cuadrícula superpuesta.

Además, ahora puedes establecer cell en cualquier valor dentro de los límites de la cuadrícula y, luego, actualizar para ver la renderización de cuadrados en la ubicación deseada.

Dibuja instancias

Ahora que puedes colocar el cuadrado en el lugar que desees con unos pocos cálculos matemáticos, el siguiente paso es renderizar un cuadrado en cada celda de la cuadrícula.

Una forma de hacerlo es escribir las coordenadas de las celdas en un búfer uniforme y, luego, llamar a draw una vez por cada cuadrado de la cuadrícula y actualizar el uniforme cada vez. Sin embargo, eso sería muy lento, ya que la GPU debe esperar a que JavaScript escriba la coordenada nueva en cada ocasión. Una de las claves para obtener un buen rendimiento de la GPU es minimizar el tiempo que se ocupa en otras partes del sistema.

En su lugar, puedes usar una técnica llamada instanciación. La instancia es una forma de indicarle a la GPU que dibuje varias copias de la misma geometría con una sola llamada a draw, lo que es mucho más rápido que llamar a draw una vez por cada copia. Cada copia de la geometría se conoce como una instancia.

  1. Para indicarle a la GPU que deseas tener suficientes instancias de tu cuadrado con el objetivo de llenar la cuadrícula, agrega un argumento a la llamada de dibujo existente:

index.html

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

Esto le indica al sistema que deseas que dibuje los seis (vertices.length / 2) vértices de tu cuadrado 16 (GRID_SIZE * GRID_SIZE) veces. Sin embargo, si actualizas la página, seguirás viendo lo siguiente:

Una imagen idéntica a la del diagrama anterior, para indicar que nada cambió.

¿Por qué? Porque dibujas los 16 cuadrados de ese cuadrado en el mismo lugar. Es necesario tener alguna lógica adicional en el sombreador que reposiciona la geometría según la instancia.

En el sombreador, además de los atributos de vértice, como pos, que provienen del búfer de vértices, también puede acceder a lo que se conoce como valores integrados de WGSL. Estos son valores que calcula WebGPU y uno de esos valores es el instance_index. El instance_index es un número de 32 bits sin firma de 0 a number of instances - 1 que puedes usar como parte de la lógica del sombreador. Su valor es el mismo para cada vértice procesado que sea parte de la misma instancia. Eso significa que se llama al sombreador de vértices seis veces con un instance_index de 0, una vez para cada posición en el búfer de vértices. Luego, seis veces más con un instance_index de 1, a continuación, seis veces más con instance_index de 2, etcétera.

Para ver esto en acción, debes agregar el instance_index integrado a las entradas del sombreador. Hazlo de la misma manera que la posición, pero en lugar de etiquetarlo con un atributo @location, usa @builtin(instance_index) y, luego, asígnale el nombre que desees. (Puedes llamarlo instance para que coincida con el código de ejemplo). Luego, úsalo como parte de la lógica del sombreador.

  1. Usa instance en lugar de las coordenadas de las celdas:

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

Si actualizas ahora, verás que tienes más de un cuadrado. Pero no puedes ver los 16.

Cuatro cuadrados rojos en diagonal de la esquina inferior izquierda a la esquina superior derecha sobre fondo azul oscuro.

Esto se debe a que las coordenadas de celda que se generan son (0, 0), (1, 1), (2, 2)… (15, 15), pero solo las primeras cuatro caben en el lienzo. Para crear la cuadrícula que deseas, debes transformar el instance_index de modo que cada índice se asigne a una celda única en la cuadrícula, de la siguiente manera:

Visualización del lienzo dividido de forma conceptual en una cuadrícula de 4x4 en la que cada celda corresponde también a un índice de instancia lineal.

Los cálculos son bastante sencillos. Para el valor X de cada celda, deseas el módulo del instance_index y el ancho de la cuadrícula, que puedes realizar en WGSL con el operador %. Y para cada valor Y de cada celda, quieres que instance_index se divida por el ancho de la cuadrícula, descartando cualquier resto fraccionario. Puedes hacerlo con la función floor() de WGSL.

  1. Cambia los cálculos de la siguiente manera:

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

Después de hacer esa actualización del código, tienes la tan esperada cuadrícula de cuadrados.

Cuatro filas de cuatro columnas de cuadrados rojos sobre el fondo azul oscuro.

  1. Ahora que ya funciona, vuelve a aumentar el tamaño de la cuadrícula.

index.html

const GRID_SIZE = 32;

32 filas de 32 columnas de cuadrados rojos sobre fondo azul oscuro.

Listo. En realidad, puedes hacer que esta cuadrícula sea muy grande ahora y tu GPU promedio la administrará sin problemas. Dejarás de ver los cuadrados individuales mucho antes de que te encuentres con cuellos de botella en el rendimiento de la GPU.

6. Crédito extra: Haz que sea más colorido

En este punto, puedes pasar fácilmente a la siguiente sección, ya que dispones de las bases para el resto del codelab. Pero aunque la cuadrícula de cuadrados del mismo color es útil, no es muy emocionante, ¿verdad? Por suerte, puedes hacerlo un poco más interesante con unos cuantos cálculos matemáticos y código de sombreadores.

Usa structs en sombreadores

Hasta ahora, pasaste un fragmento de datos del sombreador de vértices: la posición transformada. Sin embargo, puedes mostrar muchos más datos del sombreador de vértices y, luego, usarlos en el sombreador de fragmentos.

La única forma de pasar datos fuera del sombreador de vértices es mostrarlo. Siempre se requiere un sombreador de vértices para mostrar una posición, por lo que, si deseas mostrar cualquier otro dato junto con él, debes colocarlo en un struct. Los STRUCTS en WGSL son tipos de objetos con nombre que contienen una o más propiedades con nombre. Las propiedades también se pueden marcar con atributos como @builtin y @location. Los declaras fuera de cualquier función y, luego, puedes pasar instancias de ellos dentro y fuera de las funciones, según sea necesario. Por ejemplo, considera tu sombreador de vértices actual:

index.html (llamada 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);
}
  • Expresa lo mismo con structs para la entrada y salida de la función:

index.html (llamada 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;
}

Ten en cuenta que esto requiere que consultes la posición de entrada y el índice de instancia con input y que el struct que muestres primero debe declararse como una variable y tener sus propiedades individuales configuradas. En este caso, no genera demasiada diferencia y, de hecho, hace que el sombreador funcione un poco más, pero a medida que los sombreadores se hacen más complejos, usar structs puede ser una excelente manera de organizar tus datos.

Pasa datos entre las funciones de vértice y de fragmento

Recuerda que la función @fragment es lo más simple posible:

index.html (llamada createShaderModule)

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

No recibes ningún dato de entrada y pasas un color sólido (rojo) como salida. Sin embargo, si el sombreador supiera más sobre la geometría que colorea, podrías usar los datos adicionales para que todo sea un poco más interesante. Por ejemplo, ¿qué sucede si deseas cambiar el color de cada cuadrado según la coordenada de las celdas? La etapa @vertex sabe qué celda se renderiza y solo debes pasarla a la etapa @fragment.

Para pasar cualquier dato entre las etapas del vértice y del fragmento, debes incluirlo en un struct de salida con una @location de nuestra elección. Dado que deseas pasar la coordenada de la celda, agrégala al struct VertexOutput anterior y, luego, configúralo en la función @vertex antes de mostrarla.

  1. Cambia el valor que se muestra en tu sombreador de vértices de la siguiente manera:

index.html (llamada 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. En la función @fragment, agrega un argumento con el mismo @location para recibir el valor. (No es necesario que los nombres coincidan, pero es más fácil hacer un seguimiento si lo hacen).

index.html (llamada 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. También puedes usar una struct en su lugar:

index.html (llamada createShaderModule)

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

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. Otra alternativa**,** dado que en tu código las dos funciones se definen en el mismo módulo de sombreador, es reutilizar la struct de salida de la etapa @vertex. De este modo, la transmisión de valores resulta sencilla, ya que los nombres y las ubicaciones son coherentes por naturaleza.

index.html (llamada createShaderModule)

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

Sin importar el patrón que elijas, el resultado es que tienes acceso al número de celda en la función @fragment y puedes usarlo para influir en el color. Con cualquiera de los códigos anteriores, el resultado se ve de la siguiente manera:

Una cuadrícula de cuadrados en la que la columna de la izquierda es verde, la fila inferior es roja y todos los demás cuadrados son amarillos.

Sin duda, ahora hay más colores, pero su aspecto no es muy agradable. Quizá te preguntes por qué solo la filas izquierda y la inferior son diferentes. Esto se debe a que los valores de color que devuelve la función @fragment esperan que cada canal se encuentre dentro del rango de 0 a 1 y los valores que se encuentren fuera de ese rango se restringirán. Por otro lado, los valores de las celdas varían de 0 a 32 en cada eje. Lo que ves aquí es que la primera fila y columna alcanzan ese valor 1 completo de inmediato en el canal de color rojo o verde y cada celda que sigue se sujeta al mismo valor.

Si deseas una transición más fluida entre los colores, debes mostrar un valor fraccionario para cada canal de color. Lo ideal sería que empieces en cero y finalices en uno en cada eje, lo que significa otra división por grid.

  1. Cambia el sombreador de fragmentos de la siguiente manera:

index.html (llamada createShaderModule)

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

Actualiza la página y verás que el nuevo código te brinda un gradiente mucho más agradable en toda la cuadrícula.

Una cuadrícula de cuadrados que cambian de negro a rojo, a verde y a amarillo en diferentes esquinas.

Si bien esto es una mejora, ahora hay una desafortunada esquina oscura en la esquina inferior izquierda, en la que la cuadrícula se vuelve negra. Cuando empieces a realizar la simulación del Juego de la Vida, una sección difícil de ver de la cuadrícula oscurecerá lo que suceda. Sería bueno aclararlo.

Por suerte, tienes un canal de color completo sin usar, que es azul y que puedes usar. El efecto ideal es que el azul sea más brillante cuando los demás colores estén más oscuros y que se vaya apagando a medida que los demás colores aumenten de intensidad. La forma más sencilla de hacerlo es que el canal inicie en 1 y reste uno de los valores de la celda. Puede ser c.x o c.y. Prueba los dos y, luego, elige el que prefieras.

  1. Agrega colores más brillantes al sombreador de fragmentos de la siguiente manera:

Llamada createShaderModule

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

El resultado se ve bastante bien.

Una cuadrícula de cuadrados que pasan del rojo, al verde, al azul y al amarillo en diferentes esquinas.

Este no es un paso fundamental. Sin embargo, dado que se ve mejor, se incluye en el archivo fuente del punto de control correspondiente y el resto de las capturas de pantalla de este codelab reflejan esta cuadrícula más colorida.

7. Administra el estado de la celda

A continuación, debes controlar qué celdas de la cuadrícula se renderizan, en función de algún estado que se almacena en la GPU. Esto es importante para la simulación final.

Todo lo que necesitas es un indicador de activación para cada celda, de modo que cualquier opción que te permita almacenar un gran array de casi cualquier tipo de valor funcione. Podrías pensar que este es otro caso de uso de búferes uniformes. Si bien podrías hacer que eso funcione, es más difícil porque los búferes uniformes tienen un tamaño limitado, no pueden admitir arrays de tamaño dinámico (debes especificar el tamaño del array en el sombreador) y no pueden escribirlos a través de sombreadores de cómputos. Ese último elemento es el más problemático, ya que quieres hacer la simulación del Juego de la vida en la GPU en un sombreador de cómputos.

Por suerte, hay otra opción de búfer que evita todas esas limitaciones.

Crea un búfer de almacenamiento

Los búferes de almacenamiento son búferes de uso general que se pueden leer y escribir en sombreadores de cómputos y leer en sombreadores de vértices. Pueden ser muy grandes y no necesitan un tamaño declarado específico en un sombreador, lo que los hace mucho más similares a la memoria general. Eso es lo que se usa para almacenar el estado de la celda.

  1. Con el objetivo de crear un búfer de almacenamiento para el estado tu celda, usa lo que probablemente sea a partir de ahora: un fragmento del código de creación de búfer de aspecto 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,
});

Al igual que con los búferes de vértice y uniforme, llama a device.createBuffer() con el tamaño adecuado y, luego, asegúrate de especificar el uso de GPUBufferUsage.STORAGE esta vez.

Puedes propagar el búfer de la misma manera que antes si completas el TypedArray del mismo tamaño con valores y, luego, llamas a device.queue.writeBuffer(). Puesto que deseas ver el efecto de tu búfer en la cuadrícula, empieza por completarla con algo predecible.

  1. Activa cada tercera celda con el siguiente 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);

Lee el búfer de almacenamiento en el sombreador

A continuación, actualiza el sombreador para ver el contenido del búfer de almacenamiento antes de renderizar la cuadrícula. Es muy similar a como se agregaron los uniformes anteriormente.

  1. Actualiza el sombreador con el siguiente código:

index.html

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

Primero, agrega el punto de vinculación, que se encuentra justo debajo del uniforme de la cuadrícula. Te recomendamos conservar el mismo @group que el uniforme grid, pero el número @binding debe ser diferente. El tipo var es storage, con el objetivo de reflejar el tipo diferente de búfer y, en lugar de un solo vector, el tipo que asignas para cellState es un array de los valores u32, con el objetivo de que coincida con Uint32Array en JavaScript.

A continuación, en el cuerpo de tu función @vertex, consulta el estado de la celda. Debido a que el estado se almacena en un array plano en el búfer de almacenamiento, puedes usar instance_index para buscar el valor de la celda actual.

¿Cómo se desactiva una celda si el estado indica que está inactiva? Bueno, como los estados inactivo y activo que obtienes del array son 1 o 0, puedes escalar la geometría según el estado activo. Si lo escalas en 1, se deja solo la geometría y si la escalas en 0, la geometría se contrae en un solo punto, que la GPU descarta.

  1. Actualiza el código de sombreador para ajustar la posición según el estado activo de la celda. El valor de estado debe convertirse en un f32 para cumplir con los requisitos de seguridad de tipo de 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;
}

Agrega el búfer de almacenamiento al grupo de vinculación

Para ver el estado de la celda, agrega el búfer de almacenamiento a un grupo de vinculación. Debido a que es parte del mismo @group que el búfer uniforme, agrégalo al mismo grupo de vinculación en el código JavaScript.

  • Agrega el búfer de almacenamiento de la siguiente manera:

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 }
  }],
});

Asegúrate de que binding de la entrada nueva coincida con el @binding() del valor correspondiente en el sombreador.

Una vez hecho esto, deberías poder actualizar y ver el patrón en la cuadrícula.

Franjas diagonales de cuadrados de colores que van desde abajo a la izquierda hasta arriba a la derecha sobre un fondo azul oscuro.

Usa el patrón de búfer de pimpón

La mayoría de las simulaciones, como la que compilas, suelen usar al menos dos copias de su estado. En cada paso de la simulación, leen de una copia del estado y escriben en la otra. Luego, en el siguiente paso, lo giran y leen el estado en el que escribieron antes. Por lo general, se lo conoce como patrón de pimpón porque la versión más actualizada del estado rebota entre las copias de estado en cada paso.

¿Por qué es eso necesario? Veamos un ejemplo simplificado: imagina que quieres escribir una simulación muy simple en la que mueves los bloques activos a la derecha de una celda en cada paso. Para facilitar la comprensión, debes definir tus datos y la simulación en 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.

Sin embargo, si ejecutas ese código, la celda activa se mueve hasta el final del array en un solo paso. ¿Por qué? Porque sigue actualizando el estado en su lugar, por lo que mueves la celda activa hacia la derecha y, luego, observas la siguiente celda. Está activo. Vuelve a moverlo hacia la derecha. El hecho de modificar los datos al mismo tiempo que se observan corrompe los resultados.

Con el patrón pimpón, te aseguras de que siempre realizas el siguiente paso de la simulación con solo los resultados del último paso.

// 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. Usa este patrón en tu propio código. Para ello, actualiza tu asignación de búfer de almacenamiento con el objetivo de crear dos búferes 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 visualizar la diferencia entre los dos búferes, complétalos con diferentes datos:

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 los diferentes búferes de almacenamiento en la renderización, actualiza los grupos de vinculación para que también tengan dos 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] }
    }],
  })
];

Configura un bucle de renderización

Hasta ahora, solo llevaste a cabo una actualización por página actualizada, pero ahora deseas mostrar los datos que se actualizan con el tiempo. Para ello, necesitas un bucle de renderización simple.

Un bucle de renderización es un bucle infinito que se repite para dibujar tu contenido en el lienzo en un intervalo determinado. Muchos juegos y otro contenido que desea animar sin problemas usan la función requestAnimationFrame() para programar devoluciones de llamada a la misma frecuencia con la que se actualiza la pantalla (60 veces por segundo).

Esta app también puede usar esa función, pero, en este caso, quizás desees que las actualizaciones se realicen en pasos más largos para que puedas seguir más fácilmente lo que hace la simulación. Administra el bucle para controlar la velocidad a la que se actualiza la simulación.

  1. Primero, elige una velocidad de actualización para nuestra simulación (de 200 ms es aceptable, pero puedes avanzar más o menos rápido si lo deseas) y, luego, realiza un seguimiento de la cantidad de pasos de simulación completados.

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. A continuación, mueve todo el código que usas en la actualidad para el renderizado a una nueva función. Programa esa función para que se repita en el intervalo deseado con setInterval(). Asegúrate de que la función también actualice el recuento de pasos y úsalo para elegir cuál de los dos grupos de vinculaciones vincular.

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

Ahora, cuando ejecutes la app, verás que el lienzo cambia entre los dos búferes de estado que creaste.

Franjas diagonales de cuadrados de colores que van desde abajo a la izquierda hasta arriba a la derecha sobre un fondo azul oscuro. Franjas verticales de cuadrados de colores sobre fondo azul oscuro.

Con eso ya casi terminas de renderizar los elementos. Ya está todo listo para que muestres el resultado de la simulación del Juego de la vida que compilarás en el siguiente paso, en el que finalmente empezarás a usar sombreadores de cómputos.

Claro que hay mucho más en las capacidades de renderizado de WebGPU que la pequeña porción que exploraste aquí, pero el resto está fuera del alcance de este codelab. Con suerte, te permitirá saber cómo funciona la renderización de WebGPU, aunque eso ayuda a explorar técnicas más avanzadas como la renderización 3D más fácil de comprender.

8. Ejecuta la simulación

Ahora, la última gran pieza del rompecabezas: realizar la simulación del Juego de la vida en un sombreador de cómputos.

Usa sombreadores de cómputos, ¡al fin!

A lo largo de este codelab aprendiste de forma abstracta sobre los sombreadores de cómputos, pero ¿qué son en realidad?

Un sombreador de cómputos es similar a los sombreadores de vértices y fragmentos en el sentido de que están diseñados para ejecutarse con paralelismo extremo en la GPU, pero a diferencia de las otras dos etapas del sombreador, no tienen un conjunto específico de entradas y salidas. Lees y escribes datos exclusivamente de las fuentes que elijas, como búferes de almacenamiento. Esto significa que, en lugar de ejecutar una vez para cada vértice, instancia o píxel, debes indicarle cuántas invocaciones de la función del sombreador deseas. Luego, cuando ejecutes el sombreador, se te indicará qué invocación se procesa y podrás decidir a qué datos accederás y qué operaciones realizarás desde allí.

Los sombreadores de cómputos deben crearse en un módulo de sombreador, al igual que los sombreadores de vértices y fragmentos, por lo que debes agregarlos a tu código para empezar. Como puedes suponer, dada la estructura de los otros sombreadores que implementaste, la función principal del sombreador de cómputos debe marcarse con el atributo @compute.

  1. Crea un sombreador de cómputos con el siguiente 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() {

    }`
});

Debido a que las GPU se usan con frecuencia para gráficos 3D, los sombreadores de cómputos se estructuran de modo que puedas solicitar que se invoque el sombreador una cantidad específica de veces en los ejes X, Y y Z. Esto te permite enviar fácilmente un trabajo que se ajusta a una cuadrícula 2D o 3D, lo que es ideal para tu caso de uso. Debes llamar a este sombreador GRID_SIZE veces GRID_SIZE veces, una vez para cada celda de tu simulación.

Debido a la naturaleza de la arquitectura del hardware de GPU, esta cuadrícula se divide en grupos de trabajo. Un grupo de trabajo tiene un tamaño X, Y y Z y, aunque los tamaños pueden ser 1 cada uno, con frecuencia hay beneficios de rendimiento que permiten aumentar el tamaño de los grupos de trabajo. Para tu sombreador, elige un tamaño de grupo de trabajo un tanto arbitrario de 8 veces 8. Esto es útil para hacer un seguimiento en tu código JavaScript.

  1. Define una constante para el tamaño de tu grupo de trabajo, como se muestra a continuación:

index.html

const WORKGROUP_SIZE = 8;

También debes agregar el tamaño del grupo de trabajo a la función de sombreador, lo que puedes hacer con los literales de plantilla de JavaScript para que puedas usar fácilmente la constante que acabas de definir.

  1. Agrega el tamaño del grupo de trabajo a la función del sombreador de la siguiente manera:

index.html (llamada Compute createShaderModule)

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

}

Esto le indica al sombreador que el trabajo realizado con esta función se realiza en grupos (8 x 8 x 1). (Cualquier eje que dejes se establece de forma predeterminada en 1, aunque debes especificar al menos el eje X).

Al igual que con las otras etapas del sombreador, hay una variedad de valores @builtin que puedes aceptar como entrada en tu función de sombreador de cómputos para decirte en qué invocación estás y decidir qué trabajo debes hacer.

  1. Agrega un valor @builtin, como el siguiente:

index.html (llamada Compute createShaderModule)

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

}

Pasa el global_invocation_id integrado, que es un vector tridimensional de números enteros sin firma que te indica dónde estás en la cuadrícula de invocaciones del sombreador. Debes ejecutar este sombreador una vez para cada celda de tu cuadrícula. Obtienes números como (0, 0, 0), (1, 0, 0), (1, 1, 0)… hasta (31, 31, 0), lo que significa que puedes tratarlo como el índice de celda que usarás.

Los sombreadores de cómputos también pueden usar uniformes, que se usan igual que en los sombreadores de vértice y de fragmento.

  1. Usa un uniforme con tu sombreador de cómputos para decirte el tamaño de la cuadrícula de la siguiente manera:

index.html (llamada Compute createShaderModule)

@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) {

}

Al igual que en el sombreador de vértices, también debes exponer el estado de la celda como un búfer de almacenamiento. Pero en este caso, necesitas dos. Debido a que los sombreadores de cómputos no tienen un resultado obligatorio, como una posición de vértice o el color de fragmento, la escritura de valores en un búfer o una textura de almacenamiento es la única forma de obtener resultados de ellos. Usa el método de pimpón que aprendiste antes. Tienes un búfer de almacenamiento que se alimenta en el estado actual de la cuadrícula y otro en el que escribe el estado nuevo de la cuadrícula.

  1. Puede exponer la entrada y el estado de salida de la celda como búferes de almacenamiento, de la siguiente manera:

index.html (llamada Compute createShaderModule)

@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) {

}

Ten en cuenta que el primer búfer de almacenamiento se declara con var<storage>, por lo que es de solo lectura, pero el segundo se declara con var<storage, read_write>. De este modo, puedes leer y escribir en el búfer y usarlo como resultado para tu sombreador de cómputos. (No hay modo de almacenamiento de solo escritura en WebGPU).

A continuación, debes tener una forma de asignar tu índice de celdas al array de almacenamiento lineal. Básicamente, es lo opuesto a lo que hiciste en el sombreador de vértices, en el que tomaste el instance_index lineal y lo asignaste a una celda de cuadrícula en 2D. (Como recordatorio, que el algoritmo para realizar esta acción era vec2f(i % grid.x, floor(i / grid.x)).)

  1. Escribe una función para ir en la dirección opuesta. Toma el valor Y de la celda, lo multiplica por el ancho de la cuadrícula y, luego, agrega el valor X de la celda.

index.html (llamada Compute createShaderModule)

@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 último, para ver que funcione, implementa un algoritmo muy simple: si una celda está activada, se apaga y viceversa. Aún no es el Juego de la vida, pero basta con mostrar que el sombreador de cómputos funciona.

  1. Agrega el algoritmo simple, como este:

index.html (llamada Compute createShaderModule)

@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;
  }
}

Y eso es todo para tu sombreador de cómputos, por ahora. Sin embargo, antes de que puedas ver los resultados, debes realizar algunos cambios más.

Usa diseños de grupos y canalizaciones de vinculaciones

Un aspecto que puede observarse en el sombreador anterior es que usa en gran medida las mismas entradas (uniformes y búferes de almacenamiento) que la canalización de renderizaciones. Entonces, podría pensar que simplemente puede usar los mismos grupos de vinculaciones y terminar con eso, ¿verdad? La buena noticia es que puedes hacerlo. Para hacerlo, solo se necesita un poco más de configuración manual.

Cada vez que creas un grupo de vinculaciones, debes proporcionar un GPUBindGroupLayout. Antes, obtenías ese diseño llamando a getBindGroupLayout()en la canalización de renderizaciones, que, a su vez, lo creaba automáticamente porque proporcionaste layout: "auto" cuando la creaste. Ese enfoque funciona bien cuando se usa una sola canalización, pero si hay varias canalizaciones que quieren compartir recursos, es necesario crear el diseño de forma explícita y, luego, proporcionarlo al grupo de vinculaciones y a las canalizaciones.

Para entender por qué, considera lo siguiente: en las canalizaciones de renderización, debes usar un búfer uniforme y único, pero en el sombreador de cómputos que acabas de escribir, necesitas un segundo búfer de almacenamiento. Debido a que los dos sombreadores usan los mismos valores de @binding para el búfer uniforme y el primero, puedes compartirlos entre las canalizaciones y la canalización de renderización ignora el segundo búfer de almacenamiento, que no usa. Deseas crear un diseño que describa todos los recursos presentes en el grupo de vinculaciones, no solo los que usan una canalización específica.

  1. Para crear ese diseño, llama a 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
  }]
});

Su estructura es similar a la de crear el grupo de vinculaciones, ya que describe una lista de elementos entries. La diferencia es que describes qué tipo de recurso debe ser la entrada y cómo se usa en lugar de proporcionar el recurso en sí.

En cada entrada, debes proporcionar el número binding para el recurso, que (como aprendiste cuando creaste el grupo de vinculaciones) coincide con el valor @binding en los sombreadores. También proporcionas las marcas visibility, que son GPUShaderStage que indican qué etapas del sombreador pueden usar el recurso. Deseas acceder al búfer uniforme y al primer búfer de almacenamiento en los vértices y sombreadores de cómputos, pero el segundo búfer de almacenamiento solo necesita ser accesible en los sombreadores de cómputos.

Por último, indica que tipo de recursos se usa. Esta es una clave de diccionario diferente, según lo que necesites exponer. Aquí, los tres recursos son búferes, por lo que debes usar la clave buffer con el objetivo de definir las opciones para cada uno. En otras opciones se incluyen texture o sampler, pero no las necesitas aquí.

En el diccionario del búfer, puedes establecer opciones como qué type del búfer se usa. El valor predeterminado es "uniform", por lo que puedes dejar el diccionario vacío para la vinculación 0. (Sin embargo, al menos debes establecer buffer: {}, para que la entrada se identifique como búfer). La vinculación 1 recibe un tipo de "read-only-storage" porque no lo usas con acceso read_write en el sombreador y la vinculación 2 tiene un tipo de "storage" porque lo usas con acceso read_write.

Una vez que se crea el bindGroupLayout, puedes pasarlo cuando creas tus grupos de vinculaciones en lugar de consultar el grupo de vinculación desde la canalización. Si lo haces, deberás agregar una nueva entrada de búfer de almacenamiento a cada grupo de vinculaciones para que coincida con el diseño que acabas de definir.

  1. Actualiza la creación del grupo de vinculaciones de la siguiente manera:

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] }
    }],
  }),
];

Ahora que se actualizó el grupo de vinculaciones para usar este diseño de grupo de vinculaciones explícito, debes actualizar la canalización de renderización con el objetivo de usar lo mismo.

  1. Crea un elemento GPUPipelineLayout.

index.html

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

Un diseño de canalización es una lista de diseños de grupos de vinculaciones (en este caso, tiene uno) que usan una o más canalizaciones. El orden de los diseños de grupos de vinculaciones en el array debe corresponder a los atributos @group en los sombreadores. (Esto significa que bindGroupLayout está asociado con @group(0).)

  1. Una vez que tengas el diseño de la canalización, actualiza la canalización de renderización para usarla en lugar 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
    }]
  }
});

Crea la canalización de cómputos

Así como necesitas una canalización de renderización para usar tus vértices y sombreadores de fragmentos, necesitas una canalización de cómputos con el objetivo de usar tu sombreador de cómputos. Por suerte, las canalizaciones de cómputos son mucho menos complejas que las canalizaciones de renderización, ya que no tienen ningún estado para configurar, solo el sombreador y el diseño.

  • Crea una canalización de cómputos con el siguiente 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",
  }
});

Ten en cuenta que pasas en el nuevo pipelineLayout en lugar de "auto", al igual que en la canalización de renderización actualizada, lo que garantiza que tanto la canalización de renderización como la canalización de cómputos puedan usar los mismos grupos de vinculaciones.

Pases de cómputos

Esto te llevará al punto en que realmente usas la canalización de cómputos. Dado que realizas la renderización en un pase de renderización, es probable que puedas suponer que necesitas realizar un trabajo de procesamiento en un pase de cómputos. El trabajo de cómputos y renderización puede ocurrir en el mismo codificador de comando, por lo que te recomendamos modificar un poco la función updateGrid.

  1. Mueve la creación del codificador a la parte superior de la función y, luego, inicia un pase de cómputos con ella (antes del step++).

index.html

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

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count
  
// Start a render pass...

Al igual que las canalizaciones de cómputos, los pases de cómputos son mucho más fáciles de iniciar que sus equivalentes de renderización, por lo que no debes preocuparte por ningún archivo adjunto.

Deseas realizar el pase de cómputos antes de que pase de renderización, ya que permite que este pase use de inmediato los resultados más recientes del pase de cómputos. Esa es la razón por la que incrementas el recuento de step entre los pases, de modo que el búfer de salida de la canalización de cómputos se convierta en el búfer de entrada para la canalización de renderización.

  1. A continuación, configura la canalización y el grupo de vinculaciones dentro del pase de cómputos, con el mismo patrón de cambio entre los grupos de vinculaciones que realizas para el pase de renderización.

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. Por último, en lugar de dibujar como en un pase de renderización, debes enviar el trabajo al sombreador de cómputos y decirle cuántos grupos de trabajo deseas ejecutar en cada eje.

index.html

const computePass = encoder.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();

Es muy importante tener en cuenta que el número que pasas a dispatchWorkgroups() no es el número de invocaciones. En su lugar, es la cantidad de grupos de trabajo que se ejecutarán, según lo definido por el @workgroup_size en tu sombreador.

Si quieres que el sombreador se ejecute 32 x 32 veces para cubrir toda la cuadrícula y el tamaño de tu grupo de trabajo es de 8x8, debes enviar grupos de trabajo de 4x4 (4 x 8 = 32). Es por eso que divides el tamaño de la cuadrícula por el tamaño del grupo de trabajo y pasas ese valor a dispatchWorkgroups().

Ahora puedes volver a actualizar la página y deberías ver que la cuadrícula se invierte con cada actualización.

Franjas diagonales de cuadrados de colores que van desde abajo a la izquierda hasta arriba a la derecha sobre un fondo azul oscuro. Franjas diagonales de cuadrados de colores de dos cuadrados de ancho que van desde abajo a la izquierda hasta arriba a la derecha sobre un fondo azul oscuro. La inversión de la imagen anterior.

Implementa el algoritmo para el Juego de la vida

Antes de actualizar el sombreador de cómputos para implementar el algoritmo final, tienes que volver al código que inicializa el contenido del búfer de almacenamiento y actualizarlo para producir un búfer aleatorio en cada carga de página. (Los patrones regulares no crean puntos de partida muy interesantes para el Juego de la vida). Puedes elegir los valores como lo desees, pero hay una forma sencilla de empezar que ofrezca resultados razonables.

  1. Para iniciar cada celda en un estado aleatorio, actualiza la inicialización de cellStateArray al siguiente 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);

Ahora puedes implementar la lógica para la simulación del Juego de la vida. Después de todo lo que costó llegar hasta aquí, el código del sombreador puede resultar una decepción por su sencillez.

En primer lugar, es necesario saber cuántas células vecinas están activas. No es importante saber cuáles están activas, solo el recuento.

  1. Para facilitar la obtención de datos de celdas vecinas, agrega una función cellActive que devuelve el valor cellStateIn de la coordenada determinada.

index.html (llamada Compute createShaderModule)

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

La función cellActive muestra uno si la celda está activa, por lo que se suma el valor de retorno de la llamada a cellActive de las ocho celdas circundantes para saber cuántas celdas vecinas están activas.

  1. Busca la cantidad de celdas vecinas activas, de la siguiente manera:

index.html (llamada Compute createShaderModule)

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

Sin embargo, esto genera un problema menor: ¿qué sucede cuando la celda que comprueba está fuera del borde del tablero? Según la lógica de cellIndex() que tienes ahora, se desborda a la fila siguiente, anterior o se ejecuta fuera del borde del búfer.

Para el Juego de la vida, una forma común y fácil de resolver esto es hacer que las celdas en el borde de la cuadrícula traten a las celdas en el borde opuesto de la cuadrícula como sus vecinos, lo que crea un tipo de efecto envolvente.

  1. Admite el ajuste de cuadrícula con un pequeño cambio en la función cellIndex().

index.html (llamada Compute createShaderModule)

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

El uso del operador % para unir las celdas Y y X cuando se extiende más allá del tamaño de la cuadrícula, asegura que nunca accederás fuera de los límites del búfer de almacenamiento. De esa manera, puedes estar seguro de que el recuento de activeNeighbors es predecible.

Luego, aplicarás una de estas cuatro reglas:

  • Cualquier celda con menos de dos vecinos queda inactiva.
  • Cualquier celda activa con dos o tres vecinos permanece activa.
  • Cualquier celda inactiva con solo tres vecinos se activa.
  • Cualquier celda con más de tres vecinos queda inactiva.

Puedes hacer esto con una serie de sentencias IF, pero WGSL también admite sentencias SWITCH, que son una buena opción para esta lógica.

  1. Implementa la lógica del Juego de la vida de la siguiente manera:

index.html (llamada Compute createShaderModule)

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

Como referencia, la llamada final al módulo de sombreador de cómputos ahora se ve de la siguiente manera:

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

Y eso es todo. ¡Listo! Actualiza tu página y observa cómo crece tu autómata celular recién compilado.

Captura de pantalla de un ejemplo de estado de la simulación del Juego de la vida, con celdas renderizadas de colores sobre un fondo azul oscuro.

9. ¡Felicitaciones!

Creaste una versión de la simulación clásica del Juego de vida de Conway que se ejecuta completamente en tu GPU con la API de WebGPU.

¿Qué sigue?

Lecturas adicionales

Documentos de referencia