wandres.dev
PRIMER PROYECTO · Escena, cámara, renderer

Redimensionar bien: las tres cosas que hay que actualizar

Los dos tamaños que tiene un canvas, por qué hay que llamar a updateProjectionMatrix, y el patrón de redimensionado que no depende de eventos ni entra en bucles.

⏱ 16 min

Redimensionar parece un detalle de fontanería y produce dos de los defectos visuales más reconocibles del 3D web: la imagen estirada cuando la ventana cambia de forma, y la imagen borrosa cuando el canvas se escala por CSS. Ambos vienen del mismo malentendido, que es creer que un canvas tiene un tamaño cuando en realidad tiene dos, y que ninguno de los dos es el que usa la cámara.

🎯 Al terminar esta lección sabrás
  • Distinguir el tamaño de presentación de un canvas del tamaño de su búfer de dibujo.
  • Explicar por qué hay que actualizar la matriz de proyección al cambiar el aspecto.
  • Implementar un redimensionado robusto sin escuchar eventos de ventana.
  • Evitar el bucle infinito al observar el tamaño con ResizeObserver.

Un canvas tiene dos tamaños

Los atributos width y height de un elemento canvas definen el búfer de dibujo: cuántos píxeles reales tiene la imagen. Las propiedades width y height de CSS definen el tamaño de presentación: cuánto espacio ocupa el elemento en la maquetación.

Los dos son independientes. Un canvas con un búfer de trescientos por doscientos y un tamaño CSS de mil doscientos por ochocientos se dibuja con trescientos por doscientos píxeles y se estira al cuádruple para presentarlo, con el aspecto borroso de una imagen ampliada. Al revés también pasa, y ahí el resultado es nítido pero se está pagando trabajo que no se ve.

renderer.setSize(ancho, alto) ajusta los dos: pone el búfer al tamaño pedido multiplicado por la relación de píxeles, y escribe el tamaño en las propiedades de estilo del canvas. Ese comportamiento es cómodo cuando el canvas es el único elemento de la página y es un estorbo cuando la maquetación la controla CSS, porque entonces el motor escribe estilos en línea que compiten con tus reglas. Para ese caso hay un tercer argumento: setSize(ancho, alto, false) cambia el búfer y no toca el estilo.

Las tres actualizaciones

Al cambiar el tamaño hay que hacer tres cosas, y saltarse cualquiera produce un defecto distinto.

Ajustar el búfer del renderer. Si no, la imagen se estira o se recorta.

Actualizar el aspecto de la cámara. La matriz de proyección divide la coordenada horizontal por la relación de aspecto para que un círculo se vea circular. Si el aspecto no coincide con el del destino, todo aparece estirado en horizontal o en vertical.

Llamar a updateProjectionMatrix. Y esta es la que se olvida. camera.aspect es un dato de entrada, no la matriz: cambiarlo no recalcula nada. La matriz solo se reconstruye cuando lo pides explícitamente. El síntoma de olvidarlo es exactamente el mismo que el de no actualizar el aspecto, lo cual despista bastante.

import * as THREE from 'three';

export function ajustar(renderer, camera, ancho, alto) {
  renderer.setSize(ancho, alto);
  camera.aspect = ancho / alto;
  camera.updateProjectionMatrix();      // sin esto, las dos lineas anteriores no sirven
}

Para una cámara ortográfica el ajuste es distinto: no tiene aspect, tiene left, right, top y bottom, que hay que recalcular a mano manteniendo la proporción. Pero updateProjectionMatrix sigue siendo obligatorio.

El patrón que no necesita eventos

El enfoque habitual es escuchar el evento resize de la ventana. Funciona mientras el canvas ocupe la ventana entera, y falla en todos los demás casos: un canvas dentro de un panel que se puede plegar, una barra lateral que aparece, un contenedor de altura variable, un elemento dentro de una cuadrícula que se reordena. En todos ellos el canvas cambia de tamaño sin que la ventana lo haga, y no llega ningún evento.

La alternativa robusta es comprobar el tamaño dentro del bucle de render, comparando el tamaño de presentación actual con el del búfer y ajustando solo cuando difieren. No hay eventos, no hay casos que se escapen, y la comprobación cuesta dos lecturas de propiedad por fotograma.

import * as THREE from 'three';

/**
 * Ajusta el buffer al tamano real de presentacion. Devuelve true si cambio algo.
 * El canvas debe tener su tamano definido por CSS, por ejemplo width y height al 100%.
 */
export function redimensionarSiHaceFalta(renderer, camera) {
  const canvas = renderer.domElement;
  const anchoCss = canvas.clientWidth;
  const altoCss = canvas.clientHeight;
  if (anchoCss === 0 || altoCss === 0) return false;

  const dpr = Math.min(window.devicePixelRatio, 2);
  const anchoBuffer = Math.floor(anchoCss * dpr);
  const altoBuffer = Math.floor(altoCss * dpr);

  if (canvas.width === anchoBuffer && canvas.height === altoBuffer) return false;

  renderer.setPixelRatio(dpr);
  renderer.setSize(anchoCss, altoCss, false);   // false: el CSS manda sobre el estilo
  camera.aspect = anchoCss / altoCss;
  camera.updateProjectionMatrix();
  return true;
}

// Uso dentro del bucle.
export function arrancar(renderer, scene, camera) {
  renderer.setAnimationLoop(() => {
    redimensionarSiHaceFalta(renderer, camera);
    renderer.render(scene, camera);
  });
}

La comparación se hace contra el tamaño del búfer, no contra el tamaño anterior guardado en una variable, lo que hace la función idempotente: llamarla mil veces seguidas solo reasigna la primera. Y la comprobación de tamaño cero evita el caso del contenedor todavía sin maquetar, que produciría una división por cero en el aspecto y una matriz de proyección con valores no numéricos que ensucia toda la escena sin ningún mensaje.

Observar el tamaño del propio canvas con ResizeObserver puede entrar en bucle infinito

ResizeObserver es la herramienta correcta cuando quieres reaccionar de inmediato en lugar de esperar al siguiente fotograma, y tiene una trampa que el navegador señala con un aviso críptico sobre un bucle de notificaciones no entregadas. Ocurre así: observas el canvas, y dentro del manejador llamas a renderer.setSize con el comportamiento por defecto, que escribe el ancho y el alto en el estilo en línea del canvas. Ese cambio de estilo altera el tamaño del elemento observado, lo que dispara el observador de nuevo, que vuelve a escribir el estilo, y así indefinidamente. En el mejor caso el navegador corta el ciclo y descarta notificaciones; en el peor, la página se queda a tirones con la maquetación oscilando entre dos tamaños que se persiguen. Hay dos formas correctas de evitarlo y conviene elegir una conscientemente. La primera: observa el contenedor padre, no el canvas, y deja que el canvas se dimensione por CSS; así lo que escribes nunca es lo que observas. La segunda: observa el canvas pero llama siempre a setSize con el tercer argumento en false, de modo que solo cambie el búfer de dibujo y jamás el estilo. La segunda es la que uso, porque mantiene una única fuente de verdad para la maquetación, que es la hoja de estilos. Y hay un matiz más para el purista: el manejador de ResizeObserver conviene que no dibuje, solo que marque como sucio; redimensionar el búfer de dibujo obliga al driver a reasignar memoria, y hacerlo varias veces dentro del mismo fotograma —perfectamente posible mientras alguien arrastra el borde de una ventana— produce tirones que se atribuyen a la escena y son del redimensionado.

Dos apuntes que se agradecen luego

Reasignar el búfer no es gratis. Cada cambio de tamaño obliga a recrear el búfer de color, el de profundidad y todos los objetivos de render intermedios que tengas, incluidos los del post-procesado. Durante un arrastre continuo del borde de la ventana eso ocurre decenas de veces por segundo. Si notas tirones al redimensionar y no durante el uso normal, la causa es esta, y la cura es aplazar el ajuste hasta que el tamaño lleve un par de fotogramas estable.

Varias vistas, un renderer. Cuando necesites dos o más cámaras sobre la misma escena —una vista principal y un minimapa, por ejemplo— la solución no es crear un segundo renderer sino dibujar dos veces sobre regiones distintas del mismo canvas. renderer.setViewport define la región de dibujo y renderer.setScissor junto con setScissorTest limita también el borrado, que si no afectaría a todo el canvas. Cada vista necesita su propia relación de aspecto en su cámara, calculada con las dimensiones de su región y no con las del canvas.

La relación de píxeles que aparece en el ejemplo con un tope de dos merece su propia explicación, porque es la decisión de rendimiento más rentable de todo el nivel: el device pixel ratio.