Saltar al contenido

El umbral de elementos y de dónde sale

Los dos modelos de coste que compiten, por qué el número mágico ronda el millar, las cinco variables que lo mueven un orden de magnitud, y cómo medir el tuyo en diez minutos.

Dos modelos de coste distintos

Un SVG y un lienzo no son dos formas de hacer lo mismo con distinto rendimiento: su coste crece con cosas distintas.

El coste de SVG crece con el número de nodos. Cada elemento es un nodo del DOM, y sobre cada nodo el navegador hace, en cada fotograma en que algo cambia: resolución de estilo (¿qué reglas CSS le aplican?), cálculo de geometría, construcción de la lista de pintado, y mantenimiento del árbol de accesibilidad. Ese trabajo es aproximadamente lineal en el número de nodos y no depende de cuántos píxeles ocupe cada uno. Diez mil círculos de dos píxeles cuestan aproximadamente lo mismo que diez mil círculos de cien.

El coste del lienzo crece con las llamadas de dibujo y con los píxeles pintados. No existe el concepto de elemento: hay una secuencia de operaciones sobre un mapa de bits. Diez mil fillRect pequeños son diez mil llamadas de función que rellenan poco; diez mil rectángulos que ocupan la pantalla entera son diez mil llamadas que rellenan muchísimo, y esa segunda cifra sí importa.

De ahí sale el cruce. Con pocos elementos, SVG gana por goleada, porque el trabajo por nodo es despreciable y a cambio te regala eventos, CSS, texto seleccionable y accesibilidad. Con muchos, el coste por nodo domina y el lienzo gana, porque no tiene coste por nodo en absoluto.

Y hay una asimetría fundamental: el coste de SVG lo paga el navegador y no lo controlas; el coste del lienzo lo pagas tú y lo controlas entero. Puedes decidir no redibujar lo que no ha cambiado, agrupar por color para evitar cambios de estado, o dibujar en un buffer y copiarlo. Con SVG, el trabajo por nodo lo hace el motor a su manera.

De dónde sale el número

El millar no es una constante de la especificación: es el punto donde, en hardware de gama media y para contenido que cambia, el trabajo por nodo empieza a comerse el presupuesto de un fotograma.

La cuenta aproximada: un fotograma a sesenta imágenes por segundo dispone de 16,7 milisegundos, de los cuales el trabajo de tu código, el recálculo de estilo, el layout, el pintado y la composición tienen que caber todos. Si el recálculo de estilo y el layout de un nodo SVG cuestan del orden de unos pocos microsegundos (que es el orden correcto en hardware actual, y varía mucho según la complejidad de tus selectores), unos pocos miles de nodos ya consumen el presupuesto entero antes de que hayas pintado nada.

Por eso el umbral que se cita suele ser:

  • Por debajo de unos 1.000 nodos: SVG sin pensarlo.
  • Entre 1.000 y 10.000: zona gris, depende de las variables de la sección siguiente.
  • Por encima de 10.000: canvas para los datos, con la excepción de contenido completamente estático.

Esa última excepción es importante y se olvida. Un SVG estático de cincuenta mil nodos se dibuja una vez y se queda quieto: el coste se paga en la carga y luego el navegador no vuelve a tocarlo. Es lento de abrir y perfectamente utilizable después. El umbral bajo aplica al contenido que cambia.

Las cinco variables

Uno: si cambia y con qué frecuencia. Es la que más pesa. Un gráfico estático admite un orden de magnitud más de nodos que uno animado. Y dentro de lo que cambia, importa qué cambia: modificar un transform es más barato que modificar la geometría, porque puede resolverse en la fase de composición sin rehacer el layout.

Dos: la complejidad de cada nodo. Aquí está la confusión más frecuente de todo el tema, y es la del callout: el umbral cuenta nodos, no puntos. Una polilínea de cincuenta mil vértices es un nodo. Un gráfico de líneas con veinte series de mil puntos cada una son veinte nodos, y va perfectamente en SVG. Un gráfico de dispersión con veinte mil puntos son veinte mil nodos, y no.

Tres: los filtros, las máscaras y los recortes. Un filter por elemento es de lo más caro que hay en SVG: obliga a rasterizar el elemento en un buffer aparte, aplicar el pipeline de primitivas y componer. Cien elementos con sombra pueden costar más que diez mil sin ella. Lo mismo con las máscaras. Si aplicas un efecto, aplícalo a un grupo, no a cada hijo, como se explica en la región del filtro.

Cuatro: la complejidad de tus selectores CSS. El recálculo de estilo es proporcional al coste de decidir qué reglas aplican a cada nodo. Reglas con descendientes profundos, con :nth-child o con :has() sobre miles de nodos cambian el orden de magnitud del recálculo. Un SVG generado que ponga atributos de presentación en lugar de clases evita ese trabajo por completo.

Cinco: el hardware del lector. El umbral en un portátil de desarrollo y en un móvil de gama media difieren por un factor que ronda el cinco o el diez. Si tu producto se usa en móviles, mide en un móvil.

Medirlo en tu caso

Diez minutos de medición valen más que cualquier regla general. El experimento mínimo:

JS
function generarSvg(n) {  const NS = 'http://www.w3.org/2000/svg';  const svg = document.createElementNS(NS, 'svg');  svg.setAttribute('viewBox', '0 0 800 600');  svg.setAttribute('width', '800');  svg.setAttribute('height', '600');  const nodos = [];  for (let i = 0; i < n; i++) {    const c = document.createElementNS(NS, 'circle');    c.setAttribute('r', 2);    c.setAttribute('fill', '#89b4fa');    svg.appendChild(c);    nodos.push(c);  }  document.body.appendChild(svg);  return nodos;}function medir(nodos, fotogramas = 60) {  return new Promise(resolve => {    const tiempos = [];    let previo = performance.now();    let k = 0;    function paso(t) {      tiempos.push(t - previo);      previo = t;      // Mover todo: fuerza recalculo de estilo y layout de cada nodo      for (const c of nodos) {        c.setAttribute('cx', Math.random() * 800);        c.setAttribute('cy', Math.random() * 600);      }      if (++k < fotogramas) requestAnimationFrame(paso);      else {        tiempos.sort((a, b) => a - b);        resolve(tiempos[Math.floor(tiempos.length * 0.95)]);  // p95      }    }    requestAnimationFrame(paso);  });}

Y la misma medición contra un lienzo:

JS
function medirLienzo(n, fotogramas = 60) {  const lienzo = document.createElement('canvas');  const dpr = devicePixelRatio;  lienzo.width = 800 * dpr; lienzo.height = 600 * dpr;  lienzo.style.cssText = 'width:800px;height:600px';  document.body.appendChild(lienzo);  const ctx = lienzo.getContext('2d');  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);  return new Promise(resolve => {    const tiempos = [];    let previo = performance.now(), k = 0;    function paso(t) {      tiempos.push(t - previo);      previo = t;      ctx.clearRect(0, 0, 800, 600);      ctx.fillStyle = '#89b4fa';      for (let i = 0; i < n; i++) {        ctx.beginPath();        ctx.arc(Math.random() * 800, Math.random() * 600, 2, 0, Math.PI * 2);        ctx.fill();      }      if (++k < fotogramas) requestAnimationFrame(paso);      else { tiempos.sort((a, b) => a - b); resolve(tiempos[Math.floor(tiempos.length * 0.95)]); }    }    requestAnimationFrame(paso);  });}

Ejecuta las dos con 500, 1.000, 2.000, 5.000, 10.000 y 20.000, y quédate con el n donde el percentil 95 del intervalo entre fotogramas supera los 16,7 milisegundos. Ese es tu umbral, con tu contenido y en tu dispositivo.

Dos avisos sobre el experimento. Usa el percentil 95 y no la media: un gráfico que va a 60 imágenes por segundo con un tirón cada segundo se percibe peor que uno estable a 40. Y mide en el móvil más malo que soportes, no en tu máquina.

Una observación que suele salir de esta medición y sorprende: el lienzo también tiene un techo, y no está tan lejos. Cien mil arc con su beginPath y su fill cuestan bastante, porque cada uno es una llamada al rasterizador. Cuando llegues ahí, las salidas son dibujar puntos con fillRect en lugar de arc, agrupar en un solo Path2D, o subir a WebGL.