wandres.dev
RENDIMIENTO II · Las optimizaciones que valen

El coste de la transparencia y las optimizaciones que son mito

Las alternativas reales a transparent true, qué hace alphaHash frente a alphaTest y a alphaToCoverage, y una lista de optimizaciones populares que no mejoran nada.

⏱ 21 min

La transparencia es cara por tres motivos distintos que se suelen confundir en uno: obliga a ordenar en CPU, impide escribir profundidad y por tanto multiplica el sombreado, y produce resultados incorrectos en cuanto dos objetos se intersecan. Ninguno de los tres se arregla con el mismo remedio, y esa es la razón por la que Three.js ofrece cuatro caminos distintos y no uno. Esta lección cierra el nivel con esos cuatro caminos y con algo igual de útil: la lista de optimizaciones que todo el mundo repite y que, medidas, no aportan nada.

🎯 Al terminar esta lección sabrás
  • Enumerar los tres costes independientes de la transparencia y qué técnica ataca cada uno.
  • Elegir entre alphaTest, alphaHash y alphaToCoverage según el material y el antialiasing disponible.
  • Aplicar el truco de la doble pasada para objetos transparentes cerrados.
  • Descartar seis optimizaciones populares que no resisten una medición.

Los tres costes, por separado

El primero es de CPU: cada frame, los objetos transparentes se ordenan por profundidad. Con doscientos objetos es irrelevante; con veinte mil partículas transparentes en objetos independientes es un cuello real. Se ataca reduciendo el número de objetos transparentes, no su tamaño.

El segundo es de relleno: sin escritura de profundidad, cada capa se sombrea. Se ataca reduciendo el número de capas superpuestas o el coste del fragment shader de cada una. Es el que domina en móvil.

El tercero es de corrección: el orden por centro de objeto falla con objetos que se intersecan y con objetos cóncavos que se ven a sí mismos. No se ataca con rendimiento sino con técnica: doble pasada, recorte por alfa, o aceptar el artefacto.

import * as THREE from 'three';

// La configuracion tipica y por que cada linea esta ahi
const cristal = new THREE.MeshStandardMaterial({
  transparent: true,     // entra en la lista de transparentes, se ordena
  opacity: 0.4,          // el factor de mezcla
  depthWrite: false,     // no ocluye a lo que viene detras: obligatorio
  side: THREE.DoubleSide // se ven las caras traseras, y ahi empieza el lio
});

depthWrite: false con transparent: true es el par que hay que entender. Si dejas la escritura de profundidad activa, el objeto transparente tapa a los que se dibujan después aunque se vea a través de él, y aparecen agujeros. Si la desactivas, el objeto no se ordena consigo mismo y sus propias caras traseras compiten con las delanteras. Los dos problemas son reales y no se pueden tener resueltos a la vez con una sola pasada.

Las alternativas: recorte, dithering y cobertura

Cuando la transparencia es binaria (el píxel se ve o no se ve) hay tres formas de conseguirla sin entrar en la lista de transparentes.

alphaTest compara el alfa contra un umbral y descarta el fragmento si no llega. El objeto sigue siendo opaco a todos los efectos: escribe profundidad, se ordena de delante hacia atrás, y no depende del orden. El precio es el discard, que desactiva el rechazo temprano, y un borde duro con escalones.

const follaje = new THREE.MeshStandardMaterial({
  map: texturaHoja,
  alphaTest: 0.5,
  side: THREE.DoubleSide,
});

alphaHash es la opción de dithering estocástico que Three.js añadió como propiedad de material. En lugar de un umbral fijo, decide si el fragmento vive o muere con un patrón pseudoaleatorio estable comparado contra el alfa. El resultado a un solo fotograma es ruido; acumulado en el tiempo con antialiasing temporal, o simplemente en una escena que se mueve, es transparencia continua sin ordenación y con escritura de profundidad.

const material = new THREE.MeshStandardMaterial({
  color: 0x88ccff,
  opacity: 0.5,
  alphaHash: true,   // en lugar de transparent: true
});

La condición para que alphaHash se vea bien es que haya acumulación temporal o un buen antialiasing. En una escena estática y sin TAA se ve el ruido. En una escena en movimiento con TAA es indistinguible de la transparencia real y es correcta con cualquier orden, incluso con objetos que se intersecan.

alphaToCoverage delega en el MSAA del hardware: en lugar de descartar el fragmento, convierte el alfa en una máscara de las muestras del multisample. Con cuatro muestras obtienes cuatro niveles de transparencia; con ocho, ocho. Exige que el renderer tenga antialiasing activado.

const renderer = new THREE.WebGLRenderer({ antialias: true });

const material = new THREE.MeshStandardMaterial({
  map: texturaHoja,
  alphaTest: 0.5,
  alphaToCoverage: true,   // suaviza el borde del recorte con las muestras MSAA
});

La combinación de alphaTest con alphaToCoverage es la receta estándar para follaje: recorte correcto en profundidad y bordes suaves sin entrar en la lista de transparentes.

Técnica Escribe profundidad Depende del orden Bordes Requiere
transparent: true No Suaves Nada
alphaTest No Duros Nada
alphaTest con alphaToCoverage No Suaves antialias: true
alphaHash No Ruido o suaves Acumulación temporal

La doble pasada para objetos cerrados

Para un objeto transparente cerrado y convexo, como una botella o una burbuja, existe un truco que resuelve el tercer coste, el de corrección, sin técnicas avanzadas: dibujar las caras traseras primero y las delanteras después, en dos objetos separados que comparten geometría.

import * as THREE from 'three';

function cristalDosPasadas(geometria, parametros = {}) {
  const grupo = new THREE.Group();

  const base = {
    color: 0x99ccff,
    transparent: true,
    opacity: 0.35,
    depthWrite: false,
    roughness: 0.05,
    metalness: 0,
    ...parametros,
  };

  const traseras = new THREE.Mesh(
    geometria,
    new THREE.MeshStandardMaterial({ ...base, side: THREE.BackSide }),
  );
  traseras.renderOrder = 0;

  const delanteras = new THREE.Mesh(
    geometria,
    new THREE.MeshStandardMaterial({ ...base, side: THREE.FrontSide }),
  );
  delanteras.renderOrder = 1;

  grupo.add(traseras, delanteras);
  return grupo;
}

const botella = cristalDosPasadas(new THREE.SphereGeometry(1, 48, 32));
scene.add(botella);

Funciona porque para un objeto convexo las caras traseras siempre están detrás de las delanteras desde cualquier ángulo, así que el orden correcto es conocido y fijo. renderOrder lo fuerza. Cuesta el doble de fragmentos, y es exactamente lo mismo que hace MeshPhysicalMaterial internamente cuando activas transmission, solo que ahí lo paga el propio material.

Para objetos cóncavos el truco no vale: hay ángulos desde los que una cara trasera queda delante de otra delantera.

Nivel dios

El error de rendimiento más caro con transparencia no está en el material sino en el tamaño en pantalla. Un sistema de mil partículas transparentes de dos píxeles cada una cuesta cuatro mil fragmentos: nada. Las mismas mil partículas escaladas para que cada una ocupe doscientos píxeles de lado cuestan cuarenta millones de fragmentos, es decir veinte pantallas completas de sombreado por frame. La transparencia no se optimiza contando partículas, se optimiza midiendo el área que cubren. Si tu efecto de humo llena la pantalla con ocho capas, tienes un factor ocho de overdraw sin límite de mezcla y ninguna reducción de conteo lo va a arreglar: hay que reducir el tamaño, el número de capas, o mover el efecto a media resolución.

Seis optimizaciones que son mito

“Menos polígonos siempre va mejor.” Falso en cuanto el cuello está en el fragment shader, que es lo habitual en móvil y en escenas con PBR. Reducir un modelo de cincuenta mil a diez mil triángulos no cambia nada si los píxeles que cubre son los mismos y el shader es el mismo. Los triángulos importan cuando son muchísimos, cuando son diminutos (menos de un píxel, lo que desperdicia el paralelismo de la GPU en cuadrados de dos por dos) o cuando el vertex shader es caro por skinning o morph targets.

“Quitar el antialiasing acelera.” A veces, y menos de lo que parece. En escritorio, el MSAA de cuatro muestras sobre un pipeline sin post-procesado cuesta memoria y ancho de banda pero poco sombreado, porque el fragment shader se ejecuta una vez por píxel, no una por muestra. En móvil con render diferido en tiles el coste es mayor. Lo que sí acelera de verdad, con un factor mucho mayor, es bajar el pixelRatio: pasar de tres a dos en un móvil moderno recorta el número de fragmentos a la mitad.

“Fusionar todo en una geometría es la optimización definitiva.” Solo si el problema era el número de draw calls, y a costa de perder el culling individual. Una escena fusionada dibuja siempre el mundo entero. En mundos grandes el resultado neto es peor.

powerPreference: 'high-performance' hace que vaya más rápido.” Solo tiene efecto en equipos con dos GPU, donde le pide al navegador la discreta en lugar de la integrada. En un móvil o en un equipo con una sola GPU no hace absolutamente nada, y en un portátil enchufado a batería puede empeorar la experiencia térmica.

“Reutilizar objetos Vector3 en el bucle es crítico.” Lo era en 2015. Los recolectores de basura generacionales de los motores actuales manejan objetos pequeños y efímeros con un coste muy bajo, y el perfilado de una escena moderna rara vez muestra el GC como cuello. Sigue siendo buena higiene reutilizar vectores en bucles de miles de iteraciones, pero no es donde están los diez milisegundos que buscas.

“Bajar la resolución de las sombras siempre compensa.” Compensa si el cuello está en la pasada de sombras, que es una pasada de geometría con shader trivial. Si el cuello está en el shading principal, reducir el mapa de sombras de 2048 a 1024 ahorra una fracción del frame y degrada visiblemente el resultado. Mide la pasada de sombras por separado antes de tocarla: desactiva castShadow en todo y compara.

La regla que subyace a las seis: cada optimización ataca un recurso concreto, y aplicarla cuando el cuello está en otro recurso no produce mejora sino trabajo perdido y, a menudo, degradación visual gratuita. El orden correcto es medir, identificar el recurso saturado, y elegir la técnica de esa columna.

El árbol de decisión de la transparencia

Para cerrar el nivel, la secuencia de preguntas que resuelve el noventa por ciento de los casos.

¿La transparencia es binaria, es decir, el píxel se ve o no se ve? Entonces alphaTest, con alphaToCoverage si tienes MSAA. No entra en la lista de transparentes, no hay problemas de orden, y el borde queda decente.

¿Es continua pero el objeto es un plano que nunca se cruza con otro plano transparente? Entonces transparent: true con depthWrite: false es correcto y suficiente. Es el caso de la interfaz, los halos y los efectos planos.

¿Es continua, el objeto es volumétrico y convexo? Doble pasada con BackSide y FrontSide separadas por renderOrder.

¿Es continua, hay muchos objetos que se intersecan y la escena se mueve? alphaHash, y añade antialiasing temporal si el ruido molesta.

¿Es cristal de verdad, con refracción y grosor? MeshPhysicalMaterial con transmission, que resuelve todo lo anterior internamente pero cuesta una pasada extra de la escena entera por fotograma. Esa pasada es una sola, con un render target compartido y cacheado, y la aprovechan todos los objetos transmisivos a la vez. Ahí ya no estás optimizando transparencia, estás pagando un efecto, y la decisión no es cuántos cristales te puedes permitir sino si te puedes permitir el primero: a partir de él, los demás salen casi gratis.

⚔️ Reto práctico

Coge una escena con un sistema de humo de partículas transparentes y mide el tiempo de frame. Sin tocar el número de partículas, reduce a la mitad la escala de cada una y vuelve a medir. Después devuelve la escala original pero renderiza el sistema en un render target a un cuarto de resolución y compón el resultado sobre la escena. Anota las tres cifras: casi siempre la tercera opción gana con diferencia y es la que menos degrada el aspecto.