wandres.dev
RENDER PIPELINE · El estado gráfico completo

Los bloques depthStencil y multisample

Los once campos del bloque de profundidad y stencil con sus valores por defecto, las tres formas del depth bias, y los tres campos que controlan el multimuestreo.

⏱ 20 min

Estos dos bloques configuran las etapas de prueba por fragmento y la resolución por muestra. Son los que más campos tienen y los que más se copian de un ejemplo sin entender, en parte porque sus valores por defecto son razonables y en parte porque el efecto de equivocarse suele ser sutil. Merecen una lectura campo a campo, porque entre los once hay tres que resuelven problemas que de otra forma cuestan días.

🎯 Al terminar esta lección sabrás
  • Escribir un bloque depthStencil completo con sus valores por defecto.
  • Configurar las operaciones de stencil por cara con sus máscaras de lectura y escritura.
  • Usar depthBias, depthBiasSlopeScale y depthBiasClamp para resolver el z-fighting.
  • Configurar multisample de forma coherente con los attachments del pass.

Los campos de depthStencil

depthStencil: {
  format: 'depth24plus-stencil8',   // obligatorio
  depthWriteEnabled: true,
  depthCompare: 'less',
  stencilFront: { compare: 'always', failOp: 'keep',
                  depthFailOp: 'keep', passOp: 'keep' },
  stencilBack:  { compare: 'always', failOp: 'keep',
                  depthFailOp: 'keep', passOp: 'keep' },
  stencilReadMask: 0xFFFFFFFF,
  stencilWriteMask: 0xFFFFFFFF,
  depthBias: 0,
  depthBiasSlopeScale: 0,
  depthBiasClamp: 0,
}

format es el único obligatorio y tiene que ser un formato de profundidad o de stencil. Tiene que coincidir con el formato del depthStencilAttachment del render pass.

depthWriteEnabled decide si el pipeline escribe en el depth buffer. Es obligatorio si el formato tiene componente de profundidad y quieres escribir; si el formato no tiene profundidad, se puede omitir. Ponerlo a false con depthCompare activo es el modo de probar sin escribir, que es exactamente lo que hace falta para dibujar geometría transparente sobre una escena opaca.

depthCompare decide qué comparación pasa el test, con los ocho valores habituales: 'never', 'less', 'equal', 'less-equal', 'greater', 'not-equal', 'greater-equal' y 'always'. Con 'less', el fragmento pasa si su profundidad es menor que la almacenada, que es lo correcto cuando la profundidad crece con la distancia.

Las tres combinaciones que aparecen todo el tiempo:

// Opacos: prueba y escribe.
{ format: 'depth24plus', depthWriteEnabled: true,  depthCompare: 'less' }

// Transparentes: prueba contra lo opaco, no escribe.
{ format: 'depth24plus', depthWriteEnabled: false, depthCompare: 'less' }

// Segundo pase de un pre-pass de profundidad: solo lo exactamente visible.
{ format: 'depth24plus', depthWriteEnabled: false, depthCompare: 'equal' }

Las operaciones de stencil

stencilFront y stencilBack describen qué hacer con el valor de stencil según el resultado de las pruebas, separado por orientación de la cara. Cada uno tiene cuatro campos.

compare es la comparación entre el valor de referencia —fijado con setStencilReference() en el pass, no en el pipeline— y el valor almacenado, ambos enmascarados con stencilReadMask. Por defecto es 'always'.

failOp es la operación si la prueba de stencil falla. depthFailOp es la operación si la de stencil pasa y la de profundidad falla. passOp es la operación si pasan las dos. Los ocho valores posibles son 'keep' (por defecto), 'zero', 'replace', 'invert', 'increment-clamp', 'decrement-clamp', 'increment-wrap' y 'decrement-wrap'.

'replace' escribe el valor de referencia. Las variantes -clamp sujetan en los extremos; las -wrap dan la vuelta.

stencilReadMask y stencilWriteMask valen 0xFFFFFFFF por defecto y permiten trabajar con subcampos de bits dentro del byte de stencil: los tres bits bajos para una máscara de recorte, los cinco altos para un identificador de objeto, por ejemplo.

El uso canónico son dos pipelines, uno que marca y otro que dibuja dentro de la marca:

// Pipeline 1: escribe 1 en el stencil donde dibuje, sin tocar el color.
const marcar = {
  format: 'depth24plus-stencil8',
  depthWriteEnabled: false, depthCompare: 'always',
  stencilFront: { compare: 'always', passOp: 'replace',
                  failOp: 'keep', depthFailOp: 'keep' },
  stencilBack:  { compare: 'always', passOp: 'replace',
                  failOp: 'keep', depthFailOp: 'keep' },
  stencilWriteMask: 0xFF,
};

// Pipeline 2: dibuja solo donde el stencil vale 1.
const dentro = {
  format: 'depth24plus-stencil8',
  depthWriteEnabled: true, depthCompare: 'less',
  stencilFront: { compare: 'equal', passOp: 'keep',
                  failOp: 'keep', depthFailOp: 'keep' },
  stencilBack:  { compare: 'equal', passOp: 'keep',
                  failOp: 'keep', depthFailOp: 'keep' },
  stencilReadMask: 0xFF, stencilWriteMask: 0x00,
};

Y en el pass, entre los dos, pass.setStencilReference(1). El valor de referencia se inicializa a 0 al empezar cada render pass.

ℹ️
Si tocas el stencil, el formato tiene que tenerlo

La validación comprueba la coherencia: si stencilFront o stencilBack tienen algún valor distinto del defecto, el format tiene que tener componente de stencil. Y si depthWriteEnabled es true o depthCompare es distinto de 'always', el formato tiene que tener componente de profundidad. Los errores por esta vía son claros y aparecen al crear el pipeline.

Las tres formas del depth bias

El z-fighting entre superficies casi coplanares —un decal sobre una pared, una sombra proyectada sobre un suelo— se resuelve desplazando ligeramente la profundidad del fragmento. WebGPU ofrece tres parámetros que se suman.

depthBias es un desplazamiento constante en unidades del formato de profundidad. Para un formato de enteros normalizados, una unidad es el incremento mínimo representable; para un formato float, se calcula a partir del exponente máximo del primitivo.

depthBiasSlopeScale multiplica la pendiente máxima del primitivo respecto a la pantalla. Es el término que importa de verdad: un triángulo muy inclinado respecto a la cámara cubre un rango de profundidad mucho mayor por píxel, así que necesita más sesgo que uno frontal. Un sesgo constante que funcione para el triángulo inclinado será excesivo para el frontal, y viceversa.

depthBiasClamp acota el sesgo total resultante, para que las pendientes extremas no produzcan desplazamientos absurdos que despeguen la geometría.

Los tres valen 0 por defecto, y hay una restricción concreta: tienen que valer 0 con topologías de línea y de punto'line-list', 'line-strip' y 'point-list'—. Si no, el pipeline es inválido.

El caso donde más se usa es el mapa de sombras, para eliminar el acné de sombra:

const pipelineSombra = device.createRenderPipeline({
  layout: layoutSombra,
  vertex: { module, buffers: [posiciones] },
  primitive: { topology: 'triangle-list', cullMode: 'front' },
  depthStencil: {
    format: 'depth32float',
    depthWriteEnabled: true,
    depthCompare: 'less',
    depthBias: 2,
    depthBiasSlopeScale: 2.5,
    depthBiasClamp: 0.01,
  },
});

El sesgo aplicado en el pipeline de sombras es preferible al sesgo restado a mano en el shader de iluminación, porque el hardware lo aplica proporcionalmente a la pendiente y el shader no tiene esa información. Aun así, la combinación de ambos —un sesgo pequeño en el pipeline y otro pequeño en el shader— es lo que suele acabar funcionando en escenas variadas.

El bloque multisample

Tres campos, todos con valor por defecto.

count es el número de muestras por píxel y solo admite 1 o 4. Tiene que coincidir con el sampleCount de todos los attachments del render pass donde se use el pipeline. Es el error más frecuente al activar MSAA: se crea la textura multimuestreada y se olvida cambiar el pipeline, o al revés.

mask es una máscara de bits que decide qué muestras se escriben, con 0xFFFFFFFF por defecto. Poner 0b0101 con count: 4 escribe solo las muestras 0 y 2, lo que sirve para técnicas de renderizado a resolución variable.

alphaToCoverageEnabled convierte el alfa del fragmento en una máscara de cobertura: un alfa de 0,5 con cuatro muestras escribe dos de las cuatro. Es una forma de tener transparencia sin ordenar y sin blending, muy usada para vegetación y para vallas. Requiere que el pipeline tenga un target con canal alfa, es decir, que la salida del fragment shader sea un vec4.

// Pipeline de vegetación con MSAA y transparencia por cobertura.
multisample: {
  count: 4,
  mask: 0xFFFFFFFF,
  alphaToCoverageEnabled: true,
}
El coste de MSAA no es el que la gente cree, y por eso se descarta mal

La intuición dice que MSAA a 4x cuadruplica el coste, y por eso mucha gente lo descarta y usa antialiasing de post-proceso. La realidad es más interesante. MSAA ejecuta el fragment shader una vez por píxel cubierto, no una vez por muestra: lo que se multiplica por cuatro es el almacenamiento de profundidad y de color, y las pruebas de cobertura, que son hardware fijo. En un shader pesado —un PBR completo con varias luces— el coste añadido de MSAA a 4x puede ser del 10 o el 20 %, no del 300. Lo que sí se cuadruplica es la memoria de los attachments y el ancho de banda del resolve, y ahí está el verdadero coste, que en móvil con memoria compartida sí duele y en escritorio a menudo no. La excepción es cuando el fragment shader usa @builtin(sample_index) o la interpolación por muestra, porque entonces sí se ejecuta una vez por muestra. Y la limitación real de MSAA no es el rendimiento sino que no funciona en un renderer diferido: el G-buffer multimuestreado obliga a resolver la iluminación por muestra, que es lo que sí cuadruplica el coste. Por eso los motores diferidos usan TAA o FXAA, y los directos usan MSAA. La elección de arquitectura de render decide la de antialiasing, no al revés.