El parámetro de instancias de draw y drawIndexed
Los cinco argumentos de las dos llamadas de dibujo, qué valores toman instance_index y vertex_index, y las dos formas de alimentar datos por instancia con sus límites reales.
El instanciado es la primera respuesta a la pregunta de cómo dibujar diez mil objetos sin hacer diez mil llamadas, y su interfaz es tan discreta que mucha gente la usa años sin darse cuenta: es el segundo argumento de draw(), que por defecto vale uno. Detrás de ese número hay dos decisiones de diseño que conviene entender antes de construir sobre ellas, porque determinan cómo llegan los datos de cada instancia al shader y cuál de las dos vías te deja después pasar al dibujado indirecto y cuál no.
- Enumerar los cinco argumentos de
draw()ydrawIndexed()y qué hace cada uno. - Predecir el valor de
instance_indexy devertex_indexdentro del vertex shader. - Elegir entre atributos por instancia y storage buffer indexado según el caso.
- Reconocer cuándo el instanciado no aporta nada.
Las dos llamadas
// Sin indices: cinco argumentos, cuatro con valor por defecto.
pass.draw(vertexCount, instanceCount = 1, firstVertex = 0, firstInstance = 0);
// Con indices: uno mas, y el nuevo es el unico con signo.
pass.drawIndexed(indexCount, instanceCount = 1,
firstIndex = 0, baseVertex = 0, firstInstance = 0);
instanceCount es el número de repeticiones. Todo el trabajo de vértice y de fragmento se multiplica por él, pero el coste de CPU no: es exactamente el mismo comando grabado y validado una vez.
firstVertex y firstIndex desplazan el punto de partida dentro del búfer, lo que permite tener varias mallas concatenadas en un mismo par de búferes y dibujar un tramo.
baseVertex se suma al valor leído del búfer de índices. Es el que hace posible el patrón del megabúfer: todas las mallas comparten un búfer de vértices y otro de índices, y los índices de cada malla se guardan relativos a su propio inicio, con baseVertex apuntando a dónde empieza esa malla. Es el único argumento con signo, un desplazamiento de 32 bits que admite valores negativos.
firstInstance desplaza el índice de instancia. En la llamada directa se puede usar libremente; en la indirecta tiene una condición que ocupará buena parte de la lección siguiente.
Qué ve el shader
Dos builtins llevan la cuenta, y sus valores no empiezan necesariamente en cero:
@builtin(instance_index) recorre el rango que va de firstInstance a firstInstance + instanceCount - 1. Con firstInstance a cero, que es el caso habitual, va de cero a instanceCount - 1.
@builtin(vertex_index) vale, en un dibujado sin índices, desde firstVertex; y en uno indexado, el valor leído del búfer de índices más baseVertex. Esa segunda definición es la que hace que el megabúfer funcione de forma transparente para el shader: el vértice que recibe es el correcto en coordenadas absolutas del búfer grande.
@vertex
fn vs(
@location(0) posLocal : vec3f,
@builtin(instance_index) ii : u32,
) -> @builtin(position) vec4f {
let inst = instancias[ii];
return camara.viewProj * inst.modelo * vec4f(posLocal, 1.0);
}
Las dos formas de alimentar una instancia
Como atributo de vértice con stepMode: 'instance'. El búfer se declara en el layout de vértices igual que cualquier otro, pero avanzando una vez por instancia en lugar de una vez por vértice:
vertex: {
module,
entryPoint: 'vs',
buffers: [
{ // Geometria: avanza por vertice.
arrayStride: 32,
stepMode: 'vertex',
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x3' }, // posicion
{ shaderLocation: 1, offset: 12, format: 'float32x3' }, // normal
{ shaderLocation: 2, offset: 24, format: 'float32x2' }, // uv
],
},
{ // Instancias: avanza por instancia. Una matriz ocupa cuatro locations.
arrayStride: 80,
stepMode: 'instance',
attributes: [
{ shaderLocation: 3, offset: 0, format: 'float32x4' },
{ shaderLocation: 4, offset: 16, format: 'float32x4' },
{ shaderLocation: 5, offset: 32, format: 'float32x4' },
{ shaderLocation: 6, offset: 48, format: 'float32x4' },
{ shaderLocation: 7, offset: 64, format: 'float32x4' }, // color
],
},
],
}
Es el camino clásico, lo entiende cualquier hardware y el ensamblador de vértices hace la lectura por ti. Su límite es de contabilidad: hay como mucho 16 localizaciones de atributo y 8 búferes de vértices garantizados, y una matriz de cuatro por cuatro se come cuatro localizaciones. Con una matriz de modelo, una de normales y un par de vectores de material, la instancia ha consumido diez de las dieciséis y ya no queda sitio para una geometría rica.
Como storage buffer indexado por instance_index. El búfer se vincula en un bind group y el shader lo indexa:
struct Instancia {
modelo : mat4x4f,
color : vec4f,
params : vec4f,
};
@group(1) @binding(0) var<storage, read> instancias : array<Instancia>;
No consume localizaciones, no tiene límite práctico de tamaño por instancia, y —esto es lo importante para lo que viene— permite indirección. Si en lugar de instancias[ii] escribes instancias[visibles[ii]], un compute shader puede decidir qué instancias se dibujan sin tocar los datos originales ni reordenar nada. Esa línea es la bisagra entre el instanciado clásico y el renderizado dirigido por la GPU.
El camino de atributos es marginalmente más rápido en algunos backends porque el ensamblador de vértices tiene hardware dedicado para las lecturas secuenciales. La diferencia es pequeña y desaparece frente a la flexibilidad en cuanto la escena crece.
El instanciado se enseña siempre con el caso feliz: mil cubos, una llamada. Y la escena real tiene mil objetos que son cuarenta mallas distintas con setenta materiales, así que el instanciado te deja en cuarenta o setenta llamadas y no en una. Ahí es donde casi todo el mundo concluye que necesita multi-draw indirect o alguna funcionalidad exótica, y donde la ganancia real está en otro sitio: en reducir la variedad, no en emitir la variedad más rápido. Tres transformaciones cubren la mayor parte de los casos y ninguna es de la API gráfica. La primera es mover al búfer de instancias todo lo que hoy es material: si dos objetos solo se diferencian en el color, en la rugosidad o en el desplazamiento de una textura, eso son parámetros por instancia y no dos materiales; cien variantes de una silla se convierten en un lote. La segunda es el atlas o el texture_2d_array: si la única diferencia es qué textura usan, un índice de capa por instancia unifica el lote entero, y esa indirección cuesta un entero. La tercera, la que más devuelve y la que más incomoda, es fusionar mallas que comparten material: dos objetos distintos con el mismo material pueden vivir en el mismo tramo del megabúfer y dibujarse juntos, porque la variación va por instancia y no por geometría. La consecuencia de diseño es incómoda porque no se resuelve programando: el número de lotes de tu escena lo decide quien crea el contenido, no quien escribe el renderizador. Un motor que expone al artista la diferencia entre «esto es un parámetro» y «esto es un material nuevo», y le enseña el contador de lotes mientras trabaja, resuelve el problema en origen. Uno que no lo hace acaba pidiendo funcionalidades de API para emitir eficientemente un desorden que no debería existir. Antes de perseguir el dibujado indirecto, cuenta cuántas combinaciones distintas de malla y material tiene tu escena y pregúntate cuántas serían si los parámetros estuvieran donde deben.
Cuándo el instanciado no aporta
Tres casos, para no aplicarlo por reflejo.
Cuando hay pocas instancias. Con dos o tres, el coste de mantener el búfer de instancias, subirlo y sincronizarlo supera al de dos o tres llamadas de dibujo. La frontera está en torno a la decena.
Cuando las instancias tienen geometría distinta. El instanciado repite la misma secuencia de vértices. Objetos con mallas diferentes son lotes diferentes, y lo que los une no es el instanciado sino el megabúfer con firstIndex y baseVertex.
Cuando el cuello de botella no está en la CPU. Si tu perfil dice que la GPU es la que va justa, agrupar diez mil llamadas en una no cambia nada: el trabajo de vértice y de fragmento es idéntico. El instanciado ataca exclusivamente el coste por llamada, y saber si ese es tu problema es previo a aplicarlo.
Coge una escena con doscientos objetos dibujados uno a uno y conviértela a una sola llamada instanciada con los datos en un storage buffer. Mide el tiempo de CPU de la grabación de comandos antes y después, no el tiempo de fotograma. La diferencia que veas es exactamente lo que el instanciado compra, y compararla con el tiempo total te dirá si merecía la pena en tu caso o si el cuello estaba en otro sitio.