El tiempo como uniform y las constantes con defines
Cómo alimentar un shader con el tiempo sin perder precisión, y cuándo un valor debe ser un define y no un uniform, con el coste de recompilación que eso implica.
Casi todo shader interesante depende del tiempo, y casi todo el mundo lo pasa mal: acumulando en el sitio equivocado, perdiendo precisión a los veinte minutos, o recompilando el programa cada vez que se mueve un deslizador. Esta lección cierra el nivel con las dos formas de configurar un shader desde fuera —una que cuesta nanosegundos y otra que cuesta milisegundos— y con el criterio para elegir.
- Alimentar el tiempo desde
THREE.Timery repartirlo entre varios consumidores. - Anticipar la pérdida de precisión de un tiempo acumulado y aplicar el remedio.
- Declarar
definesy saber exactamente qué obliga a recompilar. - Elegir entre uniform y define con un criterio que no sea la intuición.
El tiempo, bien traído
THREE.Timer separa la actualización de la consulta, y esa separación es justo lo que hace falta cuando varios sistemas necesitan el mismo tiempo:
const reloj = new THREE.Timer();
reloj.connect( document );
function tick( marca ) {
reloj.update( marca ); // una vez por cuadro, antes de consultar
const dt = reloj.getDelta(); // segundos desde el cuadro anterior
const t = reloj.getElapsed(); // segundos desde el arranque
...
}
Una vez llamado update(), los dos métodos son consultas puras: el bucle principal, un sistema de partículas y un mezclador de animación pueden pedirlos en el mismo cuadro y los tres reciben el mismo valor. La regla que sí sigue vigente es la otra: update() una sola vez por cuadro, en el nivel más alto del bucle. Si dos módulos llaman a update() por su cuenta, el segundo mide un intervalo próximo a cero.
let tiempo = 0;
const reloj = new THREE.Timer();
reloj.connect( document );
function tick( marca ) {
reloj.update( marca );
const dt = Math.min( reloj.getDelta(), 0.05 ); // techo contra el cuadro lento suelto
tiempo += dt;
material.uniforms.uTiempo.value = tiempo;
material.uniforms.uDelta.value = dt;
renderer.render( scene, camera );
requestAnimationFrame( tick );
}
Ese techo en el delta importa aunque el reloj esté conectado al documento. connect( document ) cubre el caso más frecuente —la pestaña oculta—, pero no los demás: una pausa del depurador, una recolección de basura larga, una compilación de shader que bloquea el hilo. Sin el techo, cualquier simulación que integre el delta explota en un solo paso. Fíjate además en que el tiempo acumulado a mano no es el mismo que getElapsed(): este último suma los deltas reales, sin recortar, así que para alimentar el shader interesa el acumulador propio.
Precisión: el tiempo no puede crecer para siempre
Un float de treinta y dos bits tiene veinticuatro bits de mantisa. A los mil segundos, la distancia entre valores representables consecutivos es de unos sesenta microsegundos; a los cien mil segundos —veintiocho horas— es de casi ocho milisegundos, es decir, la mitad de un cuadro. Una animación basada en sin( uTiempo * 10.0 ) empieza a dar saltos visibles mucho antes de eso, porque multiplicar por diez multiplica el error por diez.
En una página web que se abre y se cierra eso no llega a ocurrir. En un quiosco, una instalación o un salvapantallas que lleva días encendido, sí. La solución es envolver el tiempo en un periodo que no rompa la continuidad de la animación:
// Envolver a un periodo que sea multiplo comun de todas las frecuencias que usas
const PERIODO = 1000; // segundos
material.uniforms.uTiempo.value = tiempo % PERIODO;
El envoltorio produce un salto salvo que todas las funciones periódicas del shader completen un número entero de ciclos en PERIODO. Con sin( uTiempo * k ) eso exige que k * PERIODO sea múltiplo de dos pi. Una alternativa más robusta es pasar directamente la fase ya envuelta:
material.uniforms.uFase.value = ( tiempo * 0.5 ) % ( Math.PI * 2 );
Y una tercera vía, la que usan las simulaciones: no pasar tiempo absoluto en absoluto, solo el delta, y acumular el estado dentro de una textura.
Defines: constantes en tiempo de compilación
ShaderMaterial.defines es un objeto plano que se convierte en directivas #define, una por clave, inyectadas en las dos etapas.
const material = new THREE.ShaderMaterial( {
defines: {
OCTAVAS: 5,
MAX_PASOS: 64,
USAR_NIEBLA: true,
MODO_BARATO: false, // false se omite: no genera ninguna linea
},
uniforms: { uTiempo: { value: 0 } },
vertexShader,
fragmentShader,
} );
El valor false es especial: generateDefines lo salta por completo, así que la macro no llega a existir y #ifdef da negativo. Cualquier otro valor se emite tal cual, incluido true, que produce #define USAR_NIEBLA true y sirve tanto para #ifdef como para #if.
En el shader:
uniform float uTiempo;
#ifdef USAR_NIEBLA
uniform vec3 uColorNiebla;
uniform float uDensidadNiebla;
#endif
float fbm( vec2 p ) {
float valor = 0.0;
float amplitud = 0.5;
for ( int i = 0; i < OCTAVAS; i ++ ) {
valor += amplitud * ruido( p );
p *= 2.0;
amplitud *= 0.5;
}
return valor;
}
Ése es el caso de uso principal: un límite de bucle que el compilador puede usar para desenrollar, y un tamaño de array, que en GLSL tiene que ser una expresión constante.
Three.js añade además un preprocesador propio para desenrollar bucles explícitamente. Las condiciones son estrictas: la variable del bucle tiene que llamarse i, los límites tienen que ser literales enteros, y UNROLLED_LOOP_INDEX se sustituye por el valor concreto de cada iteración.
#pragma unroll_loop_start
for ( int i = 0; i < 4; i ++ ) {
color += texture2D( uMapas[ i ], vUv ).rgb * uPesos[ i ];
}
#pragma unroll_loop_end
Eso permite indexar arrays de samplers con un índice constante, que es un requisito del lenguaje que un bucle normal no satisface.
Cambiar un define cuesta una recompilación
Un #define es texto: cambiarlo produce un programa distinto, y eso obliga a compilar y enlazar de nuevo.
function ajustarCalidad( nivel ) {
material.defines.OCTAVAS = nivel === 'alta' ? 6 : 2;
material.needsUpdate = true; // sin esto, no pasa nada
}
material.needsUpdate = true invalida el programa cacheado y fuerza a reconstruirlo en el siguiente dibujado. Esa reconstrucción ocurre en el hilo principal, dentro del bucle de render, y en un shader complejo puede costar decenas de milisegundos. Un deslizador que llame a ajustarCalidad en cada evento input produce una cascada de recompilaciones y un congelado total mientras se arrastra.
El criterio, entonces:
| Situación | Uniform | Define |
|---|---|---|
| Cambia cada cuadro | sí | nunca |
| Cambia con la interacción continua | sí | nunca |
| Límite de un bucle | no se puede | sí |
| Tamaño de un array | no se puede | sí |
| Ajuste discreto que se fija una vez | posible | mejor |
| Rama que quieres eliminar del binario | no sirve | sí |
La última fila es la que justifica los defines más allá de lo obligatorio. Un if sobre un uniform no diverge, pero el código de la rama no tomada sigue en el binario, sigue consumiendo registros en el análisis del compilador y sigue impidiendo optimizaciones. Un #ifdef lo borra antes de compilar.
El coste real de los defines no es la recompilación puntual: es la explosión de permutaciones. Three.js identifica cada programa por una clave de caché que incluye todos los defines, todos los mapas activos, el número de luces de cada tipo, si hay sombras, niebla, skinning, instancing y morph targets. Cada combinación distinta es un programa distinto que hay que compilar, enlazar y guardar. Con tres ajustes booleanos propios ya tienes ocho variantes por material, y multiplicadas por las configuraciones de luz de tu escena la cuenta se dispara sin que nadie lo note hasta que la carga inicial pasa de medio segundo a cuatro. Se mide con una sola línea: renderer.info.programs.length durante los primeros segundos. Si crece por encima de veinte o treinta en una escena modesta, tienes explosión. Y hay dos remedios opuestos según el caso. Si las variantes son necesarias, precompílalas todas antes de mostrar nada con await renderer.compileAsync( scene, camera ), que compila exactamente las que la escena en su estado actual necesita. Si no lo son —y suelen no serlo— colapsa las variantes booleanas en un único uniform y acepta la rama: en un shader de fragmento moderno, un if sobre un uniform cuesta menos que tener cinco programas peleándose por la caché de instrucciones de la GPU, que es un recurso real y limitado del que nadie habla.