Saltar al contenido

Uniforms: el canal de la CPU al shader

Cómo se declaran, cómo se actualizan, por qué un uniform mal escrito no da error, y el detalle de gestión de color que descoloca a todo el mundo la primera vez.

La forma del objeto uniforms

Three.js espera un objeto donde cada clave es el nombre exacto del uniform en GLSL y cada valor es un objeto con una propiedad value. Esa indirección es deliberada: el renderer lee .value justo antes de cada dibujado, así que mutar esa propiedad basta y no hace falta avisar de nada.

JS
const material = new THREE.ShaderMaterial( {  uniforms: {    uTiempo:     { value: 0 },    uIntensidad: { value: 1.5 },    uResolucion: { value: new THREE.Vector2() },    uColorA:     { value: new THREE.Color( '#89b4fa' ) },    uMatriz:     { value: new THREE.Matrix3() },    uMapa:       { value: textura },    uPuntos:     { value: [ new THREE.Vector3(), new THREE.Vector3() ] },  },  vertexShader,  fragmentShader,} );

Y en el shader, con los tipos que corresponden:

GLSL
uniform float uTiempo;uniform float uIntensidad;uniform vec2 uResolucion;uniform vec3 uColorA;uniform mat3 uMatriz;uniform sampler2D uMapa;uniform vec3 uPuntos[ 2 ];

La correspondencia entre el tipo de JavaScript y el de GLSL no la declaras: WebGLUniforms la deduce del tipo que el programa enlazado reporta para cada localización, y llama a la función gl.uniform* adecuada. Un number va a float, un Vector2 a vec2, un Color a vec3, un Matrix4 a mat4, un Texture a sampler2D, y un array de Vector3 a vec3[].

La actualización es una asignación:

JS
const reloj = new THREE.Timer();reloj.connect( document );function tick( tiempo ) {  reloj.update( tiempo );  material.uniforms.uTiempo.value = reloj.getElapsed();  material.uniforms.uResolucion.value.set(    renderer.domElement.width,    renderer.domElement.height  );  renderer.render( scene, camera );  requestAnimationFrame( tick );}

Dos matices sobre esa asignación. Para los tipos primitivos se reemplaza value; para los objetos conviene mutarlos con set o copy en vez de crear uno nuevo cada cuadro, porque cada objeto nuevo es basura que el recolector tendrá que limpiar sesenta veces por segundo. Y si cambias un uniform desde onBeforeRender, el renderer ya ha subido los valores, así que hay que marcarlo:

JS
mesh.onBeforeRender = () => {  material.uniforms.uIntensidad.value = calcularAlgo();  material.uniformsNeedUpdate = true;};

El silencio ante un nombre equivocado

Éste es el fallo que más tiempo consume y el que peor se diagnostica. Si escribes uTimepo en el JavaScript y uTiempo en el GLSL, no ocurre nada. No hay error, no hay advertencia, el shader compila, el material funciona, y el valor simplemente nunca llega.

La causa es doble. Por un lado, getUniformLocation devuelve null para un nombre inexistente y Three.js se limita a no subir ese uniform. Por otro —y esto es lo cruel— el compilador del driver elimina cualquier uniform que el shader no use. Un uniform declarado pero no leído desaparece del programa enlazado, con lo cual también existe el caso inverso: el nombre está bien escrito en los dos sitios, pero como una refactorización dejó de usarlo en el GLSL, deja de existir. Y si más tarde vuelves a usarlo, reaparece.

La comprobación directa es leer las localizaciones que el programa enlazado expone:

JS
// Despues de haber renderizado al menos un cuadro con este materialconst programa = renderer.properties.get( material ).currentProgram;if ( programa ) {  console.log( Object.keys( programa.getUniforms().map ) );}

Ahí aparecen exactamente los uniforms que sobrevivieron a la compilación. Si el tuyo no está, o el nombre no coincide o el shader no lo usa.

Un truco más rápido para el desarrollo diario: pinta el uniform. Si sospechas de uIntensidad, escribe temporalmente gl_FragColor = vec4( vec3( uIntensidad ), 1.0 ); y mira el color. Negro plano significa cero, y cero significa que no llegó.

El detalle del color

THREE.Color no guarda el valor que le diste. Desde que la gestión de color está activa por defecto, el constructor y setHex y setStyle interpretan la entrada como sRGB y la convierten al espacio de trabajo, que es sRGB lineal.

JS
const c = new THREE.Color( '#89b4fa' );console.log( c.r, c.g, c.b );// 0.2502..., 0.4564..., 0.9560...   NO 0.537, 0.706, 0.980

Eso es correcto y es lo que quieres: toda la matemática de iluminación tiene que ocurrir en espacio lineal, y el renderer convierte a sRGB al escribir la salida. El problema aparece cuando mezclas fuentes. Si en tu shader escribes un color a mano en GLSL:

GLSL
vec3 mismoAzul = vec3( 0.537, 0.706, 0.980 );   // esto NO coincide con uColorA

Ese literal es el valor codificado, no el lineal, y al salir por el pipeline de color se le aplicará la codificación otra vez. El resultado es visiblemente más claro y más lavado. Hay dos formas correctas de escribir colores literales en GLSL: convertirlos tú a lineal, o dejar que el propio Three.js lo haga y pasarlos como uniforms.

JS
// Lo mismo, pero calculado por Three.jsuColorA: { value: new THREE.Color().setStyle( '#89b4fa', THREE.SRGBColorSpace ) }

La misma lógica se aplica a las texturas: una textura marcada como SRGBColorSpace la decodifica el hardware al muestrear, así que lo que llega al shader ya es lineal. Una textura de datos —altura, rugosidad, máscara— tiene que quedarse en NoColorSpace para que no se toque.

El coste

Cada uniform es una llamada a gl.uniform* antes de cada dibujado del material. Un material con veinte uniforms dibujado cien veces son dos mil llamadas por cuadro. No es catastrófico, pero suma, y suma en el hilo principal.

Tres formas de reducirlo. La primera es compartir el material entre objetos que necesiten los mismos valores, que es lo que hace que Three.js pueda saltarse la subida cuando el programa no ha cambiado. La segunda es mover a defines lo que sea discreto y no cambie: un #define no cuesta nada en tiempo de ejecución. Y la tercera, para casos con muchos valores compartidos, son los bloques de uniforms.

JS
const grupo = new THREE.UniformsGroup();grupo.setName( 'DatosEscena' );grupo.add( new THREE.Uniform( new THREE.Vector3() ) );grupo.add( new THREE.Uniform( 0 ) );material.uniformsGroups = [ grupo ];

Un UniformsGroup se convierte en un uniform buffer object: un único búfer que la GPU lee directamente, subido una vez y compartido por todos los materiales que lo referencien. A cambio hay que declararlo en GLSL con la sintaxis de bloque y respetar las reglas de alineación de std140, en las que un vec3 ocupa dieciséis bytes y no doce.