1. Introducción
Última actualización: 13/04/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 las funciones de WebGPU. Ha brindado una nueva clase de contenido web enriquecido, y los desarrolladores han creado cosas increíbles con él. Sin embargo, estaba basado en la API de OpenGL ES 2.0, lanzada en 2007, que se basaba en la API de OpenGL aún más antigua. Las GPU evolucionaron significativamente en ese tiempo, y las API nativas que se utilizan para interactuar con ellas también evolucionaron con Direct3D 12, Metal y Vulkan.
WebGPU aporta los avances de estas API modernas a la plataforma web. Se enfoca en habilitar las funciones de GPU de manera multiplataforma, a la vez que presenta una API que se siente natural en la Web y es menos detallada que algunas de las API nativas en las que se basa.
Procesamiento
Las GPU suelen asociarse con la renderización de gráficos rápidos y detallados, y WebGPU no es la excepción. Cuenta con las funciones necesarias para admitir muchas de las técnicas de renderización más populares del momento en las GPU de computadoras de escritorio y dispositivos móviles, y ofrece una ruta para agregar funciones nuevas en el futuro a medida que las funciones de hardware sigan evolucionando.
datos
Además del procesamiento, WebGPU libera el potencial de tu GPU para realizar cargas de trabajo de uso general y gran paralelismo. Estos sombreadores de procesamiento se pueden usar de manera independiente, sin ningún componente de renderización, o como una parte estrechamente integrada de tu canalización de procesamiento.
En el codelab de hoy, aprenderás a aprovechar las capacidades de procesamiento y procesamiento de WebGPU para crear un proyecto introductorio sencillo.
Qué compilarás
En este codelab, compilarás Conway's Game of Life con WebGPU. Tu app hará lo siguiente:
- Usa 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
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 geometría simple en 2D
- Cómo usar sombreadores de vértices y fragmentos para modificar lo que se dibuja
- Cómo usar sombreadores de procesamiento para realizar una simulación simple
Este codelab se enfoca en la introducción de los conceptos fundamentales de WebGPU. No está pensado para 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 posterior) en ChromeOS, macOS o Windows. WebGPU es una API multiplataforma entre navegadores, pero aún no se ha enviado a todas partes.
- Conocimientos de HTML, JavaScript y Herramientas para desarrolladores de Chrome
Conocimientos de otras API de gráficos, como WebGL, Metal, Vulkan o Direct3D, no es obligatorio, pero si tienes experiencia con ellos, es probable que notes muchas similitudes con WebGPU que pueden ayudarte a poner en marcha 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 para crear la app de WebGPU, por lo que no necesitas ningún código para comenzar. No obstante, puedes encontrar algunos ejemplos que funcionan como puntos de control en https://glitch.com/edit/#!/your-first-webgpu-app. Puedes consultarlos y consultarlos a medida que avanzas.
Usa la consola para desarrolladores.
WebGPU es una API bastante compleja con muchas reglas que garantizan un uso adecuado. Lo que es peor, 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.
Encontrarás problemas al desarrollar con GPU web, especialmente como principiante, y no hay problema. Los desarrolladores detrás 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.
Mantener la consola abierta mientras se trabaja en cualquier aplicación web siempre es útil, pero se aplica en especial aquí.
3. Inicializa WebGPU
Comienza con un <canvas>
Puedes usar WebGPU sin mostrar nada en la pantalla si solo deseas usarlo para hacer cálculos. Pero si quieres renderizar algo, 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 usa 00-starter-page.html de Glitch).
- 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>
Cómo solicitar un adaptador y un dispositivo
Ahora puedes acceder a los bits de WebGPU. En primer lugar, debes considerar que las APIs, como WebGPU, pueden tardar un poco en propagarse por todo el ecosistema web. Como resultado, un buen primer paso de precaución es verificar si el navegador del usuario puede utilizar WebGPU.
- Para verificar si existe el objeto
navigator.gpu
, que sirve como punto de entrada para WebGPU, agrega el siguiente código:
index.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
Lo ideal es informar al usuario si WebGPU no está disponible haciendo que la página recurra a un modo que no use WebGPU. (Tal vez podría usar WebGL en su lugar). Sin embargo, para los fines de este codelab, solo arrojarás un error a fin de detener la ejecución del código.
Una vez que 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.
- Para obtener un adaptador, usa el método
navigator.gpu.requestAdapter()
. Muestra una promesa, por lo que es más conveniente llamarla conawait
.
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 se muestra puede ser null
, por lo que quieres 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 utilizar 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).
Una vez que tengas un adaptador, el último paso para comenzar 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.
- Para obtener el dispositivo, llama a
adapter.requestDevice()
, que también muestra una promesa.
index.html
const device = await adapter.requestDevice();
Al 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.
Cómo configurar Canvas
Ahora que tienes un dispositivo, puedes hacer lo siguiente si quieres usarlo para mostrar algo 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
desde el lienzo llamando acanvas.getContext("webgpu")
. (es la misma llamada que usarías para inicializar contextos de Canvas 2D o WebGL, con los tipos de contexto2d
ywebgl
, respectivamente). El objetocontext
que muestra debe estar asociado con el dispositivo mediante el métodoconfigure()
, de la siguiente 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, y 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 están fuera del 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.
Afortunadamente, no tienes que preocuparte mucho por eso porque WebGPU te dice qué formato usar para tu lienzo. En casi todos los casos, debes pasar el valor que se muestra llamando a navigator.gpu.getPreferredCanvasFormat()
, como se muestra más arriba.
Borrar el lienzo
Ahora que tienes un dispositivo y se configuró el lienzo, puedes comenzar a usarlo para cambiar el contenido. Para comenzar, bórralo de un color sólido.
Para hacerlo, o casi cualquier otra cosa en WebGPU, debe proporcionar algunos comandos a la GPU que le indiquen qué hacer.
- Para ello, haz que el dispositivo cree un
GPUCommandEncoder
, que proporciona una interfaz a fin 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 procesamiento se producen cuando todas las operaciones de dibujo en WebGPU ocurren. Cada una comienza con una llamada a beginRenderPass()
, que define las texturas que reciben 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.
- Obtén la textura del contexto de lienzo que creaste antes llamando a
context.getCurrentTexture()
, que muestra una textura con un ancho y una altura de píxeles que coincide con los atributoswidth
yheight
del lienzo y elformat
especificado cuando llamaste acontext.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 comience y cuándo finalice:
- Un valor
loadOp
de"clear"
indica que deseas que se borre la textura cuando comienza 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 comience el pase de renderización, no deberás 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.
- Para finalizar el pase de renderización, agrega la siguiente llamada inmediatamente después de
beginRenderPass()
:
index.html
pass.end();
Es importante saber que el hecho de realizar estas llamadas no provoca que la GPU realice ninguna acción. Solo están grabando comandos que la GPU realizará más adelante.
- Para crear un
GPUCommandBuffer
, llama afinish()
en el codificador de comandos. El búfer de comandos es un controlador opaco para los comandos grabados.
index.html
const commandBuffer = encoder.finish();
- Envía el búfer de comandos a la GPU mediante el
queue
deGPUDevice
. La cola realiza todos los comandos de la GPU, lo que garantiza que su ejecución esté bien ordenada y se sincronice de forma adecuada. El métodosubmit()
de la cola toma un array de búferes de comandos, aunque, en este caso, solo tienes uno.
index.html
device.queue.submit([commandBuffer]);
Una vez que 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 bastante común 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 ve que cambiaste la textura actual del contexto y actualiza el lienzo para mostrar esa textura como una imagen. Si quieres volver a actualizar el contenido del lienzo, debes grabar y enviar un nuevo búfer de comando y volver a llamar a context.getCurrentTexture()
para obtener una nueva textura de un pase de renderización.
- Vuelve a cargar la página. Observa que el lienzo está lleno de negro. Felicitaciones Eso significa que creaste correctamente tu primera app de GPU.
¡Elige un color!
Sin embargo, los cuadrados negros son bastante aburridos. Así que, tómate un momento antes de continuar con la siguiente sección para personalizarlo un poco.
- En la llamada
device.beginRenderPass()
, agrega una línea nueva con unclearValue
a lacolorAttachment
, 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 objeto clearValue
le indica al pase de renderización el color que debe usar cuando realiza la operación clear
al comienzo 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 de color rojo brillante.{ r: 1, g: 0, b: 1, a: 1 }
es de color púrpura brillante.{ r: 0, g: 0.3, b: 0, a: 1 }
es de color verde oscuro.{ r: 0.5, g: 0.5, b: 0.5, a: 1 }
es gris medio.{ r: 0, g: 0, b: 0, a: 0 }
es el 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.
- Una vez que hayas elegido el color, vuelve a cargar la página. Deberías ver el color que elegiste en el recuadro.
4. Dibujar geometría
Al final de esta sección, tu app dibujará geometría simple en el lienzo: un cuadrado de color. Sin embargo, le advertimos que puede parecer mucho trabajo en resultados tan simples, pero esto se debe a que WebGPU está diseñado para renderizar muchas geometrías de manera muy eficiente. Un efecto secundario de esta eficiencia es que hacer cosas relativamente simples puede parecer inusualmente 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 dibujan las GPU
Antes de que se produzcan 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. (Puedes pasar a la sección Definición de vértices si ya conoces los conceptos básicos sobre el funcionamiento de la renderización de GPU).
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 se denomina WebGPU): puntos, líneas y triángulos. Para los fines de este codelab, solo usarás triángulos.
Las GPU funcionan casi exclusivamente 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 presentan en términos de valores X, Y y (para contenido 3D) Z que definen un punto en un sistema cartesiano de coordenadas definido por WebGPU o API similares. La estructura del sistema de coordenadas es más fácil de pensar en términos de cómo se relaciona con el lienzo de su página. Independientemente del ancho o la altura del lienzo, el borde izquierdo siempre estará en -1, en el eje X, y el borde derecho, siempre en +1, en el eje X. De manera similar, 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 Clip Space.
Inicialmente, los vértices no se definen en este sistema de coordenadas, por lo que las GPU se basan en programas pequeños llamados sombreadores de vértices para realizar los cálculos necesarios a fin 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 el funcionamiento.
A partir de 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 un 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. Es totalmente bajo tu control, lo que puede ser alentador y 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 tendrás que 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:
Para ingresar esas coordenadas a la GPU, debes colocar los valores en un TypedArray. Si todavía no lo conoces, TypedArrays es un grupo de objetos JavaScript que te permite asignar bloques contiguos de memoria e interpretar cada elemento de la serie como un tipo de datos específico. Por ejemplo, en una Uint8Array
, cada elemento del array es un solo byte sin firma. TypedArrays es excelente para enviar y recibir datos con APIs sensibles al diseño de la memoria, como WebAssembly, WebAudio y (por supuesto) WebGPU.
Para el ejemplo cuadrado, como los valores son fraccionarios, es correcta una Float32Array
.
- Cree un array que contenga todas las posiciones de los vértices del diagrama. Para ello, coloque la siguiente declaración en el código. Un buen lugar para colocarlo 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 espaciado y el comentario no tienen ningún efecto en los valores; es solo para tu comodidad y para que sea más legible. Ayuda a ver que cada par de valores conforman las coordenadas X e Y de un vértice.
Pero hay un problema. Las GPU funcionan en términos de triángulos. Eso significa que deben 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 un borde a través del medio del cuadrado.
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 dividir el cuadrado con las otras dos esquinas; no hay diferencia).
- 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.
Cómo crear un búfer de Vertex
La GPU no puede extraer vértices con datos de un array de JavaScript. Las GPU suelen tener su propia memoria altamente optimizada para la renderización, por lo que cualquier dato que quiera que use la GPU mientras se dibuja debe colocarse en esa memoria.
Para muchos valores, incluidos los datos de vértices, la memoria del GPU se administra a través de objetos GPUBuffer
. Un búfer es un bloque de memoria al que la GPU puede acceder fácilmente y que se marca para ciertos fines. Puede pensarlo un poco como un TypedArray visible para la GPU.
- A fin de crear un búfer para contener tus vértices, agrega la siguiente llamada a
device.createBuffer()
después de la definición de tu arrayvertices
.
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 la etiqueta. Cada objeto WebGPU que crees puede recibir una etiqueta opcional, ¡y definitivamente lo deseas! La etiqueta es cualquier string que quieras, siempre que te ayude a identificar el objeto. Si tienes algún problema, se utilizan en los mensajes de error que genera WebGPU para ayudarte a entender lo que salió mal.
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). Afortunadamente, TypedArrays ya calcula su byteLength, por lo 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
, en las que se combinan varias marcas con el operador |
( 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 poco claro. No puede inspeccionar (fácilmente) inspeccionar 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 inicialmente el búfer, la memoria que contiene se inicializa en cero. Existen varias maneras de cambiar su contenido, pero la más sencilla es llamar a device.queue.writeBuffer()
con un TypedArray que quieras copiar.
- 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értice
Ahora tiene un búfer con datos de vértices, pero en lo que respecta a la GPU, solo se trata de un BLOB de bytes. Si le das un poco de información, debes proporcionar más información. Debe poder brindarle a WebGPU más información sobre la estructura de los datos de vértice.
- Define la estructura de datos de vértices con un diccionario de
GPUVertexBufferLayout
:
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 relativamente fácil de desglosar.
Lo primero que debes dar es arrayStride
. Esta es la cantidad de bytes que la GPU debe omitir en el búfer 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. Sus 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 GPUVertexFormat
que describen cada tipo de datos de vértices que la GPU puede comprender. Tus vértices tienen dos 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
. ¿Ves el patrón?
A continuación, el offset
describe cuántos bytes comienzan 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, está 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.
Tenga en cuenta que, si bien ahora define estos valores, en la actualidad no los pasa a la API de WebGPU. A continuación, pero es más fácil pensar en estos valores en el momento en que define sus vértices, así que los está configurando ahora para usarlos más adelante.
Comienza con sombreadores
Ahora tienes los datos que quieres 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, procesamiento de Fragment o procesamiento general. Como 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, fundamentalmente, en paralelo.
Los sombreadores en WebGPU se escriben en un lenguaje de sombreado llamado WGSL (WebGPU Shading Language). WGSL es, sintácticamente, un poco similar a Rust, con funciones diseñadas para facilitar y agilizar los tipos comunes de GPU (como matemáticas de vectores y matrices). Enseñar todo el lenguaje de sombreado no se incluye en este codelab, pero esperamos que aprendas sobre algunos de los conceptos básicos cuando revises algunos ejemplos simples.
Los sombreadores se pasan a WebGPU como strings.
- Crea un lugar para ingresar 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 proporcionas un label
y un code
opcionales como string. (Ten en cuenta que aquí se usan acentos graves para permitir strings 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.
Cómo definir el sombreador de vértices
Comienza 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
. Como tu vertexBuffer
tiene seis posiciones (vértices), la función que definas se llamará seis veces. Cada vez que se llama, se pasa una posición diferente de vertexBuffer
a la función como argumento y es la función 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 no se les llamará 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 una gran parte de lo que se debe a la velocidad increíble de las GPU, pero viene con 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 los datos de un vértice a la vez y solo puede generar valores para un solo 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 para declarar cualquier argumento y llaves.
- Crea una función
@vertex
vacía de la siguiente 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 algo tan común que se usa 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
).
- 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 la función muestra lo siguiente.
index.html (código createShaderModule)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
Por supuesto, si la función tiene un tipo de datos que se muestra, en realidad debes mostrar un valor en el cuerpo de la función. Puedes construir una nueva vec4f
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 dónde se encuentra el vértice en el espacio de recorte.
- Muestra un valor estático de
(0, 0, 0, 1)
y, 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)
}
Lo que quieres es usar los datos del búfer que creaste. Para ello, declara un argumento para tu función con un atributo y un tipo @location()
que coincidan con lo que describiste en vertexBufferLayout
. Como especificaste un shaderLocation
de 0
, en tu 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
. Puede asignarle el nombre que desee, pero como estos representan sus posiciones de vértice, un nombre como pos parece natural.
- 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 muestra 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 del vector de retorno, dejando los últimos dos componentes como 0
y 1
, respectivamente.
- Muestre la posición correcta indicando de forma explícita qué componentes de posición utilizar:
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 abreviatura conveniente y significa lo mismo.
- 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 sin cambios, pero es bueno para comenzar.
Cómo definir el sombreador de fragmentos
El siguiente es el sombreador de fragmentos. Los sombreadores de fragmentos funcionan de manera 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, rasteriza cada uno de esos triángulos determinando los píxeles de los adjuntos de color de salida incluidos en ese triángulo y, luego, llama al sombreador de fragmentos una vez para cada uno de esos píxeles. El sombreador de fragmentos muestra un color, que generalmente 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 muy 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 simplemente 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 se muestra 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.
- Crea una función
@fragment
vacía de la siguiente 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, lo que parece un color aceptable para tu cuadrado. Puedes elegir el color que quieras.
- 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, simplemente 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ó anteriormente, 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 procesamiento
No se puede usar un módulo de sombreador para renderizarlo. En su lugar, debes usarlo como parte de una GPURenderPipeline
, que se crea llamando a device.createRenderPipeline(). La canalización de renderización 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 procesamiento es el objeto más complejo de toda la API, pero no se preocupe. La mayoría de los valores que puede pasar son opcionales, y solo debe proporcionar algunos para comenzar.
- 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 layout
que describa los tipos de entrada (aparte de los búferes de vértices) que necesita la canalización, pero en realidad no tiene ninguno. Afortunadamente, puedes pasar "auto"
por ahora, y la canalización compila su propio diseño a partir de los sombreadores.
A continuación, debes proporcionar detalles sobre la etapa vertex
. El module
es el GPUShaderModule que contiene tu sombreador de vértices, y el entryPoint
proporciona el nombre de la función en el código del sombreador que se llama para cada invocación de vértice. (Puedes tener varias funciones @vertex
y @fragment
en un solo módulo de sombreador). Los búferes son un array de objetos GPUVertexBufferLayout
que describen cómo se empaquetan los datos en los búferes de vértices con los que se usa esta canalización. Afortunadamente, ya definiste esto en una vertexBufferLayout
. Aquí es donde la pasas.
Por último, tienes información sobre la etapa fragment
. También incluye un módulo del sombreador y un entryPoint, como la etapa de vértice. El último bit es definir el targets
con el que se usa esta canalización. Este es un array de diccionarios que proporcionan detalles, como la textura format
, de los adjuntos de color a los que genera la canalización. Estos detalles deben coincidir con las texturas proporcionadas en el 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 procesamiento, pero es suficiente para las necesidades de este codelab.
Dibuja el cuadrado
Ahora tienes todo lo que necesitas para dibujar tu tribuna.
- Para dibujar el cuadrado, vuelve al par de llamadas
encoder.beginRenderPass()
ypass.end()
y, luego, agrega estos nuevos comandos:
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 la WebGPU toda la información necesaria para dibujar su cuadrado. Primero, use 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, llame a setVertexBuffer()
con el búfer que contiene los vértices de su cuadrado. Lo llamará 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 debe 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 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 a mano.
- Actualice la pantalla y, finalmente, vea los resultados de todo su arduo trabajo: un cuadrado grande de color.
5. Dibujar una cuadrícula
Primero, tómate un momento para felicitarte. Mostrar los primeros bits de geometría en pantalla suele ser uno de los pasos más difíciles con la mayoría de las API de GPU. Todo lo que haga aquí se puede hacer en pasos más pequeños, lo que facilita la verificación de su progreso a medida que avanza.
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 renderización
- Cómo usar la instancia para dibujar muchas variantes diferentes de la misma geometría
Define la cuadrícula
Para renderizar una cuadrícula, debe conocer información fundamental. ¿Cuántas celdas contiene, tanto el ancho como la altura? Depende de ti el desarrollador, pero para mantener un poco más fácil, trata la cuadrícula como un cuadrado (el mismo ancho y la altura) y usa un tamaño que sea la potencia de dos. (así que algunos cálculos matemáticos son más fáciles más adelante). Quieres agrandarla con el tiempo, pero para el resto de esta sección, establece el tamaño de cuadrícula en 4 x 4, 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 en 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 quepa GRID_SIZE
veces GRID_SIZE
en el lienzo. Eso significa que el cuadrado debe ser mucho más pequeño y debe haber muchos.
Una forma en la que podría abordar 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 de ese código no estaría 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 para GPU.
Cómo crear 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 Vertex 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 para 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 utilizó antes para crear el búfer de Vertex. 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
.
Cómo acceder 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 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á 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. Como pos
es un vector en 2D y grid
es un vector en 2D, WGSL realiza una división en términos de 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 procesamiento y procesamiento dependen de ellos.
Esto significa que, si usas un tamaño de cuadrícula de 4, el cuadrado que renderices sería un cuarto de su tamaño original. Es perfecto si desea ajustar cuatro de ellas a una fila o columna.
Crea un grupo de vinculación
Sin embargo, declarar el uniforme en el sombreador no lo conecta con el búfer que creaste. Para ello, debe crear y configurar un grupo de vinculación.
Un grupo de vinculación es una colección de recursos que quieres 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 vinculación 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 algo que profundizará más adelante en un paso posterior, pero, por el momento, puede pedirle a su canalización con éxito el diseño del grupo de vinculaciones porque creó la canalización con 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)
, donde 0
corresponde a la @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 con, al menos, los siguientes valores:
binding
, que corresponde al valor de@binding()
que ingresaste en el sombreador. En este caso, es0
.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 el contenido de esos recursos. Por ejemplo, si cambias el búfer uniforme para que contenga un nuevo tamaño de cuadrícula, lo que se reflejará en llamadas de dibujo futuras con este grupo de vinculación.
Cómo vincular el grupo de vinculación
Ahora que creaste el grupo de vinculación, debes indicarle a WebGPU que lo use cuando dibuje. Afortunadamente, esto es bastante simple.
- 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.
- Actualice su página. Debería ver algo como lo siguiente:
¡Hip, hip, hurra! Ahora, su 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.
Manipulación de la geometría en el sombreador
Ahora que puedes hacer referencia al tamaño de la cuadrícula en el sombreador, puedes comenzar a manipular la geometría que renderizas para que se adapte al patrón de cuadrícula deseado. Para ello, considere exactamente lo que desea lograr.
Debes dividir conceptualmente tu lienzo 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:
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. Desearían cambiar el cuadrado por media celda de modo que se alineara bien dentro de ellos.
Una forma de solucionar esto es actualizar el búfer del vértice del cuadrado. Si mueve 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ía 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.
- 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 izquierda por 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 a la cuadrícula justo fuera del origen.
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 traducir la posición de tu geometría (-1, -1) después de dividirla por el tamaño de la cuadrícula a fin de moverla a esa esquina.
- Traduzca la posición de su 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).
¿Qué pasa si desea colocarlo en una celda diferente? Para ello, declara un vector cell
en tu 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, quieres 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
.
- 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 actualizas ahora, verás lo siguiente:
Mmm. No 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. Este es un error fácil de realizar al razonar con coordenadas de GPU. Afortunadamente, la solución es igual de fácil.
- 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 le da exactamente lo que desea.
La captura de pantalla se ve de la siguiente manera:
Además, ahora puedes establecer cell
en cualquier valor dentro de los límites de la cuadrícula y, luego, actualizar para ver el procesamiento de cuadrados en la ubicación deseada.
Dibujar instancias
Ahora que puedes colocar el cuadrado donde quieras con un poco de matemáticas, 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 cada vez. 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.
- Para indicarle a la GPU que quieres tener suficientes instancias de tu cuadrado a fin 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:
¿Por qué? Porque dibujas los 16 cuadrados de ese cuadrado en el mismo lugar. Debes tener 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
. 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
, seis más con instance_index
de 2
, y así sucesivamente.
Para ver esto en acción, debes agregar 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.
- 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.
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 encajan en el lienzo. Para crear la cuadrícula que deseas, debes transformar instance_index
, de modo que cada índice se asigne a una celda única dentro de la cuadrícula, de la siguiente manera:
La matemática para eso es razonablemente directa. Para el valor X de cada celda, deseas el módulo de 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.
- 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 la actualización del código, ¡tienes la esperada cuadrícula de cuadrados!
- Ahora que funciona, vuelve y sube el tamaño de la cuadrícula.
index.html
const GRID_SIZE = 32;
¡Listo! En realidad, puedes hacer que esta cuadrícula sea muy, muy grande, 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 adicional para que sea más colorido
En este punto, puedes pasar fácilmente a la siguiente sección, ya que sentaste las bases para el resto del codelab. Pero si bien la cuadrícula de cuadrados que comparten el mismo color es útil, no es exactamente emocionante, ¿cierto? Afortunadamente, puedes hacerlo un poco más brillante con un poco más de código de matemáticas y sombreadores.
Cómo usar 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 quieres 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 usando 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.
Cómo pasar datos entre las funciones de vértice y de fragmento
Te recordamos 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 estás tomando ninguna entrada y estás desviando un color sólido (rojo) como resultado. Sin embargo, si el sombreador sabía más sobre la geometría de su color, podrías usar los datos adicionales para que las cosas sean un poco más interesantes. Por ejemplo, ¿qué sucede si desea cambiar el color de cada cuadrado según la coordenada de las celdas? La etapa @vertex
sabe qué celda se renderiza; solo debes pasarla a la etapa @fragment
.
Para pasar cualquier dato entre las etapas del vértice y del fragmento, debes incluirlo en una estructura de salida con un @location
de nuestra elección. Como deseas pasar la coordenada de la celda, agrégala a la estructura VertexOutput
anterior y, luego, configúrala en la función @vertex
antes de mostrarla.
- 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;
}
- 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);
}
- Como alternativa, puedes usar un 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);
}
- Otra alternativa**,** dado que en tu código ambas funciones se definen en el mismo módulo de sombreador, es reutilizar la estructura de salida de la etapa
@vertex
. Esto facilita pasar valores porque los nombres y las ubicaciones tienen coherencia natural.
index.html (llamada createShaderModule)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
Independientemente del 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:
Definitivamente, hay más colores en este momento, pero no es exactamente atractivo. Es posible que se pregunte por qué solo son diferentes las filas inferior y izquierda. Esto se debe a que los valores de color que muestra 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 ve aquí es que la primera fila y columna alcanzan inmediatamente ese valor completo en el canal de color rojo o verde, y cada celda que sigue se sujeta al mismo valor.
Si quieres 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
.
- 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);
}
Actualice la página y verá que el nuevo código sí le brinda un gradiente mucho más agradable en toda la cuadrícula.
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 ocultará lo que está sucediendo. Sería bueno aclarar eso.
Afortunadamente, tienes un canal de color completo, que es azul y que puedes usar. Lo ideal es que el azul brille con el mayor brillo en los demás colores y, luego, atenuarse a medida que los otros colores aumentan 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 ambos y, luego, elige el que prefieras.
- 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.
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. Administrar 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 necesita es un indicador de activación para cada celda, de modo que cualquier opción que le permita almacenar un gran conjunto de casi cualquier tipo de valor funciona. Podría pensar que este es otro caso de uso para los 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 arreglos de tamaño dinámico (debes especificar el tamaño del array en el sombreador) y no pueden escribirlos mediante sombreadores de procesamiento. 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.
Afortunadamente, existe 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álculo, 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.
- A fin de crear un búfer de almacenamiento para tu estado en una celda, usa lo que probablemente sea a partir de ahora: un fragmento del código de creación de búfer que te resultará 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
.
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()
. Como deseas ver el efecto de tu búfer en la cuadrícula, comienza con algo predecible.
- 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 cómo se agregaron los uniformes anteriormente.
- 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 unión, que se encuentra justo debajo del uniforme de la cuadrícula. Quieres conservar el mismo @group
que el uniforme grid
, pero el número @binding
debe ser diferente. El tipo var
es storage
, a fin 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 valores u32
a fin 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? Como los estados activos e inactivos 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.
- Actualiza tu 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
a fin de 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 el 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.
Usa el patrón de búfer de ping-pong
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, gíralo y lee del estado en el que escribieron anteriormente. Por lo general, se lo conoce como patrón de ping pong porque la versión más actualizada del estado rebota entre las copias de estado en cada paso.
¿Por qué es necesario? Veamos un ejemplo simplificado: imagine que va a escribir una simulación muy simple en la que mueve los bloques activos a la derecha de una celda en cada paso. Para facilitar la comprensión, debe definir sus datos y 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 mueve la celda activa hacia la derecha y, luego, observa la siguiente celda. ¡Está activo! Vuelve a moverlo hacia la derecha. El hecho de que cambies los datos al mismo tiempo que los observas corrompe los resultados.
Si usa el patrón de ping pong, se asegurará de realizar siempre el próximo paso de la simulación mediante 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);
- Usa este patrón en tu propio código actualizando tu asignación de búfer de almacenamiento para 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,
})
];
- 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);
- Para mostrar los diferentes búferes de almacenamiento en el procesamiento, 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 hizo una actualización por página actualizada, pero ahora desea mostrar los datos que se actualizan con el tiempo. Para ello, necesitas un bucle de renderización simple.
Un bucle de procesamiento 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, probablemente 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. Administre el bucle por su cuenta para controlar la velocidad a la que se actualiza la simulación.
- Primero, seleccione una velocidad de actualización para nuestra simulación (de 200 ms es aceptable, pero puede avanzar más o menos rápido si lo desea) y, luego, realice 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
- Luego, mueve todo el código que usas actualmente para renderizar 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.
Con eso ya casi has terminado 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 comenzarás a usar sombreadores de procesamiento.
Obviamente, las capacidades de renderización de WebGPU son mucho más que la pequeña porción que exploraste aquí, pero el resto está fuera del alcance de este codelab. Esperamos que te permita saber cómo funciona la renderización de WebGPU y que te ayude a facilitar la exploración de técnicas más avanzadas, como la renderización en 3D.
8. Ejecuta la simulación
Ahora veamos la última pieza principal del rompecabezas: realizar la simulación del Juego de la Vida en un sombreador de cómputos.
¡Por último, usa sombreadores de procesamiento!
A lo largo de este codelab, aprendiste sobre los sombreadores de cálculo de forma abstracta, pero ¿qué son exactamente?
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 sombreador quieres. Luego, cuando ejecutes el sombreador, se te indicará qué invocación se está procesando y podrás decidir a qué datos accederás y qué operaciones realizarás desde allí.
Los sombreadores de cálculo 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 comenzar. Como puedes suponer, dada la estructura de los otros sombreadores que implementaste, la función principal del sombreador de procesamiento debe marcarse con el atributo @compute
.
- 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álculo 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. Quieres 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 de 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 8 veces 8. Esto es útil para hacer un seguimiento en tu código JavaScript.
- 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 mediante los literales de plantilla de JavaScript para que puedas usar fácilmente la constante que acabas de definir.
- Agrega el tamaño del grupo de trabajo a la función del sombreador de la siguiente manera:
index.html (llamada createComputeShaderModule)
@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 procesamiento para decirte en qué invocación estás y decidir qué trabajo debes hacer.
- Agrega un valor
@builtin
, como este:
index.html (llamada createComputeShaderModule)
@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álculo también pueden usar uniformes, que se usan igual que en los sombreadores de vértice y de fragmento.
- Usa un uniforme con tu sombreador de cómputo para decirte el tamaño de la cuadrícula de la siguiente manera:
index.html (llamada createComputeShaderModule)
@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álculo 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 un sombreador de procesamiento. Use el método de ping-pong que aprendió anteriormente. Tiene 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.
- Expón la entrada y el estado de salida de la celda como búferes de almacenamiento, de la siguiente manera:
index.html (llamada createComputeShaderModule)
@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>
. Esto te permite leer y escribir en el búfer usando ese búfer como resultado para tu sombreador de cómputos. (no hay modo de almacenamiento de solo escritura en WebGPU).
A continuación, debe tener una forma de asignar su í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. Recuerde que el algoritmo para realizar esta acción fue vec2f(i % grid.x, floor(i / grid.x))
.
- 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 createComputeShaderModule)
@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 funciona, implemente 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ómputo funciona.
- Agrega el algoritmo simple, como este:
index.html (llamada createComputeShaderModule)
@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;
}
}
Eso es todo por el sombreador de procesamiento. Sin embargo, antes de que pueda ver los resultados, debe realizar algunos cambios más.
Cómo usar diseños de grupos y canalizaciones de vinculación
Algo que puedes observar en el sombreador anterior es que usa en gran medida las mismas entradas (uniformes y búferes de almacenamiento) que la canalización de procesamiento. Entonces, podría pensar que simplemente puede usar los mismos grupos de vinculación y terminar con eso, ¿no? 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
. Anteriormente, obtenías ese diseño llamando a getBindGroupLayout()
en la canalización de renderización, 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 vinculación y a las canalizaciones.
Para entender por qué, considere lo siguiente: en sus canalizaciones de procesamiento, debe usar un búfer uniforme y único, pero en el sombreador de procesamiento que acaba de escribir, necesita 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. Desea crear un diseño que describa todos los recursos presentes en el grupo de vinculaciones, no solo los que usa una canalización específica.
- 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 vinculación, ya que describe una lista de elementos entries
. La diferencia es que usted describe 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 procesamiento, pero el segundo búfer de almacenamiento solo necesita ser accesible en los sombreadores de cálculo. También puedes hacer que los sombreadores de fragmentos puedan acceder a los recursos con estas marcas, pero no es necesario que lo hagas aquí.
Por último, debe indicar qué tipo de recurso se utiliza. 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
para definir las opciones para cada uno. Otras opciones incluyen texture
o sampler
, pero no las necesitas aquí.
En el diccionario del búfer, estableces 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 sí 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 vinculación para que coincida con el diseño que acabas de definir.
- Actualiza la creación del grupo de vinculación 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 a fin de utilizar lo mismo.
- Crea un
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 vinculación (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)
).
- 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
}]
}
});
Cree la canalización de procesamiento
Así como necesitas una canalización de procesamiento para usar tus vértices y sombreadores de fragmentos, necesitas una canalización de procesamiento a fin de usar tu sombreador de procesamiento. Afortunadamente, las canalizaciones de procesamiento son mucho menos complejas que las canalizaciones de procesamiento, ya que no tienen ningún estado para configurar, solo el sombreador y el diseño.
- Crea una canalización de procesamiento 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",
}
});
Observa que pasas el pipelineLayout
nuevo en lugar de la "auto"
, al igual que en la canalización de procesamiento actualizada, lo que garantiza que tanto la canalización de procesamiento como la canalización de procesamiento puedan usar los mismos grupos de vinculación.
Pases de procesamiento
Esto lo llevará al punto en que realmente usa la canalización de procesamiento. Dado que se procesa en un pase de procesamiento, es probable que adivine que necesita realizar un trabajo de procesamiento en un pase de procesamiento. El trabajo de procesamiento y renderización puede ocurrir en el mismo codificador de comando, por lo que quieres mezclar un poco la función updateGrid
.
- Mueve la creación del codificador a la parte superior de la función y, luego, inicia un pase de procesamiento 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 = computeEncoder.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 procesamiento, los pases de procesamiento son mucho más fáciles de iniciar que sus equivalentes de renderización, por lo que no debe preocuparse por ningún adjunto.
Desea realizar el pase de procesamiento antes de que pase, ya que permite que este pase use de inmediato los resultados más recientes del pase de procesamiento. Esa es la razón por la que aumentas el recuento de step
entre los pases, de modo que el búfer de salida de la canalización de procesamiento se convierta en el búfer de entrada para la canalización de renderización.
- A continuación, configura la canalización y el grupo de vinculación dentro del pase de procesamiento, con el mismo patrón de cambio entre los grupos de vinculación que realizas para el pase de renderización.
index.html
const computePass = computeEncoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline),
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- Por último, en lugar de dibujar como en un pase de renderización, debes enviar el trabajo al sombreador de procesamiento y decirles cuántos grupos de trabajo deseas ejecutar en cada eje.
index.html
const computePass = computeEncoder.beginComputePass();
computePass.setPipeline(simulationPipeline),
computePass.setBindGroup(0, bindGroups[step % 2]);
// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);
computePass.end();
Algo muy importante que debes tener en cuenta aquí es que el número que pasas a dispatchWorkgroups()
no 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 * 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.
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). Puede elegir los valores como lo desee, pero hay una forma sencilla de comenzar que brinde resultados razonables.
- 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 se necesita aquí, el código del sombreador puede ser decepcionantemente simple.
En primer lugar, debe saber para cada celda determinada cuántos de sus vecinos están activos. No importa cuáles estén activos, solo el recuento.
- Para facilitar la obtención de datos de celdas adyacentes, agrega una función
cellActive
que muestre el valorcellStateIn
de la coordenada determinada.
index.html (llamada createComputeShaderModule)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
La función cellActive
muestra una si la celda está activa, por lo que agregar el valor de retorno de llamar a cellActive
para las ocho celdas circundantes te indica cuántas celdas cercanas están activas.
- Busca la cantidad de vecinos activos, de la siguiente manera:
index.html (llamada createComputeShaderModule)
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 de la pizarra? Según la lógica de cellIndex()
que tienes ahora, se desborda a la fila siguiente o 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 las celdas en el borde opuesto de la cuadrícula como sus vecinos, lo que crea un tipo de efecto envolvente.
- Admite el ajuste de cuadrícula con un pequeño cambio en la función
cellIndex()
.
index.html (llamada createComputeShaderModule)
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
Si usas el operador %
para unir las celdas X e Y cuando se extiende más allá del tamaño de la cuadrícula, te aseguras de 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 exactamente 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 de cambio, que son una buena opción para esta lógica.
- Implementa la lógica de Game of Life de la siguiente manera:
index.html (llamada createComputeShaderModule)
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 sombreado del procesamiento 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;
}
}
}
`
});
Eso es todo. ¡Listo! Actualiza tu página y observa cómo crece tu autómata móvil.
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 mediante la API de WebGPU.
¿Qué sigue?
- Revisa los ejemplos de WebGPU.
Lecturas adicionales
- WebGPU: Todos los núcleos, ninguno de los lienzos
- GPU web sin procesar
- Aspectos básicos de la GPU
- Prácticas recomendadas para GPU web