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.
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.
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.
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:
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.
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:
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.