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.
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 usasconst PERIODO = 1000; // segundosmaterial.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;#endiffloat 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_startfor ( int i = 0; i < 4; i ++ ) { color += texture2D( uMapas[ i ], vUv ).rgb * uPesos[ i ];}#pragma unroll_loop_endEso 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.