Saltar al contenido

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.

La transferencia

JS
// Hilo principalconst 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

JS
// render.jslet 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.

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

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

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

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

JS
document.addEventListener('visibilitychange', () => {  worker.postMessage({ tipo: document.hidden ? 'pausa' : 'reanudar' });});

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.

JS
// Limpieza al desmontarfunction destruir() {  ro.disconnect();  worker.terminate();}