El sampler: filtros, modos de dirección y comparación
Los nueve campos de createSampler, la diferencia entre magFilter y minFilter, el clamp de LOD, la anisotropía y el sampler de comparación que hace posibles las sombras suaves.
El sampler es un objeto separado de la textura, y esa separación —que WebGL tenía a medias— permite muestrear la misma textura de varias formas sin duplicarla. Sus nueve campos controlan tres cosas distintas: qué pasa cuando pides una coordenada fuera del rango, cómo se combinan los texels cuando la muestra no cae en un texel exacto, y cómo se elige el nivel de mip. El noveno campo, compare, cambia el tipo del objeto y desbloquea las sombras.
- Escribir un descriptor de sampler completo y conocer todos sus valores por defecto.
- Distinguir
magFilterdeminFilterdemipmapFilterpor lo que cada uno decide. - Usar
lodMinClamp,lodMaxClampymaxAnisotropycon criterio. - Crear un sampler de comparación y usarlo con
textureSampleComparepara sombras.
Los nueve campos
const sampler = device.createSampler({
label: 'trilineal repetido',
addressModeU: 'repeat', // por defecto 'clamp-to-edge'
addressModeV: 'repeat',
addressModeW: 'clamp-to-edge',
magFilter: 'linear', // por defecto 'nearest'
minFilter: 'linear', // por defecto 'nearest'
mipmapFilter: 'linear', // por defecto 'nearest'
lodMinClamp: 0, // por defecto 0
lodMaxClamp: 32, // por defecto 32
maxAnisotropy: 1, // por defecto 1
// compare: undefined -> sampler normal; si se pone, de comparación
});
Los valores por defecto de los tres filtros son 'nearest', no 'linear'. Es la causa del clásico mi textura se ve pixelada y no sé por qué: createSampler() sin argumentos devuelve un sampler de vecino más cercano, sin filtrado de mips.
Los tres modos de dirección corresponden a las tres coordenadas de textura y admiten tres valores. 'clamp-to-edge' sujeta las coordenadas al rango [0, 1], de modo que fuera del rango se repite el texel del borde. 'repeat' envuelve, tomando la parte fraccionaria. 'mirror-repeat' envuelve invirtiendo la imagen cuando la parte entera de la coordenada es impar. addressModeW solo tiene efecto con texturas 3D.
Los tres filtros y qué decide cada uno
magFilter se aplica cuando la muestra es más grande que un texel: estás mirando la textura de cerca y un texel cubre varios píxeles. Con 'nearest' obtienes el aspecto de píxel duro; con 'linear', interpolación bilineal entre los cuatro texels vecinos.
minFilter se aplica cuando la muestra es más pequeña que un texel: la textura está lejos y varios texels caen dentro de un píxel. Aquí 'nearest' produce aliasing severo —el patrón de moiré característico de un suelo de baldosas en la distancia— porque se toma un texel arbitrario de los muchos que cubre el píxel.
mipmapFilter decide qué pasa entre dos niveles de mip. Con 'nearest' se usa el nivel más próximo y se ve la costura donde cambian; con 'linear' se interpolan los dos niveles adyacentes, que es lo que se llama filtrado trilineal.
La combinación linear, linear, linear es el punto de partida razonable para texturas de material. Para atlas de fuentes y para arte de píxel deliberado, nearest en magnificación y linear en minificación es lo correcto: bordes duros de cerca, sin aliasing de lejos.
Un sampler con cualquier filtro en 'linear' es de tipo 'filtering', y la validación exige que la textura que muestrea tenga sampleType: 'float', no 'unfilterable-float'. Muestrear un rgba32float con filtro lineal falla salvo que el dispositivo tenga la feature float32-filterable. El error aparece al crear el bind group o el pipeline, no al muestrear.
El clamp de LOD y la anisotropía
lodMinClamp y lodMaxClamp acotan el nivel de detalle que el hardware puede elegir. Por defecto van de 0 a 32, que en la práctica es sin límite.
Sirven para dos cosas concretas. La primera es forzar un nivel: lodMinClamp: 3, lodMaxClamp: 3 obliga a muestrear siempre el nivel 3, que es una forma barata de tener una versión desenfocada de una textura sin generar otra. La segunda es evitar el nivel 0 en texturas donde el detalle máximo produce aliasing y no aporta: en un mapa de rugosidad muy detallado, lodMinClamp: 1 puede ser una mejora visual.
maxAnisotropy va de 1 a un máximo dependiente del dispositivo, típicamente 16. El filtrado anisotrópico resuelve el caso de las superficies vistas en ángulo rasante, donde la huella del píxel en el espacio de textura es un rectángulo alargado y no un cuadrado: el filtrado normal elige un mip lo bastante bajo para cubrir el lado largo y desenfoca de más el lado corto. La anisotropía toma varias muestras a lo largo del eje largo y mantiene la nitidez.
La validación exige que si maxAnisotropy es mayor que 1, los tres filtros estén en 'linear'. Y el valor se sujeta al máximo de la plataforma sin dar error, así que pedir 16 en un dispositivo que soporta 8 devuelve un sampler con 8.
// El sampler de un suelo o de una pared vista en ángulo.
const anisotropico = device.createSampler({
addressModeU: 'repeat', addressModeV: 'repeat',
magFilter: 'linear', minFilter: 'linear', mipmapFilter: 'linear',
maxAnisotropy: 16,
});
El coste de la anisotropía es de ancho de banda y se paga por muestra, no por textura. En un shader con seis muestras por fragmento, subir de 1 a 16 puede multiplicar el tráfico de texturas. Aplícalo a las texturas donde se nota —suelos, paredes, terreno— y no a las que siempre se ven de frente.
El sampler de comparación
Poner el campo compare cambia la naturaleza del objeto: pasa a ser un sampler de comparación, con type: 'comparison' en el bind group layout y sampler_comparison en WGSL. Ya no devuelve el valor de la textura: devuelve el resultado de comparar un valor de referencia que tú pasas con el valor almacenado, filtrado entre los texels vecinos.
Es exactamente el mecanismo que hace suaves los bordes de las sombras. Un mapa de sombras guarda profundidades; comparar la profundidad del fragmento con la almacenada da un booleano en sombra o iluminado; interpolar booleanos no tiene sentido, pero interpolar el resultado de cuatro comparaciones sí, y produce un borde antialiasado. El hardware lo hace en una instrucción, y se llama filtrado de comparación percentual.
const cmpSombra = device.createSampler({
label: 'comparacion sombras',
compare: 'less', // el fragmento está iluminado si su z es menor
magFilter: 'linear',
minFilter: 'linear',
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge',
});
Los ocho valores de compare son 'never', 'less', 'equal', 'less-equal', 'greater', 'not-equal', 'greater-equal' y 'always'.
Y en el shader, con textureSampleCompare en el fragment shader —hay una variante textureSampleCompareLevel que fuerza el nivel 0 y es la que se usa fuera de la etapa de fragmento o bajo control de flujo no uniforme—:
@group(0) @binding(2) var mapaSombra : texture_depth_2d;
@group(0) @binding(3) var cmpSombra : sampler_comparison;
fn visibilidad(posLuz : vec3<f32>) -> f32 {
// De clip space a coordenadas de textura: xy a [0,1] con la Y invertida.
let uv = posLuz.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5, 0.5);
let z = posLuz.z - 0.002; // sesgo contra el acné de sombra
// Media de 3x3 muestras, cada una ya filtrada por el hardware.
var suma = 0.0;
let paso = 1.0 / 2048.0;
for (var y = -1; y <= 1; y = y + 1) {
for (var x = -1; x <= 1; x = x + 1) {
suma = suma + textureSampleCompare(
mapaSombra, cmpSombra, uv + vec2<f32>(f32(x), f32(y)) * paso, z);
}
}
return suma / 9.0;
}
Cada textureSampleCompare devuelve un f32 entre 0 y 1: no un booleano, sino la fracción de los texels vecinos que pasan la comparación. Nueve de esas muestras, promediadas, dan un borde suave de 3×3 con nueve instrucciones en vez de las treinta y seis que costaría hacerlo a mano.
Un renderer completo necesita entre tres y seis samplers distintos: trilineal repetido, trilineal sujeto, anisotrópico repetido, de vecino más cercano para atlas y de comparación para sombras. Eso es todo. Los cientos de texturas del proyecto los comparten. Sin embargo, el patrón que sale solo al escribir un cargador de materiales es crear el sampler junto a la textura, dentro de la misma función, y acabar con trescientos samplers idénticos. El coste no es de memoria: es que cada sampler distinto es una entrada distinta en el bind group, así que dos materiales que difieren solo en el objeto sampler no pueden compartir nada, y encima el hardware tiene un número limitado de estados de sampler que puede tener activos —en algunas arquitecturas, dieciséis— tras el cual empieza a intercambiarlos. La solución es un caché de samplers indexado por la serialización del descriptor: cinco líneas que convierten trescientos objetos en cinco.
Monta un caché de samplers: una función obtenerSampler(device, descriptor) que use JSON.stringify del descriptor normalizado como clave de un Map y devuelva siempre la misma instancia para descriptores equivalentes. Después cuenta cuántos samplers distintos usa realmente tu escena; casi seguro que son menos de cinco.