wandres.dev
TEXTURAS I · Cargar, mapear y repetir

Anisotropía: el suelo que deja de verse borroso

Por qué un mipmap emborrona las superficies vistas en ángulo, cómo lo arregla el filtrado anisótropo, y cuál es el límite que impone el dispositivo.

⏱ 15 min

Con mipmaps bien configurados el suelo deja de hervir, pero aparece el otro artefacto: la textura se vuelve papilla mucho antes de lo que debería, y a media distancia ya no se distingue el dibujo. No es falta de resolución ni un mipmap mal generado. Es que un mipmap promedia cuadrados y la huella de un píxel sobre un suelo en perspectiva no es un cuadrado.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la huella de un fragmento sobre una superficie inclinada es alargada.
  • Describir qué hace de más el filtrado anisótropo respecto al trilineal.
  • Consultar el límite del dispositivo y aplicarlo sin romperse cuando no existe.
  • Estimar el coste real y decidir el valor adecuado.

La huella no es un cuadrado

Cuando una superficie está de frente a la cámara, un píxel de pantalla se proyecta sobre la textura como un cuadrado aproximado. El mipmap está diseñado exactamente para eso: cada nivel promedia cuadrados de dos por dos.

Cuando la superficie está inclinada, la proyección se deforma. En un suelo visto casi de canto, un píxel puede abarcar dos téxeles de ancho y cincuenta de largo. El promedio correcto sería el de esa franja de dos por cincuenta, pero no hay ningún nivel de mipmap que contenga ese promedio: los niveles solo tienen cuadrados de dos por dos, de cuatro por cuatro, de ocho por ocho.

La GPU tiene que elegir un nivel y solo puede equivocarse de dos maneras. Si elige el nivel que corresponde a la dirección corta, promedia demasiado poco a lo largo y vuelve el aliasing. Si elige el que corresponde a la dirección larga, promedia demasiado a lo ancho y la imagen sale borrosa. El hardware elige lo segundo, porque el desenfoque molesta menos que el hervido.

Ese es el aspecto que reconoces de cualquier suelo en un motor sin anisotropía: nítido justo bajo los pies, papilla dos metros más allá.

Qué hace el filtrado anisótropo

En lugar de una lectura en un nivel demasiado bajo, el filtrado anisótropo toma varias muestras a lo largo del eje mayor de la huella, en un nivel de mipmap elegido según el eje menor. Ocho muestras a lo largo de la franja aproximan el promedio real mucho mejor que una sola muestra de un cuadrado gigante.

El número máximo de muestras es lo que configuras en texture.anisotropy. Vale 1 por defecto, que significa filtrado isótropo normal. Subirlo a 4, 8 o 16 permite al hardware tomar hasta esa cantidad de muestras cuando la huella lo justifique.

La palabra clave es cuando lo justifique. El hardware calcula la relación entre el eje mayor y el menor de la huella y toma tantas muestras como esa relación pida, hasta el máximo que hayas puesto. En una pared vista de frente la relación es uno y toma una muestra: la anisotropía no cuesta nada donde no hace falta. Por eso es de las opciones con mejor relación entre calidad y coste que existen, muy por delante de subir la resolución de la textura.

Tampoco cuesta memoria: no hay datos adicionales, solo más lecturas de los mipmaps que ya existen. Y necesita esos mipmaps: con minFilter puesto en NearestFilter o LinearFilter, que son los que no los usan, la anisotropía no tiene sobre qué trabajar.

El límite del dispositivo

El máximo que admite la GPU se consulta a través del renderer:

import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });

const maximo = renderer.capabilities.getMaxAnisotropy();
console.log('anisotropia maxima soportada:', maximo);

const suelo = new THREE.TextureLoader().load('/texturas/parquet.jpg');
suelo.colorSpace = THREE.SRGBColorSpace;
suelo.wrapS = suelo.wrapT = THREE.RepeatWrapping;
suelo.repeat.set(16, 16);
suelo.anisotropy = Math.min(8, Math.max(1, maximo));

Ese Math.max(1, ...) no es paranoia. La implementación real es esta:

function getMaxAnisotropy() {
  if ( maxAnisotropy !== undefined ) return maxAnisotropy;
  if ( extensions.has( 'EXT_texture_filter_anisotropic' ) === true ) {
    const extension = extensions.get( 'EXT_texture_filter_anisotropic' );
    maxAnisotropy = gl.getParameter( extension.MAX_TEXTURE_MAX_ANISOTROPY_EXT );
  } else {
    maxAnisotropy = 0;
  }
  return maxAnisotropy;
}

Si la extensión no está disponible, devuelve cero, no uno. Un Math.min(8, maximo) sin más te deja la anisotropía en cero, que no es un valor válido. La extensión está prácticamente en todas partes, pero “prácticamente” no es “siempre”, y el fallo solo aparecería en el dispositivo del usuario.

En cuanto al valor razonable: la mayoría del hardware moderno reporta 16. Subir de 8 a 16 es una diferencia casi imperceptible que dobla las lecturas en las zonas más inclinadas, así que 8 es el valor sensato por defecto y 4 una elección conservadora en móvil.

DEFAULT_ANISOTROPY se lee en el constructor, no en el render

Texture tiene una propiedad estática, Texture.DEFAULT_ANISOTROPY, que vale 1 y que el constructor usa como valor inicial de todas las texturas nuevas. Cambiarla es la forma limpia de aplicar anisotropía a todo el proyecto sin tocar cada textura, incluidas las que crean los cargadores por dentro y a las que no tienes acceso fácil:

THREE.Texture.DEFAULT_ANISOTROPY = Math.min(8, Math.max(1, renderer.capabilities.getMaxAnisotropy()));

Ahora la parte que cuesta una tarde. Ese valor se lee en el constructor de cada textura, así que solo afecta a las que se creen a partir de ese momento. Si asignas la estática después de cargar el modelo, las texturas del modelo se quedan en uno y la línea no hace nada visible. La asignación tiene que ir antes de crear ningún cargador.

Y hay un segundo momento crítico: los parámetros de muestreo se aplican cuando la textura se sube a la GPU. Cambiar anisotropy en una textura que ya se ha dibujado no surte efecto hasta que marques texture.needsUpdate = true. Es el mismo motivo por el que cambiar minFilter a mitad de ejecución parece no funcionar. Si estás construyendo un panel de ajustes de calidad que permite subir y bajar la anisotropía en caliente, tienes que recorrer las texturas y marcarlas todas.

⚔️ Reto práctico

Monta un plano grande con una textura de damero repetida cincuenta veces y la cámara casi a ras de suelo. Captura la misma imagen con anisotropy en 1, 2, 4, 8 y 16, y mide el tiempo de fotograma en cada caso. Localiza a partir de qué valor la diferencia visual deja de justificar el coste en tu GPU, y repite la medición con la cámara mirando el plano de frente para comprobar que ahí el coste es el mismo en los cinco casos.