Artículos en profundidad sobre la tecnología que da forma al futuro.

El arte del dithering: imágenes hermosas con un solo bit

Cómo los algoritmos de dithering crean sombreado con píxeles solo blancos y negros: de Floyd-Steinberg al ruido azul, con implementaciones prácticas.

Retrato de un capitán de barco formado por puntos blancos y negros con dithering que se resuelven en sombreado

Return of the Obra Dinn, de Lucas Pope, es uno de los juegos más llamativos visualmente que se han hecho, y usa exactamente dos colores: negro y blanco. Un bit por píxel. Sin grises, sin degradados, sin antialiasing. Cada píxel está encendido o apagado. Aun así, el juego consigue renderizar un mundo 3D completo con personajes reconocibles, percepción de profundidad e iluminación atmosférica, todo gracias a un dithering muy ingenioso.

El dithering es la técnica de organizar píxeles blancos y negros en patrones que engañan al ojo para que vea tonos intermedios. Lleva con nosotros desde los primeros días de la computación gráfica y está viviendo un resurgimiento en el desarrollo de videojuegos, el diseño web y el cada vez más relevante problema de mostrar imágenes en pantallas e-ink. Los algoritmos que lo hacen posible son elegantes, sorprendentemente variados y bastante más aplicables de lo que imaginas.

Por qué funciona el dithering

Tu sistema visual no procesa píxeles individuales, sino regiones. Cuando miras un patrón de píxeles blancos y negros alternados, no ves puntos sueltos: ves gris. Una región donde el 25 % de los píxeles son blancos se percibe como un gris al 25 %. Una región con un 75 % de blancos se percibe como un gris al 75 %. La densidad de píxeles blancos en un área controla el brillo percibido.

Es el mismo principio que está detrás de la impresión por semitonos de los periódicos. Esos puntos de distinto tamaño y separación crean la ilusión de un tono continuo a partir de tinta negra pura sobre papel blanco. El dithering es el equivalente digital: en lugar de tamaños de punto, usa patrones de píxeles.

El reto no es crear escala de grises, eso es sencillo. El reto está en hacerlo sin artefactos visibles: sin banding (transiciones bruscas entre patrones), sin sesgos direccionales (patrones que forman líneas visibles) y sin efectos moiré (patrones de interferencia entre el dithering y la cuadrícula de la pantalla).

Dithering por umbral: el enfoque ingenuo

El dithering más simple: comparar el brillo de cada píxel con un umbral (normalmente el 50 %). Si es más brillante que el umbral, blanco. Si es más oscuro, negro. Esto produce una silueta de alto contraste sin ninguna gradación. Sirve para texto y dibujo lineal, pero es terrible para fotografías o renderizados 3D.

import numpy as np
from PIL import Image
def threshold_dither(image, threshold=128):
"""Binary threshold — no dithering at all, really."""
gray = np.array(image.convert('L'), dtype=float)
return Image.fromarray((gray > threshold).astype(np.uint8) * 255)

Dithering ordenado: patrones sobre una cuadrícula

El dithering ordenado usa una matriz de umbrales (también llamada matriz de Bayer) que varía el umbral a lo largo de un patrón de mosaico que se repite. En lugar de comparar todos los píxeles con el mismo umbral, cada píxel se compara con un valor distinto de la matriz según su posición.

def ordered_dither(image, matrix_size=4):
"""Ordered dithering with Bayer matrix."""
gray = np.array(image.convert('L'), dtype=float)
h, w = gray.shape
# 4×4 Bayer matrix (values 0-15, normalized to 0-255)
bayer_4x4 = np.array([
[ 0,  8,  2, 10],
[12,  4, 14,  6],
[ 3, 11,  1,  9],
[15,  7, 13,  5]
]) * (255 / 16)
# Tile the matrix across the image
threshold = np.tile(bayer_4x4, (h // 4 + 1, w // 4 + 1))[:h, :w]
return Image.fromarray((gray > threshold).astype(np.uint8) * 255)

El dithering ordenado produce un patrón de rayado cruzado muy distintivo y reconocible al instante. En resoluciones bajas, la estructura regular de la cuadrícula es visible, lo que da a la imagen un aspecto mecánico y retro. En muchos contextos esto es una decisión estética: el patrón ordenado de una matriz de Bayer de 8×8 tiene un aspecto característico que muchos juegos y artistas usan de forma intencionada.

La matriz de Bayer está construida con cuidado para que cada nivel de umbral añada nuevos píxeles en posiciones lo más separadas posible de los existentes. Así se minimiza la aglomeración y se obtiene la distribución de puntos más uniforme en cada nivel de brillo. Además es muy rápido —una comparación por píxel, sin dependencias entre vecinos—, lo que lo hace ideal para renderizado en tiempo real.

Difusión de error: Floyd-Steinberg y compañía

La difusión de error adopta un enfoque completamente distinto. En lugar de usar una matriz de umbrales fija, procesa los píxeles de forma secuencial y reparte el «error», es decir, la diferencia entre el valor original del píxel y el resultado cuantizado (negro o blanco), entre los vecinos que todavía no se han procesado.

def floyd_steinberg_dither(image):
"""Floyd-Steinberg error diffusion dithering."""
gray = np.array(image.convert('L'), dtype=float)
h, w = gray.shape
for y in range(h):
for x in range(w):
old_pixel = gray[y, x]
new_pixel = 255.0 if old_pixel > 128 else 0.0
gray[y, x] = new_pixel
error = old_pixel - new_pixel
# Distribute error to neighbors
# Floyd-Steinberg diffusion kernel:
#        * 7/16
#  3/16 5/16 1/16
if x + 1 < w:
gray[y, x + 1] += error * 7 / 16
if y + 1 < h:
if x - 1 >= 0:
gray[y + 1, x - 1] += error * 3 / 16
gray[y + 1, x] += error * 5 / 16
if x + 1 < w:
gray[y + 1, x + 1] += error * 1 / 16
return Image.fromarray(gray.astype(np.uint8))

El dithering de Floyd-Steinberg da resultados mucho más naturales que el ordenado. La distribución de píxeles no tiene una cuadrícula visible y maneja los degradados suaves de forma excelente. Los puntos se agrupan de manera orgánica, creando un aspecto más cercano a la impresión fotográfica por semitonos.

El inconveniente: es secuencial. Cada píxel depende del error de los píxeles procesados antes, así que no se puede paralelizar fácilmente. Esto lo hace más lento que el ordenado y más difícil de implementar en pipelines de renderizado basados en shaders. Para imágenes preprocesadas no importa. Para renderizado 3D en tiempo real a 60 fps, sí es un problema.

Dithering con ruido azul: lo mejor de los dos mundos

El dithering con ruido azul es hoy el estado del arte en muchas aplicaciones. Usa una matriz de umbrales como el ordenado (así que es rápido y paralelizable), pero sus valores están dispuestos para producir una distribución de ruido azul: los puntos se colocan de forma aleatoria pero con una restricción de separación mínima, evitando tanto la aglomeración del ruido blanco como los patrones de cuadrícula del dithering ordenado.

El término «ruido azul» viene del procesamiento de señales. El ruido azul tiene la mayor parte de su energía en frecuencias altas, como la luz azul. En términos espaciales, esto significa que el patrón no tiene estructura a gran escala (ni cuadrícula visible, ni sesgo direccional), pero los puntos están uniformemente separados a nivel local (ni aglomeraciones, ni huecos). Es el patrón que obtienes si lanzas dardos a un tablero con la condición de que cada dardo esté al menos a una distancia mínima de todos los demás.

Generar una buena matriz de umbrales de ruido azul es sorprendentemente difícil: es un problema de optimización que requiere iterar hasta que el espectro de frecuencias coincida con el perfil deseado. Pero una vez que tienes la matriz, usarla es tan rápido como el dithering de Bayer: una comparación por píxel.

El enfoque de Obra Dinn: dithering mapeado en esfera

El dithering de Return of the Obra Dinn es especialmente ingenioso porque resuelve un problema propio del renderizado 3D: la estabilidad temporal. Cuando se aplica un patrón de dithering estándar en espacio de pantalla, mover la cámara hace que el patrón «nade»: se queda fijo en la pantalla mientras la geometría 3D se desplaza por debajo. Esto produce un efecto de parpadeo extremadamente molesto.

La solución de Pope: mapear el patrón de dithering en el espacio 3D en lugar de en el de pantalla. El patrón se proyecta sobre una esfera que rodea la cámara y después se muestrea según la dirección de visión de cada píxel. Cuando la cámara gira, el patrón gira con ella. Cuando los objetos se mueven en el espacio del mundo, su dithering es estable porque se deriva de su posición en el mundo, no de su posición en pantalla.

Es una técnica que puedes adaptar a cualquier aplicación 3D que use dithering o efectos de semitono. La idea clave: si quieres estabilidad temporal, tus valores de umbral deben depender de algo estable (la posición en el mundo, la dirección de visión) y no de algo que cambie en cada fotograma (las coordenadas de pantalla).

Aplicaciones prácticas más allá de la estética retro

El dithering no sirve solo para la estética retro de los videojuegos. Tiene aplicaciones prácticas reales en el desarrollo actual.

  • Pantallas e-ink. Los lectores electrónicos y la señalización e-ink tienen una capacidad de escala de grises limitada (normalmente 16 niveles). El dithering es esencial para mostrar fotografías y degradados en estos dispositivos. El algoritmo elegido afecta directamente a la legibilidad y a la calidad de la imagen.
  • Reducir el tamaño de los archivos. Una imagen dithered de 1 bit ocupa 1/8 del tamaño sin comprimir de una imagen en escala de grises de 8 bits. Para la entrega web de imágenes decorativas, los PNG con dithering pueden ser muchísimo más pequeños que las alternativas en escala de grises. Algunos diseñadores web usan imágenes con dithering a propósito, por razones estéticas y de rendimiento.
  • Cuantización de color. Cuando reduces una imagen a todo color a una paleta limitada (GIF, PNG indexado o pantallas con paleta restringida), el dithering evita el banding que aparece cuando los degradados suaves se asignan a valores de color discretos. El dithering de Floyd-Steinberg en el espacio de color es como los codificadores GIF resuelven este problema.
  • Banding en degradados durante el renderizado. Los render targets de baja precisión (8 bits por canal) pueden producir banding visible en degradados suaves. Añadir dithering en espacio de pantalla (incluso solo ±0,5 de un valor) rompe el banding sin ruido visible. La mayoría de los motores de juego modernos lo hacen automáticamente.
  • Impresoras térmicas y de tickets. Las impresoras térmicas son de 1 bit por naturaleza: cada punto se calienta (negro) o no. El dithering convierte fotografías y gráficos en un formato imprimible. El algoritmo elegido determina si la imagen de tu ticket es reconocible o un borrón.

Implementar dithering en un shader

En aplicaciones en tiempo real, el dithering ordenado y el de ruido azul se pueden implementar de forma eficiente como fragment shaders.

// GLSL fragment shader for Bayer 8x8 ordered dithering
precision mediump float;
uniform sampler2D uTexture;
varying vec2 vTexCoord;
// Bayer 8x8 matrix lookup via bit manipulation (no texture needed)
float bayer8x8(vec2 pos) {
ivec2 p = ivec2(mod(pos, 8.0));
int index = p.x + p.y * 8;
// Bit-reversal trick for Bayer matrix values
int value = 0;
int x = p.x ^ p.y;
int y = p.y;
for (int i = 0; i < 3; i++) {
value = value * 4 + (x & 1) * 2 + (y & 1);
x >>= 1;
y >>= 1;
}
return float(value) / 64.0;
}
void main() {
vec4 color = texture2D(uTexture, vTexCoord);
float luminance = dot(color.rgb, vec3(0.299, 0.587, 0.114));
float threshold = bayer8x8(gl_FragCoord.xy);
float dithered = step(threshold, luminance);
gl_FragColor = vec4(vec3(dithered), 1.0);
}

Para el ruido azul, normalmente precalculas la textura de ruido y la muestreas en el shader. La textura puede repetirse en mosaico, así que una textura de ruido azul de 64×64 o 128×128 cubre toda la pantalla al repetirse. El coste por píxel es una lectura de textura y una comparación, prácticamente gratis en las GPU actuales.

Elegir el algoritmo adecuado

Cada enfoque de dithering tiene un caso de uso en el que es la mejor opción.

  • Por umbral: texto, dibujo lineal y gráficos de alto contraste, donde importa más el detalle que el rango tonal.
  • Ordenado (Bayer): renderizado en tiempo real cuando quieres una estética retro o de semitono deliberada. Rápido, paralelizable y apto para GPU.
  • Floyd-Steinberg: procesado de imágenes estáticas donde la calidad es lo más importante. Ideal para fotografías e imágenes con degradados suaves.
  • Ruido azul: renderizado en tiempo real cuando quieres resultados naturales sin patrones visibles. La mejor opción general para aplicaciones modernas.
  • Esférico/en espacio de mundo: renderizado 3D donde la estabilidad temporal es crítica. Imprescindible para el renderizado 3D de 1 bit como el de Obra Dinn.

El dithering es uno de esos temas en los que la sencillez del concepto («organizar puntos para que parezcan sombreado») esconde la profundidad de las decisiones de implementación. Cada algoritmo representa un equilibrio distinto entre calidad, velocidad y carácter visual. Y en una época en la que las pantallas e-ink, las conexiones de bajo ancho de banda y la estética retro siguen siendo relevantes, entender estos compromisos es más práctico que nunca.