wandres.dev
TRAZO Y RELLENO · Estilos de línea y pintura

fillStyle: colores, gradientes y patrones

Dominar los tres tipos de pintura del canvas, sus sistemas de coordenadas y el error de rendimiento que comete todo el mundo con los gradientes.

⏱ 18 min

fillStyle y strokeStyle aceptan tres cosas distintas: una cadena de color, un objeto de gradiente o un objeto de patrón. Los dos últimos son objetos vivos que se crean una vez y se reutilizan, y ahí está la trampa: casi todo el código de ejemplo los crea dentro del bucle de dibujo, generando basura sesenta veces por segundo. Además, sus coordenadas viven en el espacio del canvas y no en el de la forma, lo que produce el segundo desconcierto clásico.

🎯 Al terminar esta lección sabrás
  • Usar los cuatro constructores de gradiente y colocar sus paradas de color.
  • Explicar en qué sistema de coordenadas viven los gradientes y qué implica.
  • Crear patrones a partir de imágenes y canvas, y transformarlos con setTransform.
  • Evitar la creación de objetos de pintura dentro del bucle de dibujo.

Colores

La cadena admite cualquier sintaxis de color de CSS que el navegador entienda: nombres, hexadecimal, rgb(), hsl(), oklch(), color(). La conversión ocurre al asignar.

Un detalle útil: leer fillStyle después de asignarlo devuelve una forma normalizada, no la cadena que escribiste. Eso permite comprobar si un color era válido, porque una cadena inválida se ignora y la propiedad conserva el valor anterior.

ctx.fillStyle = '#89b4fa';
console.log(ctx.fillStyle);    // '#89b4fa'
ctx.fillStyle = 'no-es-un-color';
console.log(ctx.fillStyle);    // sigue siendo '#89b4fa'

Esa normalización tiene un uso práctico: convertir cualquier notación de color a un formato manejable sin escribir un parser.

Gradientes: cuatro constructores

ctx.createLinearGradient(x0, y0, x1, y1);
ctx.createRadialGradient(x0, y0, r0, x1, y1, r1);
ctx.createConicGradient(anguloInicio, cx, cy);

Los tres devuelven un CanvasGradient al que se le añaden paradas de color con addColorStop(posicion, color), donde la posición va de 0 a 1.

El lineal interpola entre dos puntos. La dirección del gradiente es la recta que los une; perpendicularmente el color es constante.

El radial interpola entre dos círculos, no entre dos radios del mismo círculo. Eso permite gradientes descentrados, que es lo que da el aspecto de esfera iluminada:

const g = ctx.createRadialGradient(90, 80, 5, 110, 110, 90);
g.addColorStop(0, '#f9e2af');
g.addColorStop(0.5, '#fab387');
g.addColorStop(1, '#11111b');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(110, 110, 90, 0, Math.PI * 2);
ctx.fill();

El cónico interpola alrededor de un punto según el ángulo. El ángulo inicial va en radianes y el cero apunta a la derecha, como en arc. Es lo que se usa para ruedas de color y para indicadores circulares con degradado.

Fuera del rango de las paradas, el gradiente extiende el color de la parada extrema. No se repite ni se desvanece.

El sistema de coordenadas de los gradientes

Aquí está el desconcierto clásico. Las coordenadas de un gradiente están en el espacio de usuario del contexto en el momento de pintar, no en el de la forma que se rellena.

Eso significa que si creas un gradiente de (0, 0) a (100, 0) y lo usas para rellenar un rectángulo situado en x = 300, el rectángulo saldrá de un color plano, porque a partir de x = 100 el gradiente extiende su última parada.

// Mal: el gradiente esta en el origen, la forma esta lejos
const g = ctx.createLinearGradient(0, 0, 100, 0);
g.addColorStop(0, '#89b4fa'); g.addColorStop(1, '#f38ba8');
ctx.fillStyle = g;
ctx.fillRect(300, 20, 100, 60);      // sale rosa plano

// Bien: coordenadas del gradiente relativas a la forma
const g2 = ctx.createLinearGradient(300, 0, 400, 0);
g2.addColorStop(0, '#89b4fa'); g2.addColorStop(1, '#f38ba8');
ctx.fillStyle = g2;
ctx.fillRect(300, 20, 100, 60);      // degradado correcto

Hay una alternativa mucho más limpia: crear el gradiente siempre en el origen y trasladar el contexto. Como las coordenadas del gradiente se interpretan con la matriz activa, un translate mueve el gradiente con la forma.

const g = ctx.createLinearGradient(0, 0, 100, 0);
g.addColorStop(0, '#89b4fa'); g.addColorStop(1, '#f38ba8');

function caja(x, y) {
  ctx.save();
  ctx.translate(x, y);
  ctx.fillStyle = g;             // el MISMO gradiente, reutilizado
  ctx.fillRect(0, 0, 100, 60);
  ctx.restore();
}
caja(20, 20); caja(160, 20); caja(300, 20);

Ese patrón resuelve el problema de las coordenadas y el de la reutilización a la vez, y es la forma correcta de usar gradientes en una escena con muchos elementos.

Patrones

const patron = ctx.createPattern(fuente, repeticion);

La fuente puede ser una imagen, un canvas, un ImageBitmap, un vídeo o un OffscreenCanvas. La repetición admite 'repeat', 'repeat-x', 'repeat-y' y 'no-repeat'.

Si la fuente es una imagen que aún no ha cargado, createPattern devuelve null. Hay que esperar.

Los patrones también viven en el espacio del contexto y arrancan en el origen. Pero tienen algo que los gradientes no: una matriz propia, que se ajusta con setTransform y admite un DOMMatrix.

const patron = ctx.createPattern(textura, 'repeat');
patron.setTransform(new DOMMatrix().translate(20, 20).rotate(30).scale(0.5));
ctx.fillStyle = patron;
ctx.fillRect(0, 0, 400, 300);

Esa matriz se compone con la del contexto, lo que permite mover, girar y escalar el patrón sin mover la forma. Es la forma correcta de hacer un fondo texturado que se desplaza con el scroll o un rayado que gira.

Un patrón generado a partir de un canvas pequeño es la forma más eficiente de rellenar áreas grandes con una textura procedural:

function patronRayas(ctx, colorA, colorB, ancho = 8) {
  const t = document.createElement('canvas');
  t.width = t.height = ancho * 2;
  const c = t.getContext('2d');
  c.fillStyle = colorA; c.fillRect(0, 0, ancho * 2, ancho * 2);
  c.strokeStyle = colorB; c.lineWidth = ancho;
  c.beginPath();
  c.moveTo(-ancho, ancho); c.lineTo(ancho, -ancho);
  c.moveTo(0, ancho * 2);  c.lineTo(ancho * 2, 0);
  c.moveTo(ancho, ancho * 3); c.lineTo(ancho * 3, ancho);
  c.stroke();
  return ctx.createPattern(t, 'repeat');
}
Crear un gradiente por fotograma es la fuga de rendimiento más extendida del canvas

Este es el error que aparece en la práctica totalidad del código de canvas que circula, incluidos ejemplos de documentación. Consiste en escribir esto dentro del bucle de animación:

function marco() {
  const g = ctx.createLinearGradient(0, 0, 0, alto);   // cada fotograma
  g.addColorStop(0, '#89b4fa');
  g.addColorStop(1, '#1e1e2e');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, ancho, alto);
  requestAnimationFrame(marco);
}

Un CanvasGradient no es un objeto ligero: el navegador tiene que construir la rampa de color, que típicamente se materializa como una textura de 256 o más entradas, y en un canvas acelerado eso implica una subida a la GPU. Crear uno por fotograma son sesenta rampas por segundo que se construyen, se suben y se descartan. El coste directo es medible, pero el indirecto es peor: es basura constante para el recolector, y produce esas pausas irregulares de unos pocos milisegundos que rompen la fluidez sin que ningún fotograma concreto parezca lento. Lo mismo vale para createPattern, para new Path2D y para cualquier objeto del canvas. La regla es tajante: todos los objetos de pintura se crean una vez, fuera del bucle, y se guardan. Si dependen del tamaño del canvas, se recrean solo en el callback de redimensionado. Si dependen de un valor que cambia —un color de tema, una animación de color— se cachean por ese valor con un Map. Y hay un caso especial que hay que mencionar porque es contraintuitivo: si el gradiente depende de una posición que cambia cada fotograma, no lo recrees; créalo en el origen y usa translate, que es lo que la matriz del contexto existe para hacer. La única situación en la que hay que recrear un gradiente cada fotograma es cuando cambian sus colores, y entonces conviene preguntarse si el efecto no se consigue mejor con globalAlpha o con un modo de composición sobre un gradiente fijo.

Una utilidad de caché

Para gradientes que dependen de parámetros, esta caché resuelve el problema sin ensuciar el código:

function creadorDeGradientes(ctx) {
  const cache = new Map();
  return function gradiente(clave, construir) {
    let g = cache.get(clave);
    if (!g) { g = construir(ctx); cache.set(clave, g); }
    return g;
  };
}

const grad = creadorDeGradientes(ctx);

function pintarBarra(x, y, w, h, color) {
  ctx.save();
  ctx.translate(x, y);
  ctx.fillStyle = grad(`barra:${color}:${h}`, c => {
    const g = c.createLinearGradient(0, 0, 0, h);
    g.addColorStop(0, color);
    g.addColorStop(1, '#11111b');
    return g;
  });
  ctx.fillRect(0, 0, w, h);
  ctx.restore();
}

La clave incluye todo lo que afecta al gradiente. Si el canvas cambia de tamaño y los gradientes dependen de la altura, hay que vaciar la caché en el redimensionado, o incluir la altura en la clave como hace el ejemplo.