lonepixell

En construcción

Blog

Un campo de píxeles sin una sola librería

El fondo animado del hero pesa menos que el icono de una librería de animación. Así funciona, y por qué no usamos GSAP ni Three.js.

  • código
  • movimiento
  • rendimiento

El hero de esta página tiene un campo de píxeles que crece desde el punto naranja del titular y se asienta solo. Es el único momento de firma del sitio. No usa GSAP, ni Three.js, ni Lenis, ni una librería de partículas. Son unas cuarenta líneas de Canvas 2D y un requestAnimationFrame que se apaga a los dos segundos.

Esto no es purismo. Es aritmética: GSAP con ScrollTrigger son ~45 KB comprimidos, Three.js pasa de 150 KB. Toda esta página pesa 57 KB. Meter una librería de movimiento habría triplicado el peso del sitio para animar una cosa.

Aleatorio, pero siempre el mismo

El primer requisito es raro: el campo tiene que verse aleatorio pero ser determinista. Si cada recarga dibuja un patrón distinto, deja de ser una identidad y pasa a ser ruido. Y Math.random() no sirve, porque no se puede preguntar "¿qué valor te tocaría en la celda 12, 7?".

La solución es una función hash: mismas coordenadas, mismo número, siempre, sin guardar nada.

src/js/hero-pixels.js
function hash(x, y) {
  let h = (x * 374761393 + y * 668265263) ^ 0x5bf03635
  h = Math.imul(h ^ (h >>> 13), 1274126177)
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296
}

Devuelve un número entre 0 y 1 a partir de dos enteros. Es el mismo truco de los generadores de terreno procedural, en su versión más pequeña posible.

Crecer, no aparecer

El campo no se dibuja de golpe: se propaga desde una celda semilla, como algo que se extiende. Un recorrido en anchura sobre la retícula, donde cada celda vecina entra o no según su propio hash y con una probabilidad que baja con la distancia, para que el campo se apague en los bordes en vez de cortarse.

js
const queue = [[seedX, seedY, 0]]
let head = 0

while (head < queue.length && head < budget) {
  const [x, y, depth] = queue[head++]

  for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]) {
    if (seen[ny * cols + nx]) continue
    // El umbral sube con la distancia: el campo se disuelve, no se recorta.
    if (hash(nx, ny) > 0.93 - depth * 0.0085) continue
    seen[ny * cols + nx] = 1
    queue.push([nx, ny, depth + 1])
  }
}

El budget es el detalle que hace que esto sea viable en un teléfono: en escritorio son ~1250 celdas, en móvil 420. No es una degradación visual perceptible; es la diferencia entre 60 fps y un ventilador.

El truco de rendimiento que sí importa

Un fillRect por celda con un fillStyle distinto cada vez es lento, porque cada cambio de estilo es un cambio de estado del contexto. La solución no es dibujar menos: es agrupar.

Las opacidades se cuantizan en diez cubos, y se pinta cubo por cubo. Diez cambios de fillStyle en total, en lugar de mil doscientos:

js
const buckets = Array.from({ length: 10 }, () => [])

for (const cell of cells) {
  buckets[Math.min(9, (cell.alpha * 10) | 0)].push(cell)
}

buckets.forEach((cells, i) => {
  if (!cells.length) return
  ctx.fillStyle = `rgba(20, 20, 18, ${(i + 0.5) / 10})`
  for (const c of cells) ctx.fillRect(c.x, c.y, size, size)
})

Nadie nota la diferencia entre 1000 opacidades distintas y 10. Todo el mundo nota un scroll a 30 fps.

Sembrado en la posición real

El campo tiene que nacer exactamente del punto naranja del <h1>. No de una coordenada aproximada escrita a mano: del punto renderizado, que cambia según la tipografía cargada, el ancho de pantalla y el tamaño de fuente del sistema.

src/js/main.js
const fonts = document.fonts?.ready ?? Promise.resolve()

Promise.race([fonts, new Promise((r) => setTimeout(r, 400))]).then(async () => {
  const { initHeroPixels } = await import('./hero-pixels.js')
  initHeroPixels(canvas, seed, { staticOnly, hover: env.hover })
})

Dos cosas pasan aquí. La primera: se espera a document.fonts.ready, porque antes de que cargue Instrument Sans el punto está en otro sitio y el campo nacería descolocado. La segunda: se espera con techo. Si una fuente falla, el Promise.race cede a los 400 ms y el hero se dibuja igual. Una promesa sin timeout es un bug esperando a una mala conexión.

El módulo además se importa dinámicamente, así que su peso no entra en el paquete inicial. Y quien pidió menos movimiento, tiene modo de ahorro de datos o está en un equipo de gama baja recibe el campo ya asentado, sin animación:

src/js/env.js
export const staticOnly = env.reduced || env.saveData || env.lowEnd

La animación que se apaga sola

Lo último, y lo más fácil de olvidar: el bucle termina. A los dos segundos el campo está asentado, se cancela el rAF y la pestaña vuelve a costar cero. Una animación ambiental que corre para siempre es una batería que se vacía para siempre.

Un fondo animado no tiene por qué costar 200 KB y un núcleo de CPU. Casi siempre cuesta eso porque nadie preguntó cuánto tenía que costar.