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