draw y submit: qué pasa cuando por fin dibujas
Los cuatro parámetros de draw y sus variantes indexadas, qué hace finish, qué garantiza submit y qué ocurre entre que la llamada retorna y el píxel aparece.
draw(3) y queue.submit([...]) son las dos líneas que convierten toda la preparación anterior en píxeles. Y son también las dos líneas que más engañan sobre lo que ocurre cuando se ejecutan, porque ninguna de las dos dibuja nada: una anota una orden en un búfer y la otra entrega ese búfer a una cola. Entender esa separación es lo que hace que el resto de la API deje de parecer arbitraria.
- Usar
drawydrawIndexedcon sus cuatro y cinco parámetros. - Explicar qué produce
finish()y por qué el resultado es inmutable. - Describir qué garantiza
submit()y qué no garantiza. - Trazar el recorrido completo desde la llamada hasta el píxel en pantalla.
draw y sus parámetros
pase.draw(vertexCount, instanceCount, firstVertex, firstInstance);
vertexCount es el número de vértices a procesar. Con 3 y topología de lista de triángulos sale un triángulo.
instanceCount es cuántas copias dibujar. Por defecto 1. Cada copia ejecuta el vertex shader con el mismo vertex_index y un instance_index distinto, que se recibe con @builtin(instance_index). Es la palanca de rendimiento más eficaz de la API: dibujar mil copias cuesta prácticamente lo mismo que dibujar una, desde el punto de vista de la CPU.
firstVertex desplaza el valor inicial de vertex_index. Por defecto 0.
firstInstance hace lo propio con instance_index. Por defecto 0.
La variante indexada añade un parámetro:
pase.setIndexBuffer(indices, 'uint16');
pase.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance);
baseVertex se suma a cada índice leído antes de usarlo, lo que permite tener varias mallas en un mismo búfer de vértices con índices que empiezan en cero cada una.
Y las variantes indirectas, drawIndirect y drawIndexedIndirect, que leen esos mismos parámetros de un búfer con usage INDIRECT en lugar de recibirlos como argumentos. Es lo que permite que un compute shader decida cuánto dibujar sin que la CPU se entere. Un detalle: usar firstInstance distinto de cero en un dibujado indirecto requiere la feature indirect-first-instance.
draw(3) no significa «dibuja los tres primeros vértices del búfer»: significa «ejecuta el vertex shader tres veces». Si hay búferes de vértices enlazados, el ensamblador leerá de ellos según la disposición declarada en el pipeline. Si no los hay, el shader tiene que generar las posiciones a partir de vertex_index, que es lo que hace el triángulo mínimo. Los dos casos son igual de legítimos.
finish y el command buffer
pase.end();
const commandBuffer = encoder.finish();
finish() cierra la grabación y produce un GPUCommandBuffer. Tres propiedades definen ese objeto.
Es inmutable. No se puede modificar, ni inspeccionar, ni añadirle nada. Es una lista de comandos ya traducida a un formato que la implementación puede pasar al controlador.
Es de un solo uso. Se envía una vez. Enviarlo dos veces es un error de validación. Si quieres repetir una secuencia, la alternativa es un GPURenderBundle, que sí es reutilizable dentro de pases.
El encoder muere con él. Tras finish(), el GPUCommandEncoder no admite más comandos. Un encoder por fotograma es el patrón normal.
finish() también valida: si el pase no se cerró con end(), si un pase quedó abierto, si algún comando era incoherente, el command buffer resultante es inválido y el submit fallará.
submit y lo que no garantiza
device.queue.submit([commandBuffer]);
Recibe un array, no un solo elemento, y los ejecuta en orden. Enviar varios buffers en una llamada es más eficiente que varias llamadas, porque cada submit tiene un coste fijo de cruzar la frontera al proceso de GPU.
Lo que submit() garantiza: que el trabajo está encolado, y que se ejecutará después de todo lo enviado antes y antes de todo lo enviado después. El orden de la cola es total.
Lo que no garantiza es lo importante: cuando submit() retorna, no ha pasado absolutamente nada en la GPU. Ni ha empezado, ni hay forma de saber cuándo empezará. La llamada es asíncrona en el sentido más fuerte: no devuelve una promesa porque no hay nada que esperar desde el punto de vista de tu código.
Si necesitas saber cuándo ha terminado, hay una función para eso:
device.queue.submit([commandBuffer]);
await device.queue.onSubmittedWorkDone();
console.log('la GPU ha terminado todo lo enviado hasta el submit anterior');
Y hay que decirlo claro: await sobre eso en el bucle de render destruye el rendimiento, porque serializa la CPU con la GPU y elimina el solapamiento entre fotogramas. Es una herramienta de medición y de lectura de resultados, no de sincronización rutinaria.
El recorrido completo
De la llamada al píxel, en siete pasos.
Uno. draw() anota una entrada en el búfer de comandos que el encoder mantiene en el proceso de la página. Coste: unos pocos microsegundos, y prácticamente nada de validación.
Dos. finish() cierra y valida la secuencia, produciendo un búfer de comandos listo.
Tres. submit() lo envía al proceso de GPU del navegador. Ahí se traduce a la API nativa —Vulkan, Metal o D3D12— y se entrega al controlador.
Cuatro. El controlador lo pone en la cola del hardware. La GPU está probablemente ocupada con el fotograma anterior.
Cinco. La GPU ejecuta. Los vértices se transforman, las primitivas se ensamblan y se recortan, el rasterizador genera fragmentos, el fragment shader los colorea, las pruebas de profundidad y estencil descartan lo que sobra, y el resultado se mezcla y se escribe.
Seis. El navegador presenta el canvas: la textura del contexto se entrega al compositor, que la combina con el resto de la página.
Siete. El compositor del sistema operativo lo lleva a la pantalla, sincronizado con su refresco.
Entre el paso uno y el siete pueden pasar dos o tres fotogramas. Esa latencia es intencionada: es lo que permite que la CPU esté preparando el fotograma N+2 mientras la GPU ejecuta el N+1 y la pantalla muestra el N. Sin ella, cada lado esperaría al otro y el rendimiento sería la mitad.
Hay un fallo de arquitectura que aparece en la mayoría de los proyectos que llegan a producción y que es especialmente difícil de diagnosticar: algo en el bucle de fotograma obliga a la CPU a esperar a la GPU, la canalización se rompe, y el rendimiento cae a la mitad sin que ninguno de los dos lados parezca ocupado.
Los tres culpables habituales. Un await queue.onSubmittedWorkDone() colocado «para asegurarse». Un await buffer.mapAsync() para leer un resultado en el mismo fotograma en que se produjo. Y el más sutil: leer una textura del canvas para una captura o para un cálculo de histograma dentro del bucle.
Lo que hace que sea difícil de encontrar es el síntoma. El perfilador de CPU muestra el hilo principal mayormente ocioso: está esperando, no trabajando. Las medidas de GPU muestran la GPU también ociosa buena parte del tiempo: está esperando a que le manden trabajo. Los dos lados parecen infrautilizados y el tiempo de fotograma es el doble de la suma de sus partes. Sin el modelo mental de la canalización, ese resultado no tiene sentido y la gente se pone a optimizar shaders que no eran el problema.
La comprobación que lo detecta en un minuto: busca todos los await que hay dentro del bucle de fotograma y quítalos. Si el rendimiento se dispara, ya sabes dónde estaba. Y la solución nunca es esperar mejor, es no esperar: lo que necesites leer, léelo con dos o tres fotogramas de retraso y usa el valor viejo. En la práctica nadie nota que un histograma de exposición automática tiene tres fotogramas de antigüedad, y la diferencia de rendimiento es del cien por cien.
Todas las piezas están sobre la mesa. Falta juntarlas en algo que se pueda copiar y ejecutar: el triángulo completo.