stepMode y varios buffers: el instancing por atributos
Cómo se declara un buffer que avanza por instancia, cómo se pasa una matriz completa como atributos, y por qué los motores modernos han abandonado esta técnica.
Un solo campo del descriptor convierte un buffer de vértices en un buffer de instancias: stepMode. Con él, la misma geometría se dibuja mil veces con mil transformadas distintas en una sola llamada, y el ensamblador de vértices se encarga de emparejar cada instancia con su fila de datos. Es la técnica clásica de instancing, funciona perfectamente, y hay una razón concreta por la que los renderers actuales usan otra cosa.
- Declarar un buffer con
stepMode: 'instance'y sus atributos. - Pasar una matriz 4x4 por instancia repartida en cuatro localizaciones.
- Coordinar varias ranuras de buffer con sus
setVertexBuffer. - Comparar el instancing por atributos con el instancing por storage buffer.
stepMode
Cada elemento de vertex.buffers tiene un stepMode que solo admite dos valores:
'vertex', que es el valor por defecto: el índice que recorre el buffer avanza una posición por cada vértice.
'instance': el índice avanza una posición por cada instancia, y todos los vértices de una misma instancia leen la misma fila.
buffers: [
{
arrayStride: 20,
stepMode: 'vertex', // geometria compartida
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
{ shaderLocation: 1, offset: 12, format: 'float32x2' },
],
},
{
arrayStride: 16,
stepMode: 'instance', // datos por instancia
attributes: [
{ shaderLocation: 2, offset: 0, format: 'float32x3' }, // desplazamiento
{ shaderLocation: 3, offset: 12, format: 'float32' }, // escala
],
},
]
Del lado del shader no hay ninguna diferencia sintáctica: los cuatro atributos son @location normales y el shader no sabe cuál viene de dónde.
struct EntradaVS {
@location(0) posicion : vec3f,
@location(1) uv : vec2f,
@location(2) despl : vec3f, // por instancia
@location(3) escala : f32, // por instancia
};
@vertex
fn vs(e : EntradaVS) -> SalidaVS {
var s : SalidaVS;
s.clip = camara.viewProj * vec4f(e.posicion * e.escala + e.despl, 1.0);
s.uv = e.uv;
return s;
}
Y el dibujado pide el número de instancias:
pase.setVertexBuffer(0, bufferGeometria);
pase.setVertexBuffer(1, bufferInstancias);
pase.draw(cuentaVertices, 1000); // mil instancias
Los dos últimos argumentos de draw son firstVertex y firstInstance, y el segundo desplaza también la lectura del buffer con stepMode: 'instance': la instancia número cero lee la fila firstInstance.
Una matriz por instancia
Una mat4x4f no se puede declarar como atributo, porque @location solo admite escalares y vectores. Se pasa como cuatro localizaciones consecutivas de vec4f y se reconstruye dentro del shader:
{
arrayStride: 64, // una mat4x4 son 64 bytes
stepMode: 'instance',
attributes: [
{ shaderLocation: 4, offset: 0, format: 'float32x4' },
{ shaderLocation: 5, offset: 16, format: 'float32x4' },
{ shaderLocation: 6, offset: 32, format: 'float32x4' },
{ shaderLocation: 7, offset: 48, format: 'float32x4' },
],
}
struct EntradaVS {
@location(0) posicion : vec3f,
@location(4) m0 : vec4f,
@location(5) m1 : vec4f,
@location(6) m2 : vec4f,
@location(7) m3 : vec4f,
};
@vertex
fn vs(e : EntradaVS) -> @builtin(position) vec4f {
let modelo = mat4x4f(e.m0, e.m1, e.m2, e.m3);
return camara.viewProj * modelo * vec4f(e.posicion, 1.0);
}
Los cuatro vec4f son las columnas de la matriz, en orden, porque WGSL almacena por columnas y el constructor toma columnas.
Aquí es donde empieza a apretar el límite: cuatro localizaciones para una matriz, más las de la geometría, más el color, más lo que necesite el material. Con maxVertexAttributes garantizando solo 16, dos matrices por instancia ya se comen la mitad del presupuesto.
Varias ranuras
El índice de un buffer en el array buffers es su ranura, y es el primer argumento de setVertexBuffer. Las ranuras no tienen relación con las localizaciones: un buffer en la ranura 1 puede alimentar las localizaciones 4 a 7, como en el ejemplo de arriba.
pase.setVertexBuffer(0, geometria);
pase.setVertexBuffer(1, transformadas);
pase.setVertexBuffer(2, colores);
Un buffer se puede desatar pasando null, y su ranura queda vacía; dibujar con un pipeline que espera esa ranura es un error de validación.
Como cada setVertexBuffer es una llamada de CPU con su coste de validación, el número de ranuras que usa un pipeline se nota en el bucle de dibujado cuando hay muchos objetos. Tres ranuras por objeto y cinco mil objetos son quince mil llamadas por fotograma solo para atar geometría.
Un mismo buffer grande puede contener las transformadas de todos los objetos de la escena, y cada dibujado usa la porción que le toca ajustando firstInstance en draw, o el offset de setVertexBuffer. Así se evita crear un buffer por tipo de objeto y se reducen las llamadas de atado. Recuerda que el offset de setVertexBuffer tiene que ser múltiplo de 4.
La técnica de esta lección es correcta, está soportada en todas partes y para muchos casos es la respuesta. Pero si miras un renderer escrito en los últimos años, lo más probable es que no la use, y las razones son cuatro.
El límite de atributos. Dieciséis localizaciones garantizadas se agotan enseguida cuando cada matriz cuesta cuatro. Con una matriz de modelo, una matriz normal y un puñado de parámetros de material por instancia, no cabe.
El layout es parte del pipeline. Añadir un dato por instancia obliga a cambiar el descriptor de vértices y, por tanto, a recrear el pipeline. Un storage buffer se cambia añadiendo un campo a una struct.
No se combina bien con el dibujado indirecto. Cuando un compute shader decide qué objetos son visibles y escribe los parámetros de dibujado en un buffer, la lista de instancias visibles es un array compactado que el shader ha generado. Leer ese array con instance_index desde un storage buffer es directo; hacer que el ensamblador de vértices lo recorra con un stepMode exige que el orden coincida exactamente con lo que la GPU escribió.
Y la razón de fondo: la indirección. Con un storage buffer, instance_index no tiene por qué ser el índice del objeto. Puede ser el índice de una entrada en una tabla de visibles, que a su vez apunta al objeto real. Esa capa de indirección es exactamente lo que permite reordenar, filtrar y agrupar objetos desde la GPU sin tocar ningún buffer de vértices.
El patrón que sustituye a todo esto es de una simplicidad casi decepcionante:
@group(1) @binding(0) var<storage, read> instancias : array<Instancia>;
@vertex
fn vs(@location(0) p : vec3f, @builtin(instance_index) i : u32) -> @builtin(position) vec4f {
let inst = instancias[i];
return camara.viewProj * inst.modelo * vec4f(p, 1.0);
}Un solo buffer de vértices con la geometría, cero atributos por instancia, y todos los datos por objeto en una struct que puedes ampliar sin tocar el pipeline. Sigue habiendo un caso donde el instancing por atributos gana: cuando los datos por instancia son pocos, cambian cada fotograma y ya están en un array contiguo que subes entero. Para todo lo demás, el storage buffer es más flexible y no cuesta más.