wandres.dev
GRADIENTES · Interpolación, ángulos y cónicos

Bandas, ruido y el coste real de un degradado

Por qué un degradado suave se ve a escalones, cuántos niveles tiene de verdad, cómo se rompen las bandas con ruido, y cuál es la única forma fiable de animar un degradado.

⏱ 19 min

Un degradado oscuro y sutil de fondo a pantalla completa se ve casi siempre a escalones, y la reacción instintiva —añadir paradas intermedias— no arregla nada porque el problema no está donde parece. La causa se calcula con una división, la solución es prestada de la fotografía digital, y por el camino aparece el otro asunto que nadie mide: cuánto cuesta pintar esto en cada fotograma.

🎯 Al terminar esta lección sabrás
  • Calcular cuántos niveles distintos tiene un degradado y de qué ancho salen sus bandas.
  • Romper las bandas con una capa de ruido controlada.
  • Explicar por qué un degradado se repinta y un color plano no.
  • Animar un degradado sin provocar un repintado por fotograma.

Por qué aparecen las bandas

El cálculo del degradado es continuo; la escritura no. El resultado acaba en un búfer de ocho bits por canal, es decir 256 niveles posibles por canal. Si el degradado recorre pocos niveles y ocupa muchos píxeles, cada nivel se estira sobre una franja ancha y esa franja se ve.

La cuenta es una división:

Degradado Niveles distintos Ancho de banda sobre 1000 px
#000000 a #111111 18 56 px
#101010 a #202020 17 59 px
#808080 a #a0a0a0 33 30 px

Bandas de cincuenta y seis píxeles. Con eso no hace falta buscar el problema: se ve desde el otro lado de la habitación.

De aquí sale directamente por qué añadir paradas no sirve de nada. Las paradas afectan a la función continua que se evalúa, no a la cuantización que ocurre al escribir el píxel. Poner diez colores intermedios en un degradado de #000 a #111 sigue dando dieciocho valores distintos, porque dieciocho es lo que hay entre esos dos extremos y no hay más.

Tres factores empeoran la situación. Un rango corto, que es lo que hace el degradado sutil que estabas buscando. Una superficie grande, porque estira cada nivel. Y la zona oscura, porque ahí la codificación gamma comprime muchos niveles de luz en pocos códigos y el ojo, que es más sensible a diferencias relativas, discrimina mejor.

Romper las bandas con ruido

La solución es la misma que lleva usando la industria de la imagen desde siempre: tramado. Se añade una perturbación aleatoria de amplitud comparable a un nivel de cuantización, con lo que el punto exacto donde el degradado cambia de nivel deja de ser una línea recta y pasa a ser una frontera irregular. La media local sigue siendo correcta y el ojo integra el ruido en vez de ver el escalón.

CSS no tiene una función de ruido, pero SVG sí, y un SVG en línea funciona como imagen de fondo:

.superficie {
  position: relative;
  background: linear-gradient(in oklab to bottom, #0b0b12, #1e1e2e);
}

.superficie::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0.04;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='r'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23r)'/%3E%3C/svg%3E");
}

feTurbulence genera ruido fractal, stitchTiles='stitch' hace que el mosaico case consigo mismo al repetirse, y la opacidad baja controla la amplitud. Con 0.03 a 0.05 las bandas desaparecen y el ruido no se percibe como tal; por encima de 0.08 empieza a verse la textura, que a veces es justo lo que quieres.

💡
El ruido es un solo mosaico, no una capa a pantalla completa

El SVG mide 160 por 160 y se repite. El navegador rasteriza el filtro una vez para ese mosaico y luego repite el bitmap resultante, así que el coste no crece con el tamaño del elemento. Generar el ruido a pantalla completa con un solo feTurbulence sin repetición sería órdenes de magnitud más caro y no se ve mejor.

Qué cuesta pintar un degradado

Un color plano se resuelve en la fase de composición: es un rectángulo de un color, y el compositor sabe rellenarlo sin tocar la CPU ni generar un mapa de píxeles nuevo. Un degradado no: hay que evaluar una función en cada píxel y escribir el resultado, y eso es un repintado.

El coste tiene tres componentes.

El área. Es lineal en el número de píxeles y hay que contarlos en píxeles del dispositivo, no de CSS. Un fondo a pantalla completa en un portátil con escala 2 son cuatro veces más píxeles de los que sugiere el ancho en CSS.

El tipo. Un degradado lineal se evalúa con un producto escalar por píxel y los rasterizadores lo tienen muy optimizado. Un radial necesita una raíz cuadrada. Un cónico necesita un arcotangente. Los tres son baratos por píxel, pero cuando el píxel se multiplica por varios millones la diferencia se mide.

Cuántas veces. Aquí está todo lo que importa. Un degradado que se pinta una vez al cargar la página es gratis a efectos prácticos. Uno que se repinta en cada fotograma porque su caja cambia de tamaño, porque una parada depende de una custom property que se está animando, o porque el elemento se redimensiona con el contenido, cuesta el área entera sesenta veces por segundo.

Animar los parámetros, no la imagen

No existe una transición fiable entre dos degradados. Son valores de tipo imagen, no de tipo color, y aunque la especificación define la interpolación para algunos casos, no es algo sobre lo que se pueda construir.

Lo que sí funciona es registrar los parámetros y dejar que el degradado se reconstruya:

@property --angulo {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.borde-vivo {
  --angulo: 0deg;
  background: conic-gradient(
    from var(--angulo),
    #89b4fa, #cba6f7, #f38ba8, #89b4fa
  );
  animation: girar 4s linear infinite;
}

@keyframes girar {
  to { --angulo: 360deg; }
}

Esto funciona y se ve muy bien, y hay que saber lo que cuesta: cada fotograma es un repintado del área entera. Para un borde de doscientos por cincuenta píxeles es irrelevante; para un fondo a pantalla completa es un problema, y en un móvil se nota en la batería antes que en la fluidez.

La alternativa barata, cuando la animación es entre dos estados y no continua, es apilar los dos degradados y cruzar opacidades:

.superficie {
  position: relative;
  background: linear-gradient(in oklab to bottom, #0b0b12, #1e1e2e);
}

.superficie::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(in oklab to bottom, #1e1e2e, #313244);
  opacity: 0;
  transition: opacity 300ms;
}

.superficie:hover::before { opacity: 1; }

Los dos degradados se rasterizan una vez cada uno. Lo que se anima es opacity, que el compositor resuelve sin repintar nada. Es más código y cuesta prácticamente cero.

El criterio no es si algo se puede animar, sino en qué fase del pipeline se resuelve

Casi toda la discusión sobre rendimiento de animaciones en CSS se reduce a una pregunta que rara vez se formula bien: en qué fase del pipeline se resuelve el cambio. Hay tres, y el salto de coste entre ellas es de un orden de magnitud cada uno. Cambiar algo que afecta a la geometría dispara layout, y hay que recalcular posiciones de todo lo que dependa. Cambiar algo que afecta al aspecto de un elemento sin mover nada dispara paint, y hay que regenerar el mapa de píxeles de ese elemento. Cambiar algo que el compositor sabe aplicar sobre un mapa de píxeles ya generado —transform y opacity, básicamente— no dispara ninguna de las dos: la GPU aplica una matriz o multiplica un alfa sobre una textura que ya existe. Un degradado animado por parámetros cae inevitablemente en la segunda categoría, porque el degradado es el mapa de píxeles y cambiar sus parámetros lo invalida. No hay optimización que lo evite: ningún will-change va a hacer que un conjunto de píxeles distinto aparezca sin generarlo. Lo único que se puede hacer es cambiar el problema, y ahí está el patrón que resuelve la mayoría de los casos: si los estados posibles son pocos y conocidos, genera todos los mapas de píxeles una vez y anima entre ellos con opacity, que sí es de la tercera categoría. Vale para degradados, para sombras, para filtros y para cualquier cosa que se pinte. El precio es tener varios elementos superpuestos en vez de uno, es decir más memoria de vídeo; la ganancia es pasar de repintar un área grande sesenta veces por segundo a no repintar nada. Cuando el número de estados no es finito —una rotación continua, un valor que sigue al ratón— ese cambio no está disponible y solo queda reducir el área. Por eso los bordes animados con degradado se ven tanto y los fondos animados con degradado se ven tan poco: no es una cuestión de gusto, es que uno cabe en el presupuesto y el otro no.

⚔️ Mide y arregla
  1. Pinta un fondo de #0b0b12 a #1a1a26 a pantalla completa y busca las bandas.
  2. Añade paradas intermedias y comprueba que no cambia nada.
  3. Añade la capa de ruido y ajusta la opacidad hasta que desaparezcan.
  4. Anima un conic-gradient con @property y mira el panel de rendimiento para ver los repintados.
  5. Reescribe la misma animación como cruce de opacidades y compara.