El vertex buffer layout: de bytes a @location
Cómo se describe la disposición de un buffer de vértices, las reglas de validación de arrayStride y offset, y el camino completo de un byte hasta el color de un píxel.
Un buffer de vértices es un bloque de bytes sin estructura. Todo lo que la GPU sabe sobre él lo dice el vertexBufferLayout: cada cuántos bytes empieza un vértice nuevo, qué campos hay dentro y a qué localización del shader va cada uno. Ese descriptor se congela al crear el pipeline, y a cambio la etapa de ensamblado de vértices no vuelve a tomar ni una decisión en tiempo de ejecución.
- Escribir un
vertexBufferLayoutcompleto con sus tres campos y sus atributos. - Aplicar las reglas de validación de
arrayStride,offsetyshaderLocation. - Trazar el camino de un byte del buffer hasta la salida del fragment shader.
- Calcular a mano los desplazamientos de un vértice entrelazado.
El descriptor
vertex.buffers es un array donde cada elemento describe un buffer, y su posición en el array es la ranura que usará setVertexBuffer.
const pipeline = device.createRenderPipeline({
layout: disposicion,
vertex: {
module,
entryPoint: 'vs',
buffers: [
{
arrayStride: 32, // bytes de un vertice al siguiente
stepMode: 'vertex', // por defecto
attributes: [
{ shaderLocation: 0, offset: 0, format: 'float32x3' }, // posicion
{ shaderLocation: 1, offset: 12, format: 'float32x3' }, // normal
{ shaderLocation: 2, offset: 24, format: 'float32x2' }, // uv
],
},
],
},
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
});
Y en el shader, la correspondencia por número:
struct EntradaVS {
@location(0) posicion : vec3f,
@location(1) normal : vec3f,
@location(2) uv : vec2f,
};
Los tres campos del layout:
arrayStride es la distancia en bytes entre el principio de un vértice y el del siguiente. No es la suma de los tamaños de los atributos: es lo que tú decidas, y puede sobrar espacio si te conviene alinear.
stepMode decide si el índice que recorre este buffer avanza por vértice o por instancia. Tiene lección propia.
attributes es la lista de campos, cada uno con su format, su offset dentro del vértice y su shaderLocation.
Las reglas de validación
Estas son las que producen errores al crear el pipeline, y merece la pena tenerlas escritas porque los mensajes no siempre son claros:
arrayStridetiene que ser múltiplo de 4 y no puede superarmaxVertexBufferArrayStride, cuyo mínimo garantizado es 2048.offsettiene que ser múltiplo del menor entre 4 y el tamaño en bytes del formato. En la práctica: múltiplo de 4 para formatos de 4 bytes o más, múltiplo de 2 para los de 2 bytes.offsetmás el tamaño del formato no puede pasarse dearrayStride.shaderLocationtiene que ser único entre todos los buffers, no solo dentro de uno, y menor quemaxVertexAttributes, con mínimo garantizado de 16.- El número de elementos de
buffersestá acotado pormaxVertexBuffers, con mínimo garantizado de 8.
Y una regla que no es de validación pero se comporta como tal: el tipo declarado en WGSL tiene que ser de la familia correcta. Un formato unorm8x4 entrega flotantes y hay que declararlo vec4f; un uint16x2 entrega enteros y hay que declararlo vec2u. La familia sí tiene que coincidir; el número de componentes no: si el formato tiene menos, los que faltan se rellenan con ceros salvo el cuarto, que se rellena con uno.
El camino completo
Merece la pena ver de una vez la cadena entera, porque los mismos números aparecen en cuatro sitios distintos y significan cosas distintas en cada uno.
flowchart TB A[Buffer de vertices en memoria de la GPU] --> B[arrayStride marca donde empieza cada vertice] B --> C[Cada attribute lo localiza con offset y lo interpreta con format] C --> D[shaderLocation 1 en el descriptor] D --> E[location 1 en la entrada del vertex shader] E --> F[El vertex shader devuelve builtin position y sus propias location] F --> G[El rasterizador interpola cada location por fragmento] G --> H[location 1 en la entrada del fragment shader] H --> I[location 0 de salida hacia el primer color target] style A fill:#94e2d5,color:#11111b style B fill:#89b4fa,color:#11111b style C fill:#89b4fa,color:#11111b style D fill:#cba6f7,color:#11111b style E fill:#cba6f7,color:#11111b style F fill:#fab387,color:#11111b style G fill:#fab387,color:#11111b style H fill:#fab387,color:#11111b style I fill:#a6e3a1,color:#11111b
Lo que hay que sacar de ahí es que el @location(1) de la entrada del vertex shader y el @location(1) de la entrada del fragment shader no tienen nada que ver entre sí. El primero viene del descriptor de buffers; el segundo, de lo que el vertex shader haya decidido devolver. Que coincidan en número es casualidad o costumbre.
Entrelazado y desplazamientos
El ejemplo de arriba está entrelazado: los tres atributos de un vértice están juntos y el buffer alterna posición, normal, uv, posición, normal, uv. Los desplazamientos se calculan sumando tamaños.
// float32x3 = 12 bytes, float32x2 = 8 bytes
// posicion en 0, normal en 0+12 = 12, uv en 12+12 = 24
// arrayStride = 24 + 8 = 32
La alternativa es un buffer por atributo, con tres entradas en buffers, tres arrayStride distintos y tres setVertexBuffer. Las dos formas son válidas y la elección tiene consecuencias:
Entrelazado gana cuando el vertex shader usa todos los atributos, porque los tres caen en la misma línea de caché y una sola lectura los trae.
Separado gana cuando hay pases que solo usan algunos. Un pase de profundidad o de sombras solo necesita la posición: si está en su propio buffer, ese pase lee 12 bytes por vértice en vez de 32, y en una escena con mucha geometría eso es un tercio del ancho de banda de la pasada.
El compromiso que usan los motores maduros es tener las posiciones en un buffer y todo lo demás entrelazado en otro, que captura las dos ventajas donde importan.
Hay dos desplazamientos con el mismo nombre y confundirlos produce geometría desplazada. El offset del atributo es dentro de cada vértice y va en el pipeline; el offset de setVertexBuffer(ranura, buffer, offset, size) es dentro del buffer y sirve para empezar a leer más adelante, por ejemplo cuando varias mallas comparten un buffer grande. El segundo tiene que ser múltiplo de 4.
El descriptor está en el código que crea el pipeline. La struct EntradaVS está en el WGSL. Y los bytes los escribe el código que carga la malla, que muchas veces es un importador de glTF que ni siquiera escribiste tú. Tres sitios que tienen que estar de acuerdo en los mismos números, sin que ningún compilador compruebe la relación completa.
El fallo que produce esto es característico y merece reconocerse a la primera: la geometría aparece, pero deformada de una forma que sugiere que los datos están desfasados. Vértices estirados hacia el origen, normales que apuntan en direcciones imposibles, UVs que giran. Nunca es una pantalla negra, que sería más fácil de diagnosticar, porque los bytes están ahí y se interpretan como algo.
Las tres causas, por frecuencia. El arrayStride no coincide con el tamaño real del vértice, con lo que cada vértice lee un poco desplazado y el error se acumula a lo largo del buffer: es el que produce el efecto de espiral. El formato no coincide con lo que hay, típicamente float32x3 sobre datos guardados como float16x4: la posición sale multiplicada por números absurdos. Y el importador entregó los atributos en otro orden, con lo que la normal se lee como posición.
La defensa que funciona es no escribir los números a mano en tres sitios, sino derivar el descriptor y los desplazamientos de una única descripción:
const VERTICE = [ {nombre:'posicion', format:'float32x3', bytes:12}, ... ];
De ahí sale el array attributes con los offsets acumulados, sale el arrayStride con la suma, y sale el código que rellena el buffer. Si además generas la struct de WGSL desde la misma lista, los tres sitios pasan a ser uno y esta clase entera de bug desaparece. Son treinta líneas de utilidad y es lo primero que escribe todo el mundo que ha sufrido esto dos veces.