Límites de tamaño, memoria y el canvas que se queda en blanco
Conocer los techos reales del tamaño de un canvas, detectarlos en tiempo de ejecución y aplicar la estrategia de teselado cuando el lienzo no cabe.
Un canvas suficientemente grande deja de funcionar, y lo hace de la peor manera posible: sin lanzar ninguna excepción. El elemento existe, el contexto existe, las llamadas de dibujo no se quejan, y el resultado es un rectángulo en blanco o negro. Los límites dependen del navegador, del sistema y de la memoria disponible en ese momento, así que la única defensa es detectarlos en ejecución y tener una estrategia para cuando el lienzo no cabe.
- Enumerar los tres tipos de límite que afectan al tamaño de un canvas.
- Escribir una prueba en tiempo de ejecución que detecte el área máxima utilizable.
- Aplicar la estrategia de teselado para superficies mayores que el límite.
- Reconocer los síntomas de agotamiento de memoria gráfica antes del fallo.
Tres límites distintos
Cuando se habla del “tamaño máximo del canvas” se están mezclando tres restricciones independientes, y conviene separarlas porque se detectan de forma distinta.
El límite por dimensión. Cada eje tiene un máximo, típicamente 32767 o 65535 según el motor. Superarlo hace que el canvas se cree con dimensiones truncadas o que no se cree en absoluto.
El límite por área total. Independiente del anterior y mucho más bajo. Un canvas de 20000 por 20000 no supera ningún límite por eje pero son 400 megapíxeles, es decir, 1,6 GB de búfer, y ningún navegador lo permite. Los límites de área rondan los cientos de megapíxeles en escritorio y son bastante menores en móvil.
El límite por memoria disponible. No es un número fijo: depende de cuánta memoria de vídeo queda, de cuántos canvas hay vivos en la pestaña y en otras pestañas, y de lo que esté haciendo el sistema. Un canvas que se crea sin problemas por la mañana puede fallar por la tarde.
Los valores concretos varían entre navegadores, versiones y plataformas, y cambian con el tiempo, así que codificar una constante en tu aplicación es garantía de que estará mal. Safari en iOS ha sido históricamente el más restrictivo por la limitación de memoria de los dispositivos, y sigue siendo el que primero falla.
Detectar el límite en ejecución
La prueba fiable no es preguntar sino intentar: crear un canvas del tamaño deseado, pintar un píxel en la esquina más lejana y comprobar si está ahí.
/** Devuelve true si un canvas de ese tamano es realmente utilizable. */
function cabeCanvas(ancho, alto) {
const c = document.createElement('canvas');
c.width = ancho;
c.height = alto;
// Si el navegador trunco las dimensiones, ya sabemos que no cabe
if (c.width !== ancho || c.height !== alto) return false;
const ctx = c.getContext('2d', { willReadFrequently: true });
if (!ctx) return false;
ctx.fillStyle = '#fff';
ctx.fillRect(ancho - 1, alto - 1, 1, 1);
const ok = ctx.getImageData(ancho - 1, alto - 1, 1, 1).data[3] === 255;
c.width = c.height = 0; // liberar el buffer cuanto antes
return ok;
}
La comprobación del alfa en la última fila es la clave: si el navegador creó el elemento pero no pudo asignar el búfer, esa lectura devuelve cero. El willReadFrequently evita penalizar la lectura, y poner las dimensiones a cero al final es la forma más directa de pedir que el búfer se libere sin esperar al recolector.
A partir de esa función se construye una búsqueda binaria del área máxima:
function areaMaxima(lado = 'cuadrado', tope = 40000) {
let bajo = 1, alto = tope;
while (bajo < alto) {
const medio = Math.ceil((bajo + alto) / 2);
const cabe = lado === 'cuadrado'
? cabeCanvas(medio, medio)
: cabeCanvas(medio, 1);
if (cabe) bajo = medio; else alto = medio - 1;
}
return bajo;
}
Esta búsqueda crea y destruye una docena de canvas grandes, así que no la ejecutes en cada carga de página. Hazla una vez, guarda el resultado y trátalo como una estimación conservadora, no como un valor exacto: el límite real depende de la memoria libre en ese instante.
No hay ninguna excepción, ningún evento y ninguna propiedad que te diga que el búfer no se pudo asignar. La secuencia real en producción es esta: el usuario abre un documento grande, la aplicación calcula que necesita un canvas de 12000 por 9000, lo crea, dibuja durante trescientos milisegundos y muestra un rectángulo vacío. En la consola no hay nada. En el panel de red no hay nada. Y el bug no se reproduce en el portátil del desarrollador porque allí sí cabe. Lo que hay que hacer es convertir ese fallo silencioso en un fallo explícito, y solo hay una forma: verificar el búfer justo después de crearlo, siempre, para cualquier canvas cuyo tamaño no controles tú. Son cuatro líneas —pintar un píxel en la esquina, leerlo, comprobar el alfa— y cuestan una lectura de un píxel, que en un canvas recién creado y aún no compuesto es barata. Con esa comprobación puedes degradar a una estrategia de teselado, reducir la resolución o avisar al usuario, en lugar de mostrar un rectángulo vacío. Y hay un matiz que rara vez se menciona: el fallo también puede ocurrir más tarde, cuando el sistema reclama memoria de una pestaña en segundo plano. Un canvas grande que funcionaba puede vaciarse al volver a la pestaña. Ese caso concreto es lo que los eventos contextlost y contextrestored existen para señalar, y es un argumento definitivo para que la escena viva en datos y el pintado sea reproducible.
Teselado: cuando el lienzo no cabe
Cuando la superficie lógica supera lo que un solo búfer puede contener —un documento de diseño de veinte mil píxeles de lado, un mapa a alta resolución, una partitura larga— la solución es dividirla en teselas: canvas pequeños que cubren regiones, de los que solo existen los que hacen falta.
class Teselas {
constructor({ lado = 1024, dibujarRegion }) {
this.lado = lado;
this.dibujarRegion = dibujarRegion;
this.cache = new Map(); // clave "col,fila" -> canvas
this.maxTeselas = 48; // techo duro: sin esto la cache crece sin fin
}
tesela(col, fila, dpr) {
const clave = `${col},${fila},${dpr}`;
let c = this.cache.get(clave);
if (c) {
this.cache.delete(clave); // reinsertar al final = orden de uso reciente
this.cache.set(clave, c);
return c;
}
c = document.createElement('canvas');
c.width = c.height = this.lado * dpr;
const ctx = c.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.translate(-col * this.lado, -fila * this.lado);
this.dibujarRegion(ctx, col * this.lado, fila * this.lado, this.lado);
this.cache.set(clave, c);
if (this.cache.size > this.maxTeselas) {
const vieja = this.cache.keys().next().value;
const cv = this.cache.get(vieja);
cv.width = cv.height = 0; // liberar el buffer explicitamente
this.cache.delete(vieja);
}
return c;
}
pintarVista(ctx, vista, dpr) {
const c0 = Math.floor(vista.x / this.lado);
const c1 = Math.floor((vista.x + vista.ancho) / this.lado);
const f0 = Math.floor(vista.y / this.lado);
const f1 = Math.floor((vista.y + vista.alto) / this.lado);
for (let f = f0; f <= f1; f++) {
for (let c = c0; c <= c1; c++) {
ctx.drawImage(this.tesela(c, f, dpr),
c * this.lado - vista.x, f * this.lado - vista.y,
this.lado, this.lado);
}
}
}
}
Tres detalles de esa implementación que no son obvios. El Map reinsertado en cada acierto implementa una política de expulsión por uso menos reciente sin ninguna estructura adicional, aprovechando que los mapas de JavaScript conservan el orden de inserción. El techo de teselas es obligatorio: sin él, un usuario que recorra un documento grande acumula gigabytes. Y poner las dimensiones a cero antes de descartar una tesela es lo que garantiza que el búfer se libere pronto en lugar de esperar a que el recolector se fije en un objeto que, desde su punto de vista, pesa unos pocos bytes.
Síntomas de agotamiento
Antes del fallo definitivo hay señales, y reconocerlas ahorra horas.
El canvas pierde la aceleración. El rendimiento cae de golpe sin que el código haya cambiado. El navegador ha decidido mover uno o varios canvas a memoria del sistema porque no cabía en la de vídeo.
Aparecen rectángulos en blanco intermitentes. Teselas que se pintan y se vacían. Es la memoria de vídeo reciclándose bajo presión.
La pestaña muere sin error. El proceso de renderizado ha superado su límite de memoria y el navegador lo ha terminado. En el gestor de tareas del navegador se ve el consumo subiendo antes del final.
El heap de JavaScript no crece. Y eso es lo que más despista: como los búferes viven fuera del heap, el gráfico de memoria del panel de rendimiento se queda plano mientras el proceso consume gigabytes. La herramienta correcta para diagnosticar esto es el gestor de tareas del propio navegador, que muestra la huella de memoria y la memoria de GPU por pestaña.
La defensa preventiva es siempre la misma: poner un techo a cualquier estructura de canvas que crezca, liberar los búferes explícitamente poniendo sus dimensiones a cero, y llamar a close() sobre los ImageBitmap que dejes de usar, que es el único recurso gráfico de la plataforma con liberación explícita.