wandres.dev
RENDIMIENTO II · Las optimizaciones que valen

Compartir materiales y el coste real de un programa de shader

Qué genera exactamente un programa de shader en Three.js, por qué la clave de caché importa más que el número de materiales, y cómo evitar el parón de compilación en el primer frame.

⏱ 19 min

La frase que circula por foros y charlas es que cada material único es un programa de shader. Es casi verdad, y el “casi” es justamente lo que separa una optimización eficaz de una superstición. Three.js no compila un programa por instancia de material: compila uno por configuración distinta, y decide qué es distinto con una clave de caché que puedes inspeccionar. Entender esa clave te dice qué cambios son gratis, cuáles duplican programas y por qué la primera vez que un objeto entra en cámara la página se congela un cuarto de segundo.

🎯 Al terminar esta lección sabrás
  • Explicar qué entra en la clave de caché de un programa y qué cambios de material no generan uno nuevo.
  • Medir el número de programas vivos y relacionarlo con el parón del primer frame.
  • Compilar shaders por adelantado con compileAsync sin bloquear el hilo principal.
  • Reducir la variedad de materiales de una escena sin perder la variedad visual.

Qué es un programa y quién decide que hay uno nuevo

Un programa de shader es el par vertex y fragment ya compilado y enlazado por el driver. Es un objeto de la GPU, costoso de crear y barato de usar. Three.js mantiene una lista de programas vivos con un contador de usos: cuando un material necesita renderizarse, el renderer construye una clave de caché a partir de decenas de parámetros y busca si ya existe un programa con esa clave. Si existe, lo reutiliza e incrementa el contador. Si no, compila.

Esto tiene una consecuencia que contradice el mito: dos instancias distintas de MeshStandardMaterial con exactamente la misma configuración comparten programa. Clonar un material no duplica shaders.

import * as THREE from 'three';

const a = new THREE.MeshStandardMaterial({ color: 0xff0000 });
const b = new THREE.MeshStandardMaterial({ color: 0x00ff00 });

scene.add(new THREE.Mesh(new THREE.BoxGeometry(), a));
scene.add(new THREE.Mesh(new THREE.BoxGeometry(), b));

renderer.render(scene, camera);

// Dos materiales, un solo programa: el color es un uniform, no una define
console.log(renderer.info.programs.length);

Lo que sí genera un programa nuevo es cualquier cosa que cambie el código del shader. El color es un uniform y viaja como dato. La presencia de un normalMap es una define de preprocesador y cambia el código. Esa es la frontera.

Entran en la clave, entre otras cosas: qué mapas tiene el material y cuáles no, el número de luces de cada tipo de la escena, si hay sombras y con qué filtro, si el material es skinned, si tiene morph targets y cuántos, el modo de tone mapping, el espacio de color de salida, los planos de recorte, la niebla, si hay alphaTest, si usa vertexColors, si usa flatShading, y el resultado de material.customProgramCacheKey() cuando lo sobrescribes.

// Cuenta cuantas configuraciones distintas hay de verdad en la escena
function contarConfiguraciones(renderer) {
  const filas = renderer.info.programs.map((p) => ({
    usos: p.usedTimes,
    clave: p.cacheKey.slice(0, 80),
  }));
  console.table(filas);
  return filas.length;
}

renderer.info.programs es un array de los programas vivos, cada uno con su cacheKey y su usedTimes. Es la herramienta directa para responder a “por qué tengo cuarenta programas si solo uso tres materiales”. Casi siempre la respuesta está en una define que varía sin que te dieras cuenta: un modelo cargado donde la mitad de las mallas trae vertexColors y la otra mitad no, o texturas que en unos materiales existen y en otros son null.

ℹ️
Nota

Añadir una luz a la escena invalida la clave de todos los materiales afectados y fuerza una recompilación completa. Por eso una escena que añade luces dinámicamente da tirones que no aparecen en el perfilado de JavaScript: el tiempo se lo come el driver compilando, fuera de tu código. Si necesitas encender y apagar luces, créalas todas al principio y juega con intensity a cero.

El parón de la primera aparición

Compilar y enlazar un programa de MeshStandardMaterial con sombras y varias luces cuesta del orden de decenas de milisegundos en un portátil y puede pasar de cien en un móvil. Y ocurre en el momento exacto en que el objeto entra por primera vez en el frustum, que suele ser el peor momento posible: justo cuando el usuario gira la cámara.

Three.js ofrece dos vías para adelantarlo. La síncrona bloquea, y por eso solo sirve durante una pantalla de carga. La asíncrona es la buena.

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);

const loader = new GLTFLoader();
const gltf = await loader.loadAsync('/modelos/nivel.glb');

// Compila los programas de este subarbol contra esta escena y esta camara,
// resolviendo cuando el driver ha terminado. No bloquea el hilo.
await renderer.compileAsync(gltf.scene, camera, scene);

// Solo ahora el objeto entra en el grafo: su primer render ya no compila nada
scene.add(gltf.scene);

compileAsync recibe el objeto a compilar, la cámara y opcionalmente la escena, porque el conjunto de luces y la niebla de la escena forman parte de la clave. Compilar contra una escena vacía y luego añadir el objeto a la escena real no sirve de nada: la clave será distinta y se recompilará. Es un error frecuente y silencioso.

La versión síncrona, renderer.compile(scene, camera), hace lo mismo bloqueando. Tiene sentido en el último paso de una pantalla de carga, donde bloquear es aceptable porque nada se está animando todavía. En cualquier otro punto produce exactamente el tirón que intentabas evitar, solo que antes.

Reducir la variedad sin perder la variedad

El objetivo práctico no es tener un material, es tener pocas configuraciones. Y hay tres palancas para llegar ahí sin que la escena se vuelva monótona.

La primera es mover la variación del código a los datos. Si diez objetos solo se distinguen por el color, un material y diez valores de uniform no valen: cada Mesh con material propio es de todos modos un draw call. Lo que sí vale es un único InstancedMesh con setColorAt, o un BatchedMesh con color por instancia. La variación pasa a ser un atributo, y atributos hay uno por vértice sin coste de estado.

La segunda es unificar el conjunto de mapas. Un modelo donde unas mallas tienen aoMap y otras no genera dos programas. Si el resto es idéntico, casi siempre sale más barato dar a todas un aoMap (aunque sea una textura blanca de un píxel compartida) que mantener dos variantes. La textura de un píxel cuesta prácticamente nada y colapsa dos claves en una.

import * as THREE from 'three';

// Textura blanca de 1x1 compartida: sirve como aoMap neutro
const blanco = new THREE.DataTexture(
  new Uint8Array([255, 255, 255, 255]),
  1,
  1,
  THREE.RGBAFormat,
);
blanco.needsUpdate = true;

function normalizarMateriales(raiz) {
  raiz.traverse((o) => {
    if (!o.isMesh) return;
    const mats = Array.isArray(o.material) ? o.material : [o.material];
    for (const m of mats) {
      if (m.isMeshStandardMaterial && m.aoMap === null) {
        m.aoMap = blanco;
        m.needsUpdate = true;
      }
    }
  });
}

La tercera es el atlas: unificar texturas para que varios objetos que hoy tienen map distinto pasen a compartir uno. Eso reduce cambios de estado además de claves, y tiene su propia lección en texture atlas y texture arrays.

Nivel dios

material.needsUpdate = true no es gratis: fuerza a recalcular la clave y, si cambió, a compilar. Ponerlo dentro del bucle de render, aunque sea condicionalmente, es una de las causas más frecuentes de escenas que empiezan a treinta fotogramas y bajan a cinco a los dos minutos, porque el renderer va acumulando programas y el driver acaba desalojándolos. Si necesitas cambiar una propiedad cada frame, comprueba primero si esa propiedad entra en la clave: cambiar color, opacity, roughness o cualquier uniform no exige needsUpdate. Solo lo exigen los cambios estructurales, y esos no deberían ocurrir cada frame.

Cuándo un material propio compensa

Todo lo anterior empuja hacia la uniformidad, pero hay un caso donde el material a medida gana claramente: cuando el material integrado hace mucho más de lo que necesitas. Un MeshStandardMaterial evalúa la ecuación de renderizado completa, con IBL, sombras y todos los mapas presentes. Si un objeto solo necesita un color plano con una textura, un MeshBasicMaterial es un programa distinto pero un fragment shader mucho más corto, y en escenas limitadas por relleno esa diferencia se nota más que cualquier ahorro de draw calls.

La regla que funciona: uniformiza los materiales de los objetos que dominan el número de llamadas, y especializa los materiales de los objetos que dominan el número de píxeles. Suelen ser conjuntos distintos, y es perfectamente coherente tener una escena con tres programas para diez mil objetos pequeños y un cuarto programa dedicado al fondo de pantalla completa.

Sobre onBeforeCompile y las extensiones de material: cuando modificas el código de un material integrado sin declarar una clave propia, Three.js no sabe que ese material genera un shader distinto, y dos materiales con modificaciones diferentes pueden acabar compartiendo el programa equivocado. La solución es material.customProgramCacheKey, que devuelve una cadena que se concatena a la clave.

const material = new THREE.MeshStandardMaterial();
material.userData.modo = 'ondas';

material.onBeforeCompile = (shader) => {
  shader.uniforms.uTiempo = { value: 0 };
  shader.vertexShader = 'uniform float uTiempo;\n' + shader.vertexShader;
};

// Sin esto, otro material con onBeforeCompile distinto podria reutilizar
// este programa por tener el resto de parametros identicos
material.customProgramCacheKey = () => material.userData.modo;
⚔️ Reto práctico

Carga un modelo glTF cualquiera de varios materiales, renderiza un frame y vuelca renderer.info.programs con la tabla del principio. Cuenta cuántos programas hay y compara con el número de materiales distintos del modelo. Luego busca el par de claves más parecidas y localiza el único token que las diferencia: casi siempre es un mapa presente en uno y ausente en otro. Neutralízalo con la textura de un píxel y comprueba que el contador baja.