El index buffer: dos formatos, cinco argumentos y un buffer para todo
Cómo se ata un buffer de índices, qué hace cada argumento de drawIndexed, la diferencia real entre uint16 y uint32, y el reinicio de primitiva en las topologías de tira.
El buffer de índices es la pieza que convierte una lista de vértices en una malla. Sin él, cada triángulo necesita sus tres vértices completos y un cubo son 36 vértices; con él, un vértice compartido por seis triángulos se procesa una vez y se referencia seis. Y sus dos argumentos menos conocidos, firstIndex y baseVertex, son la base del patrón que permite dibujar una escena entera sin volver a atar un solo buffer.
- Atar un buffer de índices con su formato y sus restricciones de alineación.
- Explicar qué hace cada uno de los cinco argumentos de
drawIndexed. - Elegir entre
uint16yuint32con criterio de memoria y de límite de vértices. - Aplicar el reinicio de primitiva en topologías de tira y saber por qué se usan poco.
Atar el índice
const indices = device.createBuffer({
label: 'indices de la malla',
size: datosIndices.byteLength,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(indices, 0, datosIndices);
pase.setIndexBuffer(indices, 'uint16');
El buffer necesita el flag GPUBufferUsage.INDEX. El formato es 'uint16' o 'uint32' y no hay más opciones. setIndexBuffer admite además un offset y un size opcionales para usar solo una porción, y el offset tiene que ser múltiplo del tamaño del índice: 2 bytes para uint16 y 4 para uint32.
Solo hay un buffer de índices activo a la vez, a diferencia de los de vértices, que tienen varias ranuras.
drawIndexed y sus cinco argumentos
pase.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance);
indexCount: cuántos índices leer. Para triángulos, tres por triángulo.
instanceCount: cuántas instancias dibujar. Por defecto 1.
firstIndex: desde qué posición del buffer de índices empezar a leer, contada en índices y no en bytes.
baseVertex: un número con signo que se suma a cada índice leído antes de usarlo para acceder a los buffers de vértices. Es el argumento que permite que varias mallas compartan un buffer de vértices sin reescribir sus índices.
firstInstance: el número de la primera instancia, que además desplaza la lectura de los buffers con stepMode: 'instance'.
El valor de @builtin(vertex_index) que llega al shader es el índice leído más baseVertex. Y @builtin(instance_index) empieza en firstInstance.
// Dibujar la submalla que empieza en el indice 300 y tiene 900 indices,
// cuyos vertices empiezan en la posicion 500 del buffer de vertices comun.
pase.drawIndexed(900, 1, 300, 500, 0);
uint16 frente a uint32
La diferencia es de un factor dos en memoria y en ancho de banda, y de un límite duro en el número de vértices referenciables.
uint16 |
uint32 |
|
|---|---|---|
| Bytes por índice | 2 | 4 |
| Vértice máximo referenciable | 65535 | unos 4290 millones |
| Valor de reinicio de primitiva | 0xFFFF | 0xFFFFFFFF |
Para una malla de cien mil triángulos son trescientos mil índices: 600 KiB en uint16 y 1,2 MiB en uint32. Ese megabyte extra se lee entero en cada pasada que dibuje la malla, y en una escena con sombras y prepass eso son tres lecturas por fotograma.
La estrategia que usan los motores es partir las mallas grandes en trozos de como mucho 65536 vértices y usar uint16 en todos. Con baseVertex no hace falta ni reescribir los índices: cada trozo mantiene sus índices locales de 0 a 65535 y el dibujado le pasa el desplazamiento que le corresponde en el buffer de vértices global.
Cuidado con un detalle al usar uint16 con topologías de tira: el valor 0xFFFF está reservado para el reinicio de primitiva, así que el vértice referenciable más alto es el 65534.
Tiras y reinicio de primitiva
Las topologías 'triangle-strip' y 'line-strip' describen una secuencia continua donde cada vértice nuevo forma una primitiva con los dos anteriores. Ahorran índices —una tira de N triángulos necesita N más 2 índices en vez de 3N— y a cambio obligan a que la geometría sea una tira continua.
Para cortar la tira y empezar otra sin cambiar de dibujado se usa el valor de reinicio: 0xFFFF en uint16 y 0xFFFFFFFF en uint32. Al encontrarlo, el ensamblador termina la tira actual y empieza una nueva con los siguientes vértices.
En el descriptor del pipeline, las topologías de tira tienen un campo asociado:
primitive: {
topology: 'triangle-strip',
stripIndexFormat: 'uint32', // necesario para el dibujado indexado indirecto
cullMode: 'back',
},
En la práctica las tiras se usan poco en geometría real y por dos motivos. El primero es que convertir una malla arbitraria en tiras largas es un problema difícil y los resultados suelen ser tiras cortas con muchos reinicios, con lo que el ahorro se evapora. El segundo es que rompen la reordenación para el caché de vértices: el orden de una tira está determinado por la topología y no se puede reordenar para maximizar los aciertos de caché, que es una optimización que suele valer más que los índices ahorrados.
Donde sí valen la pena es en geometría generada procedimentalmente con estructura de rejilla —terreno, planos teselados, tiras de partículas—, donde la tira sale gratis por construcción.
En una 'triangle-strip', los triángulos alternan su orientación para que todos queden mirando al mismo lado. El ensamblador lo compensa automáticamente, así que el culling funciona como esperas. Lo que no se compensa es si tú generas la tira con el orden equivocado: en una rejilla, el patrón correcto alterna filas en zigzag, y si lo generas fila a fila en la misma dirección, la mitad de los triángulos quedan mirando hacia atrás.
Estos dos argumentos parecen una comodidad menor. Son, en realidad, la pieza que permite que un renderer pase de miles de llamadas de atado por fotograma a ninguna.
El patrón se llama buffer único de geometría y funciona así. En vez de un buffer de vértices y uno de índices por malla, la escena entera vive en dos buffers grandes: uno con todos los vértices concatenados y otro con todos los índices. Cada malla se registra con cuatro números: dónde empiezan sus índices, cuántos tiene, dónde empiezan sus vértices, y cuántos ocupa.
Dibujar la escena se convierte en esto:
pase.setVertexBuffer(0, verticesGlobal); // una vez por fotograma
pase.setIndexBuffer(indicesGlobal, 'uint16');
for (const objeto of visibles) {
pase.setBindGroup(2, objeto.material);
pase.drawIndexed(objeto.numIndices, 1, objeto.primerIndice, objeto.baseVertice, objeto.instancia);
}Cero setVertexBuffer por objeto. Cero setIndexBuffer por objeto. Y si además agrupas los objetos por material, cero setBindGroup en la mayoría de las iteraciones. El bucle se reduce a una llamada por objeto, que es el mínimo teórico con dibujado directo.
Pero lo importante viene después. Fíjate en que los cinco argumentos de drawIndexed son cinco números, y que un drawIndexedIndirect lee exactamente esos cinco números de un buffer. Con la geometría en buffers globales, un compute shader puede hacer el culling, decidir qué objetos se dibujan, y escribir él mismo la lista de comandos —los cinco números por objeto visible— sin que la CPU se entere. La CPU emite un único drawIndexedIndirect con un contador que también escribió la GPU.
Ese es el salto de arquitectura completo, y no habría sido posible si cada malla necesitara sus propios buffers atados, porque atar buffers es lo único que un compute shader no puede hacer. baseVertex y firstIndex son lo que elimina esa necesidad. Por eso el primer paso para migrar un renderer clásico a uno dirigido por GPU no es escribir compute shaders: es unificar la geometría en dos buffers.