transferControlToOffscreen y el dibujo en un worker
Mover el renderizado completo a un hilo aparte: transferir el control del canvas, montar el protocolo de mensajes y gestionar el redimensionado desde fuera.
transferControlToOffscreen es la pieza que convierte un canvas normal en uno gobernado desde un worker. A partir de esa llamada, el elemento de la página es un marcador de posición que el navegador actualiza automáticamente, y todo el dibujo ocurre en otro hilo. El resultado es una animación que no se detiene aunque el hilo principal esté ocupado, con un coste concreto en complejidad que conviene conocer antes de empezar.
- Transferir el control de un canvas a un worker y dibujar desde allí.
- Diseñar el protocolo de mensajes entre el hilo principal y el worker.
- Gestionar el redimensionado y la densidad de pantalla desde el hilo principal.
- Reenviar eventos de puntero al worker con las coordenadas correctas.
La transferencia
// Hilo principal
const canvas = document.getElementById('c');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('/render.js', { type: 'module' });
worker.postMessage({ tipo: 'init', lienzo: offscreen }, [offscreen]);
Tres cosas ocurren ahí y las tres importan.
El elemento pasa a modo marcador de posición. Ya no se le puede pedir un contexto desde el hilo principal: canvas.getContext('2d') devuelve null para siempre. La transferencia es irreversible.
El OffscreenCanvas va en la lista de transferibles. El segundo argumento de postMessage es lo que hace que se mueva en lugar de copiarse. Sin él, la llamada falla.
El navegador se encarga de mostrar el resultado. No hay que hacer nada para que lo que el worker dibuja aparezca en la página: la propagación es automática y ocurre en cada fotograma de composición.
El worker
// render.js
let ctx = null;
let lienzo = null;
let estado = { ancho: 0, alto: 0, dpr: 1, puntero: { x: 0, y: 0 } };
let corriendo = false;
self.onmessage = ({ data }) => {
switch (data.tipo) {
case 'init':
lienzo = data.lienzo;
ctx = lienzo.getContext('2d');
arrancar();
break;
case 'resize':
lienzo.width = data.ancho; // el buffer se controla desde aqui
lienzo.height = data.alto;
estado.ancho = data.ancho / data.dpr;
estado.alto = data.alto / data.dpr;
estado.dpr = data.dpr;
break;
case 'puntero':
estado.puntero.x = data.x;
estado.puntero.y = data.y;
break;
case 'pausa':
corriendo = false;
break;
case 'reanudar':
if (!corriendo) arrancar();
break;
}
};
function arrancar() {
corriendo = true;
let anterior = performance.now();
const marco = ahora => {
if (!corriendo) return;
const dt = Math.min(50, ahora - anterior);
anterior = ahora;
actualizar(dt);
dibujar();
requestAnimationFrame(marco);
};
requestAnimationFrame(marco);
}
const particulas = Array.from({ length: 900 }, () => ({
x: Math.random(), y: Math.random(),
vx: (Math.random() - 0.5) * 0.0004, vy: (Math.random() - 0.5) * 0.0004,
}));
function actualizar(dt) {
for (const p of particulas) {
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.x < 0 || p.x > 1) p.vx *= -1;
if (p.y < 0 || p.y > 1) p.vy *= -1;
}
}
function dibujar() {
const { ancho, alto, dpr, puntero } = estado;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, ancho, alto);
ctx.fillStyle = '#89b4fa';
for (const p of particulas) {
const x = p.x * ancho, y = p.y * alto;
const d = Math.hypot(x - puntero.x, y - puntero.y);
const r = d < 90 ? 1.5 + (90 - d) / 22 : 1.5;
ctx.fillRect(x - r, y - r, r * 2, r * 2);
}
}
requestAnimationFrame existe en los workers cuando hay un OffscreenCanvas transferido, y está sincronizado con la composición igual que en el hilo principal. Es lo que hace viable todo el patrón.
El lado del hilo principal
El hilo principal conserva tres responsabilidades que el worker no puede asumir, porque exigen acceso al DOM.
const canvas = document.getElementById('c');
const worker = new Worker('/render.js', { type: 'module' });
worker.postMessage({ tipo: 'init', lienzo: canvas.transferControlToOffscreen() },
[canvas.transferControlToOffscreen === undefined ? [] : []][0] ?? []);
Escrito con claridad, la inicialización correcta es:
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ tipo: 'init', lienzo: offscreen }, [offscreen]);
Uno: medir el tamaño. El worker no puede leer getBoundingClientRect ni devicePixelRatio. Hay que medir aquí y mandarlo.
const ro = new ResizeObserver(entradas => {
for (const e of entradas) {
const caja = e.devicePixelContentBoxSize?.[0];
const dpr = window.devicePixelRatio || 1;
const ancho = caja ? caja.inlineSize : Math.round(e.contentRect.width * dpr);
const alto = caja ? caja.blockSize : Math.round(e.contentRect.height * dpr);
worker.postMessage({ tipo: 'resize', ancho, alto, dpr });
}
});
try { ro.observe(canvas, { box: 'device-pixel-content-box' }); }
catch { ro.observe(canvas, { box: 'content-box' }); }
Dos: reenviar los eventos. Los eventos de puntero llegan al elemento, que está en el hilo principal. Hay que convertir las coordenadas y mandarlas.
canvas.addEventListener('pointermove', e => {
const r = canvas.getBoundingClientRect();
worker.postMessage({
tipo: 'puntero',
x: (e.clientX - r.left),
y: (e.clientY - r.top),
});
});
Tres: gestionar el ciclo de vida. Pausar cuando la pestaña se oculta, reanudar al volver, y terminar el worker al desmontar.
document.addEventListener('visibilitychange', () => {
worker.postMessage({ tipo: document.hidden ? 'pausa' : 'reanudar' });
});
El patrón de reenviar eventos tiene un problema que aparece en cuanto la interacción es intensa y que contradice el objetivo entero de sacar el dibujo del hilo principal. Cada postMessage implica serializar el mensaje, encolarlo, y que el worker lo procese en un turno de su bucle de eventos. Con un ratón que emite eventos a la frecuencia de refresco no pasa nada, pero con un lápiz digital de alta frecuencia —que puede emitir a 240 hercios o más— o con varios punteros simultáneos, la cola de mensajes se llena y el worker gasta más tiempo procesando mensajes que dibujando. Y hay un efecto peor: si el hilo principal está ocupado, los eventos ni siquiera llegan a enviarse, con lo que la animación del worker deja de responder a la interacción aunque siga dibujando a sesenta fotogramas. Has movido el dibujo pero no la entrada. Hay tres mitigaciones y conviene conocer las tres. La primera, y la más efectiva: no envíes un mensaje por evento, envía el estado en el fotograma. Guarda la última posición en una variable del hilo principal y mándala una vez por requestAnimationFrame. Reduce el tráfico a sesenta mensajes por segundo como máximo, y no pierdes nada porque el worker no puede dibujar más rápido. La segunda: usa un SharedArrayBuffer para el estado de entrada, si tu sitio cumple los requisitos de aislamiento de origen. Con él, el hilo principal escribe las coordenadas en memoria compartida y el worker las lee cuando le viene bien, sin ningún mensaje. Es la solución técnicamente correcta y la que usan las aplicaciones que necesitan latencia mínima. La tercera, para casos concretos: no reenvíes. Si la interacción es sencilla —un desplazamiento, un zoom— resuélvela en el hilo principal aplicando una transformación de CSS al elemento canvas mientras dura el gesto, y manda al worker un único mensaje al final. El usuario ve una respuesta inmediata aunque el contenido tarde en regenerarse. Es exactamente lo que hacen las aplicaciones de mapas.
Lo que hay que recordar del ciclo de vida
Cuatro reglas que evitan los problemas más habituales.
El worker controla el tamaño del búfer. Asignar lienzo.width dentro del worker es lo que redimensiona. El elemento de la página sigue teniendo el tamaño que le dé el CSS.
El estado del contexto se resetea al redimensionar, igual que en un canvas normal. Después de cambiar width, hay que volver a poner la matriz y los estilos.
Las fuentes hay que cargarlas en el worker. Las del documento no están disponibles. Se usa new FontFace y self.fonts.add.
Terminar el worker destruye el canvas. El contenido se queda como estaba en el último fotograma; el elemento no vuelve a ser utilizable desde el hilo principal.
// Limpieza al desmontar
function destruir() {
ro.disconnect();
worker.terminate();
}