wandres.dev
COMMAND ENCODING · El modelo de comandos

El command encoder y las operaciones fuera de los pases

Qué se puede hacer a nivel de encoder sin abrir ningún pase: copias entre búferes y texturas, limpieza, resolución de consultas y grupos de depuración.

⏱ 16 min

El GPUCommandEncoder no es solo la fábrica de pases: tiene un conjunto propio de operaciones que se graban fuera de cualquier pase y que son las que mueven datos por la GPU. Copias entre búferes, copias entre texturas, limpieza de rangos, resolución de consultas. Son las operaciones que casi ningún tutorial cubre y las que más falta hacen en cuanto la aplicación tiene más de un pase.

🎯 Al terminar esta lección sabrás
  • Enumerar las operaciones que se graban a nivel de encoder.
  • Aplicar las reglas de alineación de las copias entre búferes y texturas.
  • Usar los grupos de depuración para que los mensajes de error sean legibles.
  • Explicar por qué las copias no pueden ir dentro de un pase.

Crear el encoder

const encoder = device.createCommandEncoder({ label: 'frame 1234' });

El descriptor solo tiene label, y merece la pena rellenarlo con algo que identifique el fotograma o la fase. Los mensajes de validación citan esa etiqueta.

Un encoder tiene una máquina de estados sencilla: está abierto desde que se crea, pasa a bloqueado mientras hay un pase activo, y muere al llamar a finish(). Mientras está bloqueado no admite operaciones propias: primero hay que cerrar el pase con end().

Las copias

Copias entre búferes

encoder.copyBufferToBuffer(origen, origenOffset, destino, destinoOffset, tamano);
// Forma corta equivalente a los desplazamientos a cero:
encoder.copyBufferToBuffer(origen, destino, tamano);

La especificación define las dos formas. Las reglas son cuatro: el origen necesita COPY_SRC, el destino COPY_DST, los dos desplazamientos y el tamaño tienen que ser múltiplos de 4, y origen y destino tienen que ser búferes distintos.

Es la operación más rápida para mover datos que ya están en la GPU, porque no involucra a la CPU en absoluto. Aparece en dos sitios constantemente: el paso intermedio del patrón de lectura, y el intercambio de datos entre etapas de una canalización.

Copias con texturas

Tres métodos, con la misma forma general: un descriptor de origen, uno de destino y un tamaño.

encoder.copyBufferToTexture(
  { buffer, offset: 0, bytesPerRow: 1024, rowsPerImage: 256 },
  { texture, mipLevel: 0, origin: { x: 0, y: 0, z: 0 } },
  { width: 256, height: 256, depthOrArrayLayers: 1 }
);

encoder.copyTextureToBuffer(origenTextura, destinoBuffer, tamano);
encoder.copyTextureToTexture(origenTextura, destinoTextura, tamano);

La regla que hay que recordar de estas tres es bytesPerRow tiene que ser múltiplo de 256. Es la restricción de alineación de filas y no se puede evitar. Una textura de 100 píxeles de ancho en rgba8unorm ocupa 400 bytes por fila, y 400 no es múltiplo de 256, así que en el búfer hay que reservar 512 bytes por fila y dejar 112 de relleno al final de cada una.

const bytesPorPixel = 4;
const bytesPorFila = Math.ceil(ancho * bytesPorPixel / 256) * 256;
const tamanoBuffer = bytesPorFila * alto;

Y al leer los datos de vuelta hay que deshacer ese relleno fila a fila, no leer el búfer como si fuera un array contiguo de píxeles. Es el error número uno al capturar una textura, y produce una imagen sesgada en diagonal, que es un síntoma tan característico que sirve de diagnóstico.

⚠️
El relleno de 256 no es opcional ni ajustable

No hay ningún límite que consultar ni ninguna forma de pedir un valor menor: 256 es un valor fijo de la especificación. Cualquier código que copie texturas a búferes tiene que calcular el paso con ese redondeo. Si el ancho de la textura ya produce un múltiplo de 256 —un ancho de 64, 128, 256 píxeles en rgba8unorm— el relleno es cero y el problema no aparece, lo que explica que mucha gente lo descubra tarde, al probar con una textura de tamaño arbitrario.

Limpieza, consultas y depuración

clearBuffer(buffer, offset, size) rellena un rango de un búfer con ceros. Los argumentos de rango son opcionales; sin ellos limpia el búfer entero. Es mucho más eficiente que escribir un array de ceros con writeBuffer, porque no mueve datos: es una operación de la GPU sobre su propia memoria. Es el patrón correcto para reiniciar contadores atómicos o acumuladores al principio de cada fotograma.

// Reiniciar el contador antes del dispatch que lo va a incrementar.
encoder.clearBuffer(contador, 0, 4);

resolveQuerySet(querySet, primeraConsulta, cuantas, destino, destinoOffset) vuelca los resultados de un conjunto de consultas a un búfer con usage QUERY_RESOLVE. Es el paso obligatorio para leer marcas de tiempo o consultas de oclusión: los resultados no son accesibles directamente desde el GPUQuerySet.

writeTimestamp existe en algunas implementaciones pero la vía portable para marcas de tiempo en un pase es el campo timestampWrites del descriptor del pase, que escribe al principio y al final.

Grupos de depuración

Tres métodos que no hacen nada funcional y que valen su peso en oro cuando algo falla:

encoder.pushDebugGroup('preparacion de sombras');
encoder.copyBufferToBuffer(a, b, tam);
const pase = encoder.beginRenderPass(descSombras);
pase.insertDebugMarker('cascada 0');
// ... dibujos
pase.end();
encoder.popDebugGroup();

Los grupos anidan y aparecen en los mensajes de validación y en las herramientas de captura externas. En una aplicación con quince pases, la diferencia entre «error en un dibujo» y «error en un dibujo dentro de preparación de sombras, cascada 0» es la diferencia entre veinte minutos y veinte segundos.

Los mismos tres métodos existen en el encoder, en los pases de render y en los de cómputo.

Por qué las copias van fuera de los pases

No es una restricción arbitraria: es una consecuencia directa de cómo funciona el hardware, especialmente el de tiles.

Un render pass en una GPU de tiles procesa la pantalla por bloques, manteniendo el contenido del bloque en memoria dentro del chip. Una copia entre búferes en mitad de esa secuencia obligaría a vaciar el estado del tile y volver a cargarlo, lo que anularía toda la ventaja del diseño. En una GPU de renderizado inmediato pasaría algo análogo con las transiciones de estado de los recursos.

De ahí la estructura que impone el modelo, y que resulta ser bastante natural:

const encoder = device.createCommandEncoder({ label: 'frame' });

encoder.clearBuffer(contadores);                    // preparacion

const computo = encoder.beginComputePass();
// ... simulacion
computo.end();

encoder.copyBufferToBuffer(resultado, staging, tam); // entre pases

const render = encoder.beginRenderPass(desc);
// ... dibujado
render.end();

device.queue.submit([encoder.finish()]);

Preparación, cómputo, transferencia, render: cada fase en su sitio, con las copias entre pases y no dentro. La implementación puede deducir todas las dependencias de esa secuencia y colocar las barreras donde toca.

Un encoder por fotograma es un valor por defecto, no una ley, y saber cuándo romperlo importa

El patrón de un encoder y un submit por fotograma es correcto para el 90% de los casos y conviene saber por qué existe el otro 10%.

Varios encoders con un solo submit tiene sentido cuando distintos subsistemas graban su parte de forma independiente: la escena, la interfaz, los efectos. Cada uno produce su command buffer y se envían juntos con queue.submit([a, b, c]), que los ejecuta en orden. Es más limpio arquitectónicamente y no cuesta nada, porque el coste fijo está en el submit, no en el encoder.

Varios submit en el mismo fotograma es lo que hay que pensar dos veces. Cada uno tiene coste fijo, así que hacerlo por costumbre es tirar rendimiento. Pero hay un caso donde gana claramente: cuando quieres que la GPU empiece a trabajar antes de que hayas terminado de grabar el resto. Si tu fotograma tiene una fase de cómputo pesada al principio y una fase de render que requiere trabajo de CPU considerable para prepararse, enviar el cómputo pronto deja que la GPU se ponga con él mientras la CPU sigue. Es solapamiento dentro del mismo fotograma, y en un perfil se ve como GPU ocupada durante un tramo en el que antes estaba esperando.

Y el caso donde varios submit son obligatorios: cuando algo tiene que estar enviado para que una promesa pueda resolverse. Un mapAsync no avanza hasta que la copia que lo alimenta se ha enviado; si la copia sigue en un encoder abierto, la promesa nunca resuelve. Es un interbloqueo silencioso que se manifiesta como «mi lectura se queda colgada» y cuya causa es simplemente que faltaba un submit.

Los pases son la otra mitad del encoder y tienen sus propias reglas: los pases como ámbitos.