wandres.dev
MULTISAMPLING · Antialiasing en hardware

La textura multimuestreada, el resolveTarget y las cuatro coherencias

Cómo se crea una textura de cuatro muestras y qué restricciones arrastra, dónde se declara la resolución automática, y cómo se lee una textura multimuestreada desde un shader cuando hace falta resolverla a mano.

⏱ 17 min

Activar el multimuestreo en WebGPU son cuatro cambios que tienen que ocurrir a la vez: la textura de color, la de profundidad, el pipeline y el attachment. Si falta uno, la validación lo rechaza con un mensaje claro, y eso es una suerte porque la alternativa —el comportamiento de WebGL, donde el desajuste producía un framebuffer incompleto y ninguna pista— era considerablemente peor. Lo que la validación no te dice es cuál de las cuatro piezas has olvidado, así que conviene tener el conjunto en la cabeza como una unidad.

🎯 Al terminar esta lección sabrás
  • Crear texturas multimuestreadas de color y profundidad con sus restricciones.
  • Configurar el attachment con resolveTarget y elegir el storeOp correcto.
  • Explicar por qué la profundidad no se puede resolver automáticamente.
  • Leer una textura multimuestreada desde un shader con textureLoad.

Las cuatro piezas

const muestras = 4;

// 1. Textura de color multimuestreada. No se presenta: se resuelve.
const colorMS = device.createTexture({
  label: 'color 4x',
  size: [ancho, alto],
  sampleCount: muestras,
  format: navigator.gpu.getPreferredCanvasFormat(),
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
});

// 2. La profundidad tambien tiene que ser multimuestreada.
const depthMS = device.createTexture({
  label: 'depth 4x',
  size: [ancho, alto],
  sampleCount: muestras,
  format: 'depth32float',
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
});

// 3. El pipeline declara el mismo numero de muestras.
const pipeline = device.createRenderPipeline({
  layout: layoutEscena,
  vertex: { module, entryPoint: 'vs' },
  fragment: { module, entryPoint: 'fs', targets: [{ format }] },
  primitive: { topology: 'triangle-list', cullMode: 'back' },
  depthStencil: { format: 'depth32float',
                  depthWriteEnabled: true, depthCompare: 'less' },
  multisample: { count: muestras },
});

// 4. El attachment apunta a la multimuestreada y resuelve al canvas.
const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: colorMS.createView(),
    resolveTarget: context.getCurrentTexture().createView(),
    clearValue: { r: 0.07, g: 0.07, b: 0.11, a: 1 },
    loadOp: 'clear',
    storeOp: 'discard',        // el contenido multimuestreado no hace falta
  }],
  depthStencilAttachment: {
    view: depthMS.createView(),
    depthClearValue: 1.0,
    depthLoadOp: 'clear',
    depthStoreOp: 'discard',
  },
});

Fíjate en el storeOp: 'discard' del attachment de color. Es correcto y es importante: lo que quieres conservar es el resultado resuelto, que va al resolveTarget, no las cuatro muestras. Guardar además la textura multimuestreada escribe cuatro veces la pantalla a memoria para nada. Es el error de rendimiento más caro de todo el multimuestreo y el más fácil de cometer, porque 'store' parece lo prudente.

Las restricciones de una textura multimuestreada

Una textura con sampleCount mayor que uno no es una textura normal con más datos: es una categoría aparte del API, y arrastra cinco limitaciones.

sampleCount solo admite 1 o 4. No hay valores intermedios ni superiores.

No puede tener mipmaps. mipLevelCount tiene que valer 1. Tiene sentido: los mipmaps son una jerarquía de promedios y las muestras aún no se han promediado.

No puede ser un array ni una textura 3D. depthOrArrayLayers tiene que valer 1. Eso descarta el multimuestreo en las técnicas que renderizan a capas de un array, como los mapas de sombras en cascada empaquetados.

Tiene que llevar RENDER_ATTACHMENT y no puede llevar STORAGE_BINDING. No se puede escribir en ella con textureStore desde un compute.

El formato tiene que admitir multimuestreo. Los formatos habituales de color y profundidad lo admiten; los de 32 bits en coma flotante con cuatro canales y algunos exóticos, no siempre.

Y una más que no es una restricción de la textura sino del uso: la textura del canvas nunca es multimuestreada. Siempre es el destino de la resolución, nunca el origen. Si alguna vez te ves poniendo sampleCount en configure(), es que el modelo mental está torcido.

El resolve y sus reglas

resolveTarget es un campo opcional del attachment de color. Cuando está presente, al terminar el pass el hardware promedia las muestras de cada píxel del view y escribe el resultado en él. La operación es de función fija, ocurre dentro del pass y no cuesta un pass adicional.

Las reglas que comprueba la validación son cuatro y todas evidentes en retrospectiva: el view tiene que tener más de una muestra, el resolveTarget exactamente una, los dos tienen que tener el mismo formato y el mismo tamaño, y el destino tiene que ser renderizable.

Esa igualdad de formatos tiene una consecuencia práctica: si renderizas en rgba16float para tener rango dinámico alto, el destino de la resolución también es rgba16float, y el mapeo de rango a la textura del canvas es una pasada aparte que lee la textura resuelta.

Para la profundidad no hay resolveTarget. El attachment de profundidad y estarcido no tiene ese campo, y no es un olvido: promediar profundidades no significa nada. La media aritmética de la profundidad de un borde entre un objeto cercano y uno lejano es una profundidad intermedia donde no hay ninguna superficie, y usarla para reconstruir posiciones produce geometría fantasma exactamente en los bordes, que es donde más se nota.

Leer las muestras a mano

Cuando necesitas la profundidad multimuestreada en un pass posterior —niebla, oclusión ambiental, reflejos en espacio de pantalla, reconstrucción de posición— la única vía es guardarla y leer las muestras individualmente. En WGSL eso tiene tipos propios:

@group(0) @binding(0) var colorMS : texture_multisampled_2d<f32>;
@group(0) @binding(1) var depthMS : texture_depth_multisampled_2d;

@fragment
fn resolverAMano(@builtin(position) p: vec4f) -> @location(0) vec4f {
  let coord = vec2i(p.xy);
  let n = i32(textureNumSamples(colorMS));

  var suma = vec4f(0.0);
  for (var s = 0; s < n; s++) {
    // textureLoad con indice de muestra: no hay sampler ni filtrado.
    suma += textureLoad(colorMS, coord, s);
  }
  return suma / f32(n);
}

Tres cosas de ese fragmento. Una textura multimuestreada no admite sampler: no hay textureSample, solo textureLoad con coordenadas enteras y un índice de muestra. textureNumSamples devuelve el número de muestras, lo que permite escribir el bucle sin constantes duplicadas. Y este shader reproduce a mano exactamente lo que hace el resolve automático, que es la media aritmética; su utilidad no es sustituirlo sino poder hacer otra cosa.

Resolver antes de mapear el rango dinámico produce puntos brillantes que no desaparecen, y la solución cabe en un resolve a mano

Hay un artefacto que aparece en cuanto un renderizador tiene rango dinámico alto y multimuestreo a la vez, y que la gente atribuye erróneamente al bloom o al modelo de iluminación. Considera un píxel en el borde de una fuente de luz muy brillante: tres de sus cuatro muestras están en el fondo, con luminancia 0,1, y una está en la fuente, con luminancia 4000. La media es 1000, que tras el mapeo de rango sale casi blanco puro. Pero lo correcto perceptualmente es que ese píxel esté cubierto en un cuarto por algo brillante, y el resultado debería estar bastante más cerca del fondo. El problema es de orden de operaciones: el mapeo de rango dinámico no es lineal, y el resolve automático promedia antes de aplicarlo. Promediar y luego comprimir no es lo mismo que comprimir y luego promediar, y la diferencia es exactamente el brillo desproporcionado de los bordes de las fuentes de luz, que además parpadea al mover la cámara porque la cobertura de esa muestra cambia de fotograma a fotograma. Es el mismo fenómeno que produce las «luciérnagas» de un trazador de rayos y tiene la misma naturaleza: una media de valores con varianza enorme. La solución conocida desde hace años en la industria es resolver a mano con una ponderación: en lugar de la media aritmética, se aplica a cada muestra un peso inversamente proporcional a su luminancia, con la forma w = 1 / (1 + luminancia), se hace la media ponderada y se corrige dividiendo por la suma de pesos. Eso comprime el rango antes de promediar, de forma reversible, y elimina el artefacto por completo. Cuesta guardar la textura multimuestreada en lugar de descartarla —el ancho de banda que la sección anterior te decía que no gastaras— y una pasada de pantalla completa. Por eso la decisión correcta depende de la plataforma: en escritorio con contenido HDR, casi siempre compensa; en móvil, casi nunca, y ahí lo que se hace es acotar la luminancia de las fuentes antes de escribirlas, que es más tosco y cuesta cero. Lo que no es una opción es no saber que el artefacto tiene esta causa, porque se persigue en el sitio equivocado durante mucho tiempo.

⚔️ Reto práctico

Dibuja un triángulo blanco sobre fondo negro con y sin multimuestreo y guarda las dos imágenes. Amplía un borde casi horizontal y cuenta los niveles de gris distintos que aparecen: con cuatro muestras deberías ver cinco, incluidos el negro y el blanco. Después repite el experimento con un borde exactamente diagonal a 45 grados y comprueba que ahí los niveles útiles son menos: es la demostración visual de por qué el patrón de muestras está optimizado para bordes casi alineados y no para diagonales.