El bloque primitive: topología, orientación y descarte
Las cinco topologías y cuándo compensa una tira, por qué stripIndexFormat existe, la relación entre frontFace y cullMode, y qué hace unclippedDepth.
El bloque primitive es el más pequeño del descriptor y controla la etapa que está entre el vertex shader y el rasterizador: cómo se agrupan los vértices en primitivas y cuáles se tiran a la basura antes de gastar un solo fragmento. El descarte por orientación es, en una escena cerrada, la optimización que elimina la mitad de los triángulos sin que se note, y depende de dos campos que hay que entender juntos.
- Elegir la topología correcta y calcular cuántas primitivas produce cada una.
- Explicar por qué las tiras exigen
stripIndexFormaten dibujado indexado. - Configurar
frontFaceycullModede forma coherente con el winding de tus mallas. - Saber qué hace
unclippedDepthy qué feature necesita.
Las cinco topologías
topology decide cómo se agrupan los vértices, y su valor por defecto es 'triangle-list'.
| Valor | Primitivas con N vértices | Uso |
|---|---|---|
'point-list' |
N puntos | depuración, sistemas de partículas simples |
'line-list' |
N/2 líneas | gizmos, wireframe explícito |
'line-strip' |
N-1 líneas | trazos y curvas continuas |
'triangle-list' |
N/3 triángulos | prácticamente todo |
'triangle-strip' |
N-2 triángulos | quads y mallas en tira |
Las tiras ahorran vértices: una malla en tira de N triángulos necesita N+2 vértices en vez de 3N. Para geometría regular —un terreno en filas, un cilindro— el ahorro es real y llega a ser de casi tres veces.
En la práctica, sin embargo, 'triangle-list' domina por dos razones. La primera es que el caché de vértices post-transformación de las GPUs modernas ya elimina la mayoría de la redundancia de una lista indexada bien ordenada, así que la ventaja de la tira se reduce mucho. La segunda es que una malla arbitraria no se descompone en una sola tira, y las tiras múltiples exigen o varios draws o el valor de reinicio de primitiva.
'triangle-strip' sigue siendo la elección correcta para un caso concreto y muy frecuente: dibujar un quad con cuatro vértices sin índices, que es lo que se usa para sprites, para partículas y para pasadas de pantalla completa.
stripIndexFormat y el reinicio de primitiva
Este campo solo tiene sentido con topologías de tira, y su existencia responde a un problema concreto. Cuando dibujas varias tiras con un solo draw indexado, necesitas un valor de índice especial que signifique aquí acaba una tira y empieza otra. Ese valor es el máximo representable del tipo de índice: 0xFFFF para uint16 y 0xFFFFFFFF para uint32.
El problema es que ese valor depende del tipo de índice, y el tipo de índice se pasa en setIndexBuffer, en tiempo de dibujado, mientras que el pipeline se crea antes. stripIndexFormat resuelve la ambigüedad declarando en el pipeline cuál va a ser:
primitive: {
topology: 'triangle-strip',
stripIndexFormat: 'uint32', // el valor de reinicio será 0xFFFFFFFF
}
Las reglas son estrictas. Un pipeline con topología de tira que se use en un draw indexado tiene que declarar stripIndexFormat, y ese formato tiene que coincidir con el que se pasa a setIndexBuffer. Un pipeline con topología de lista no debe declararlo: las listas no tienen reinicio de primitiva, y el formato de índice se toma del setIndexBuffer sin más.
Con reinicio de primitiva, cien tiras se dibujan con un solo drawIndexed. Sin él, hacen falta cien llamadas. La diferencia importa cuando el número de tiras es alto, que es el caso de un terreno o de un sistema de hierba. Para geometría normal, el instancing con 'triangle-list' resuelve el mismo problema mejor.
Orientación y descarte
frontFace y cullMode
Estos dos campos trabajan juntos y hay que leerlos como una unidad.
frontFace declara qué orientación de vértices se considera la cara frontal de un triángulo, mirándolo desde la cámara. 'ccw' —antihorario, el valor por defecto— es la convención de OpenGL y de la mayoría de las herramientas de contenido. 'cw' —horario— es la de Direct3D.
cullMode declara qué caras se descartan: 'none' (por defecto, no se descarta nada), 'front' o 'back'.
La configuración habitual para geometría cerrada es frontFace: 'ccw' con cullMode: 'back': las caras traseras de un objeto sólido nunca se ven, así que descartarlas antes de rasterizar ahorra la mitad de los fragmentos. Es la optimización de dos líneas más rentable del pipeline, y no cuesta nada porque el descarte ocurre en hardware fijo antes del rasterizador.
primitive: {
topology: 'triangle-list',
frontFace: 'ccw',
cullMode: 'back',
}
El síntoma de tener el frontFace invertido respecto al winding de tus mallas es inconfundible: ves el interior de los objetos. Las caras que deberían estar delante se descartan y las de atrás se dibujan, produciendo un aspecto de objeto hueco visto desde dentro. Antes de tocar las mallas, prueba a cambiar frontFace o a poner cullMode: 'none' para confirmar el diagnóstico.
Hay tres causas frecuentes de winding invertido. Una matriz de escala con un número impar de componentes negativos invierte la orientación de todos los triángulos del objeto —un espejo por escala de -1 en X necesita el cullMode contrario—. Un exportador que usa otra convención de sistema de coordenadas. Y una matriz de proyección con la Y invertida, que en WebGPU es frecuente porque el espacio de clip tiene la Y hacia arriba mientras las coordenadas de framebuffer la tienen hacia abajo.
Los tres campos no tienen efecto sobre 'point-list', 'line-list' ni 'line-strip': una línea no tiene orientación.
Los casos donde no se descarta
Hay geometría que necesita cullMode: 'none' y conviene reconocerla, porque el instinto de poner 'back' en todas partes produce objetos que desaparecen.
Superficies de un solo polígono. Hojas, hierba, tela, banderas, decals sobre una superficie. No son sólidos cerrados y se ven por los dos lados.
Interiores. Un skybox dibujado como cubo se ve desde dentro, así que la cara que hay que dibujar es la trasera: o cullMode: 'front' o invertir el winding de la malla.
Geometría transparente. Un cristal o un líquido a menudo se dibuja en dos pases, primero las caras traseras y después las frontales, para que el orden de composición sea correcto.
Para las superficies de un solo polígono con iluminación, además de desactivar el descarte hay que invertir la normal en el fragment shader cuando se ve por detrás, y WGSL da el builtin necesario:
@fragment
fn fs(entrada : Interpolado, @builtin(front_facing) frontal : bool)
-> @location(0) vec4<f32> {
let n = select(-entrada.normal, entrada.normal, frontal);
// ... iluminar con n ...
}
unclippedDepth
unclippedDepth vale false por defecto y desactiva, cuando se pone a true, el recorte por profundidad: los fragmentos con una profundidad fuera del rango [0, 1] dejan de descartarse y se sujetan al rango en vez de eliminarse.
Requiere la feature depth-clip-control, y sin ella el campo produce un error de validación. Se pide como cualquier otra:
const device = await adapter.requestDevice({
requiredFeatures: adapter.features.has('depth-clip-control')
? ['depth-clip-control'] : [],
});
Su uso principal son los volúmenes de sombra y las técnicas de stencil que necesitan que la geometría que sobresale del frustum siga generando fragmentos en vez de cortarse por el plano cercano. También sirve para renderizar geometría muy cercana a la cámara sin que se recorte, en aplicaciones de CAD o de primera persona con armas muy próximas.
Todo el mundo sabe que cullMode: 'back' ahorra la mitad de los fragmentos. Lo que casi nadie tiene en cuenta es que también decide si el depth test funciona bien. En una escena sin descarte, cada superficie de un objeto sólido se rasteriza dos veces —la cara frontal y la trasera— y ambas compiten por el depth buffer. Con el orden de dibujado desfavorable, la cara trasera se escribe primero, la frontal la sobrescribe, y todo funciona pero has gastado el doble de escrituras de profundidad y el doble de ejecuciones del fragment shader. Con el descarte activo, ese trabajo desaparece antes de llegar al rasterizador. Y hay un tercer efecto, más sutil: sin descarte, las caras traseras y frontales de una superficie coplanar tienen exactamente la misma profundidad, y el resultado del depth test entre ellas depende de la precisión, lo que produce z-fighting en superficies de grosor cero. Es la razón por la que un plano dibujado sin descarte a veces parpadea y con descarte no. Cuando algo parpadea de forma inexplicable, cullMode es de las primeras cosas que mirar.