wandres.dev
TEXTURAS I · Cargar, mapear y repetir

Filtrado y mipmaps: el problema que resuelven de verdad

Qué pasa cuando un píxel cubre cien téxeles, por qué el resultado hierve al mover la cámara, y cómo una pirámide de imágenes precalculadas lo arregla.

⏱ 19 min

El filtrado de texturas suena a ajuste de calidad opcional y no lo es: es la solución a un problema de muestreo que, sin resolver, produce el artefacto más molesto que existe en tiempo real. Un suelo con textura visto en perspectiva sin mipmaps no se ve simplemente peor, se ve hirviendo: los píxeles lejanos parpadean y reptan con cada movimiento de cámara, y ninguna cantidad de resolución lo arregla.

🎯 Al terminar esta lección sabrás
  • Distinguir magnificación de minificación y qué filtro gobierna cada una.
  • Explicar el aliasing por minificación en términos de muestreo insuficiente.
  • Describir qué es un mipmap, qué cuesta en memoria y cómo elige la GPU el nivel.
  • Elegir la combinación de minFilter y magFilter adecuada a cada uso.

Dos problemas opuestos

Cuando la GPU tiene que decidir el color de un fragmento a partir de una textura, se puede encontrar en dos situaciones opuestas.

Magnificación: el fragmento cubre menos de un téxel. La textura está estirada. La pregunta es qué color poner entre dos téxeles, y la gobierna magFilter, que solo admite dos valores, NearestFilter y LinearFilter. El defecto es LinearFilter, que interpola bilinealmente entre los cuatro téxeles vecinos y produce el suavizado que esperas. NearestFilter devuelve el téxel más cercano y produce píxeles cuadrados nítidos, que es exactamente lo que quieres para arte de píxeles o para una tabla de consulta.

Minificación: el fragmento cubre muchos téxeles. La textura está comprimida. La gobierna minFilter, y aquí el problema es mucho más grave de lo que parece.

Por qué la minificación produce hervido

Imagina un suelo de damero que se aleja hacia el horizonte. Cerca de la cámara, un píxel de pantalla cubre medio téxel. En el horizonte, ese mismo píxel cubre cien téxeles, cincuenta blancos y cincuenta negros.

El color correcto de ese píxel es la media de los cien téxeles, un gris. Pero un muestreo puntual, incluso bilineal, solo mira uno o cuatro téxeles: los que caen bajo el centro del píxel. Con suerte cae en un blanco y el píxel sale blanco; con otra suerte cae en un negro y sale negro. El píxel de al lado hace lo mismo con un resultado arbitrario distinto.

Eso ya es feo en una imagen fija: aparecen patrones de moiré, rejillas fantasma que no están en la textura. Pero lo insoportable llega con el movimiento. Al desplazar la cámara medio píxel, el centro de cada fragmento cae sobre un téxel distinto, y todos los píxeles del horizonte cambian de color a la vez de forma incoherente. La imagen hierve.

En términos de teoría de señales es aliasing puro: estás muestreando una señal cuya frecuencia espacial supera con mucho la frecuencia de muestreo, y por debajo de Nyquist las frecuencias altas se pliegan y aparecen como frecuencias bajas falsas. El moiré es literalmente eso, una frecuencia baja que no existe en el original.

La solución teórica es filtrar la señal antes de muestrear, es decir, promediar los cien téxeles. Hacerlo en tiempo de ejecución cuesta cien lecturas por fragmento, lo cual es inviable. Hacerlo por adelantado cuesta muy poco. Eso es un mipmap.

La pirámide

Un mipmap es una cadena de versiones de la misma imagen, cada una la mitad de ancha y de alta que la anterior, hasta llegar a un téxel. Cada téxel de un nivel es el promedio de cuatro del nivel anterior, así que consultar un nivel bajo equivale a leer un promedio ya calculado de una región grande.

El coste en memoria es sorprendentemente pequeño. La suma de la serie de cuartos converge a un tercio:

1 + 1/4 + 1/16 + 1/64 + ... = 4/3

Un mipmap completo ocupa un 33 por ciento más que la imagen base. Para una textura de 1024 por 1024 en RGBA de ocho bits, eso son 4 megabytes que pasan a 5,3.

Y aquí viene lo contraintuitivo: activar mipmaps casi siempre hace el render más rápido, no más lento. Cuando un objeto está lejos, la GPU lee de un nivel pequeño que cabe entero en la caché de texturas, en lugar de saltar por posiciones dispersas de una imagen de 4 megabytes. El ahorro de ancho de banda de memoria supera con creces el 33 por ciento extra de almacenamiento.

Three.js los genera solo: generateMipmaps vale true por defecto y la cadena se construye al subir la textura a la GPU.

Elegir el nivel, y los cuatro filtros de minificación

La GPU decide qué nivel usar comparando cuánto cambia la coordenada UV entre píxeles vecinos de la pantalla. Ese cálculo son las derivadas en pantalla de la UV, y de ahí sale un número continuo que indica qué nivel corresponde.

Como ese número es continuo y los niveles son discretos, hay dos decisiones que tomar, y de ahí salen los cuatro filtros de minificación:

filtro dentro del nivel entre niveles
NearestMipmapNearestFilter téxel más cercano nivel más cercano
LinearMipmapNearestFilter bilineal nivel más cercano
NearestMipmapLinearFilter téxel más cercano interpola dos niveles
LinearMipmapLinearFilter bilineal interpola dos niveles

El último es el filtrado trilineal y es el valor por defecto de minFilter en Three.js. Cuesta ocho lecturas en lugar de cuatro y a cambio elimina la frontera visible entre niveles, que sin interpolación aparece como un anillo o una banda donde la nitidez cambia de golpe.

Los otros dos valores admitidos, NearestFilter y LinearFilter a secas, no usan mipmaps en absoluto, y ponerlos en minFilter es la forma de desactivarlos. Es lo que quieres exactamente en dos casos: una textura que siempre se ve al mismo tamaño en pantalla, como un elemento de interfaz, y una tabla de consulta donde promediar valores no tiene sentido, como el gradientMap de un material toon.

import * as THREE from 'three';

// Textura de superficie normal: trilineal, mipmaps, repeticion.
const suelo = new THREE.TextureLoader().load('/texturas/piedra.jpg');
suelo.colorSpace = THREE.SRGBColorSpace;
suelo.wrapS = suelo.wrapT = THREE.RepeatWrapping;
suelo.repeat.set(12, 12);
// minFilter y magFilter ya son los correctos por defecto.

// Arte de pixel: nada de suavizado, pero mipmaps para que no hierva de lejos.
const sprite = new THREE.TextureLoader().load('/texturas/sprite.png');
sprite.colorSpace = THREE.SRGBColorSpace;
sprite.magFilter = THREE.NearestFilter;
sprite.minFilter = THREE.NearestMipmapLinearFilter;

// Tabla de consulta: ni interpolacion ni mipmaps.
const rampa = new THREE.DataTexture(datos, datos.length, 1, THREE.RedFormat);
rampa.magFilter = THREE.NearestFilter;
rampa.minFilter = THREE.NearestFilter;
rampa.generateMipmaps = false;
rampa.needsUpdate = true;

Fíjate en el caso del pixel art: la combinación correcta es NearestFilter en magnificación, para que los píxeles se vean cuadrados, y un filtro con mipmap en minificación, porque el arte de píxel visto de lejos hierve exactamente igual que cualquier otra textura. Poner NearestFilter en los dos es el error clásico.

Los mipmaps se calculan en espacio no lineal y eso oscurece tus texturas

La generación de mipmaps que hace el driver promedia los valores tal y como están almacenados. Para una textura de color codificada en sRGB, esos valores no son proporcionales a la luz: la codificación sRGB es aproximadamente una potencia de 1 partido por 2,2. Promediar dos valores codificados no da el mismo resultado que promediar la luz que representan y volver a codificar.

El caso extremo se ve con dos téxeles, uno negro y otro blanco. La media de la luz es 0.5 lineal, que codificada en sRGB es 0.735, es decir, 188 en ocho bits. La media de los valores codificados es 0.5, es decir, 128. El mipmap sale claramente más oscuro de lo que debería.

El efecto real es que las texturas con mucho contraste de alta frecuencia se oscurecen al alejarse, y las mallas de rejilla o de vegetación se ven más densas de lejos que de cerca. La GPU sabe hacerlo bien si la textura está en un formato sRGB nativo, porque entonces linealiza antes de promediar, y ese es uno de los motivos poco publicitados para usar formatos comprimidos que declaran su espacio de color, como KTX2, en lugar de subir un JPEG y anotar el espacio en el material. También es la razón por la que las herramientas de horneado profesionales generan la cadena de mipmaps ellas mismas, en espacio lineal, y la incrustan en el fichero en lugar de dejar que la genere el driver.

Los mipmaps resuelven el aliasing pero introducen un problema propio: son isótropos, promedian una región cuadrada. Una superficie vista en ángulo rasante no cubre una región cuadrada de la textura sino una franja larga y estrecha, y ahí es donde entra la anisotropía.