wandres.dev
EL CANVAS · Contexto, formato y configuración

getCurrentTexture cada frame y el redimensionado que no rompe nada

Por qué la textura del canvas no se puede guardar, cuándo expira, y cómo se redimensiona un canvas con búfer de profundidad y objetivos intermedios sin fugas ni parpadeos.

⏱ 18 min

getCurrentTexture() es la única función de WebGPU que devuelve un objeto con fecha de caducidad. La regla —pedirla cada fotograma y no guardarla nunca— se repite en todas partes sin explicar de qué depende, y el resultado es que la gente la aplica mal en los dos sentidos: guardándola cuando no debe, o pidiéndola varias veces por fotograma creyendo que obtiene texturas distintas.

🎯 Al terminar esta lección sabrás
  • Explicar el mecanismo de expiración de la textura del canvas.
  • Escribir un bucle de fotograma con las llamadas en el orden correcto.
  • Implementar un redimensionado que recrea las texturas dependientes sin fugas.
  • Evitar el parpadeo y los errores de dimensión cero al redimensionar.

Qué devuelve y cuándo caduca

getCurrentTexture() devuelve una GPUTexture con el tamaño del canvas y el formato de la configuración. Dos hechos gobiernan su comportamiento.

Devuelve siempre el mismo objeto hasta que expira. Llamarla tres veces en el mismo fotograma da tres referencias a la misma textura, no tres texturas. Eso es útil: no hace falta pasarla como parámetro por todo el código de un fotograma, se puede volver a pedir donde haga falta.

Al expirar, la textura se destruye. La especificación define la operación de expiración como llamar a destroy() sobre la textura actual. Cualquier referencia que hayas guardado —a la textura o a una vista creada a partir de ella— queda inservible, y usarla es un error de validación.

La expiración ocurre en varios momentos definidos por la especificación: cuando el navegador presenta el contenido del canvas, cuando llamas a configure(), cuando el canvas cambia de tamaño, y en algún otro caso como transferToImageBitmap() sobre un OffscreenCanvas. Hay además una expiración por tarea automática, cuya implementación la especificación deja como opcional.

Esa última nota es importante para entender la regla práctica: la expiración está garantizada en unos casos concretos y no en todos, así que un código que guarde la textura puede funcionar en un navegador y fallar en otro, o funcionar con el canvas visible y fallar cuando la pestaña pasa a segundo plano. La regla «pídela cada fotograma» no es una recomendación de estilo: es la única forma de no depender de un comportamiento que varía.

⚠️
Las vistas también caducan

texture.createView() produce un GPUTextureView que apunta a la textura. Cuando la textura se destruye, la vista deja de valer. El error clásico es guardar la vista en lugar de la textura, creyendo que se evita el problema. Se crea una vista nueva cada fotograma, y crear una vista es barato.

El bucle correcto

function frame(tiempoMs) {
  ajustarTamano();                     // 1. antes de pedir la textura

  const textura = context.getCurrentTexture();   // 2. una vez por fotograma
  const vista = textura.createView();

  const encoder = device.createCommandEncoder({ label: 'frame' });
  const pase = encoder.beginRenderPass({
    colorAttachments: [{
      view: vista,
      clearValue: { r: 0.04, g: 0.04, b: 0.06, a: 1 },
      loadOp: 'clear',
      storeOp: 'store',
    }],
    depthStencilAttachment: {
      view: profundidad.createView(),
      depthClearValue: 1.0,
      depthLoadOp: 'clear',
      depthStoreOp: 'store',
    },
  });

  dibujarEscena(pase, tiempoMs);
  pase.end();
  device.queue.submit([encoder.finish()]);      // 3. y se acabo el fotograma

  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

El orden importa en un punto: el ajuste de tamaño va antes de pedir la textura. Si cambias canvas.width después de haber obtenido la textura, esa textura expira y el pase que la usa falla.

Y hay un detalle sobre requestAnimationFrame que conviene tener claro: cuando la pestaña no está visible, deja de dispararse. Eso es lo que quieres —no gastar GPU en algo que nadie ve— y significa que tu bucle se para solo. Si tienes trabajo que debe continuar en segundo plano, no lo cuelgues del bucle de render.

Redimensionar

El canvas tiene dos tamaños independientes: el del búfer, en canvas.width y canvas.height, y el de presentación, que decide el layout. Sincronizarlos es cosa tuya y hay que hacerlo teniendo en cuenta la densidad de píxeles del dispositivo.

La versión completa, con las tres precauciones que hacen falta:

const MAX_DPR = 2;

function ajustarTamano() {
  const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
  const rect = canvas.getBoundingClientRect();
  const w = Math.max(1, Math.min(Math.round(rect.width  * dpr), maxDim));
  const h = Math.max(1, Math.min(Math.round(rect.height * dpr), maxDim));

  if (canvas.width === w && canvas.height === h) return false;

  canvas.width = w;
  canvas.height = h;
  recrearTexturasDependientes(w, h);
  return true;
}

Math.max(1, ...) evita la dimensión cero, que es un error de validación y ocurre en cuanto el canvas está oculto con display:none, dentro de un contenedor colapsado, o antes del primer layout.

Math.min(..., maxDim) acota contra device.limits.maxTextureDimension2D, cuyo valor por defecto es 8192. Un monitor 5K con densidad 2 pide 10240 píxeles de ancho y eso excede el límite garantizado.

La comparación antes de asignar es esencial y no es una microoptimización: asignar canvas.width siempre reinicia el búfer y expira la textura actual, aunque el valor sea el mismo. Hacerlo cada fotograma sin comprobar destruye y recrea la cadena de imágenes sesenta veces por segundo.

Las texturas que hay que recrear con él

El canvas se redimensiona solo. Todo lo demás, no. Cualquier textura cuyo tamaño tenga que coincidir con el del canvas hay que destruirla y crearla de nuevo, y esa lista crece rápido: el búfer de profundidad, los objetivos intermedios del post-proceso, el G-buffer si haces render diferido, el objetivo de resolución si usas multimuestreo.

let profundidad = null;

function recrearTexturasDependientes(w, h) {
  profundidad?.destroy();          // liberar la anterior de forma explicita
  profundidad = device.createTexture({
    label: 'depth',
    size: [w, h],
    format: 'depth24plus',
    usage: GPUTextureUsage.RENDER_ATTACHMENT,
  });
}

El destroy() explícito no es opcional en la práctica. Un búfer de profundidad de 2560 por 1440 en depth24plus son del orden de 15 MB. Redimensionar una ventana arrastrando el borde dispara decenas de eventos por segundo; sin destrucción explícita se acumulan cientos de megabytes de texturas huérfanas esperando al recolector, y el resultado es la pérdida de dispositivo por falta de memoria. Es exactamente el fallo que se describe como «se cae si redimensiono un rato».

Por la misma razón conviene limitar la frecuencia del redimensionado real. Un ResizeObserver que anota el tamaño pendiente, y el ajuste efectivo dentro del bucle de fotograma, colapsa una ráfaga de cincuenta eventos en un solo cambio:

let pendiente = null;
new ResizeObserver((entries) => { pendiente = entries[0]; }).observe(canvas);

function frame(t) {
  if (pendiente) { ajustarTamano(); pendiente = null; }
  // ... resto del fotograma
}

ResizeObserver es mejor que el evento resize de la ventana porque detecta cambios provocados por el layout —un panel lateral que se pliega, una columna de rejilla que cambia— y no solo por la ventana.

El redimensionado es el que más problemas de producción causa, y la razón es que nadie lo prueba en movimiento

Todo el mundo prueba el redimensionado igual: arrastra el borde de la ventana, suelta, mira que se vea bien. Y así el 90% de los fallos no aparecen, porque los fallos del redimensionado son transitorios y ocurren durante el arrastre, no al final.

Cuatro que aparecen siempre y que solo se ven en movimiento. Uno: usar la textura del canvas después de haber cambiado el tamaño en el mismo fotograma, que produce un error de validación intermitente proporcional a la velocidad del arrastre. Dos: recrear el búfer de profundidad con el tamaño nuevo mientras el pase todavía apunta al viejo, y ver un pase entero fallar por incompatibilidad de dimensiones. Tres: la fuga de memoria acumulada, que solo se manifiesta tras varios segundos de arrastre continuo. Cuatro: la matriz de proyección calculada con la relación de aspecto anterior, que produce un fotograma deformado que nadie ve porque pasa en 16 milisegundos, pero que en una grabación se nota.

La prueba que los saca todos cuesta un minuto: arrastra el borde de la ventana durante diez segundos seguidos, en diagonal, con la consola abierta y el monitor de memoria del navegador visible. Si al final no hay errores, la memoria ha vuelto a su sitio y la imagen no ha parpadeado, el redimensionado está bien. Ese minuto encuentra más bugs reales que cualquier otra prueba manual de un proyecto de WebGPU, y no lo hace prácticamente nadie.

Con la superficie de salida resuelta, toca la entrada: los búferes y cómo se crean.