wandres.dev
SHADERS II · ShaderMaterial, uniforms y varyings

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.

⏱ 18 min

Un uniform es un valor constante durante todo un dibujado y visible desde las dos etapas. Es el único canal por el que la lógica de tu aplicación llega al shader, y su manejo en Three.js tiene tres particularidades que no aparecen en ninguna documentación de WebGL: la indirección por el objeto value, el silencio absoluto ante un nombre equivocado, y la conversión de espacio de color que ocurre antes de que el valor salga de JavaScript.

🎯 Al terminar esta lección sabrás
  • Declarar uniforms de todos los tipos habituales y actualizarlos correctamente cada cuadro.
  • Explicar por qué un uniform que no se usa en el shader desaparece sin aviso.
  • Anticipar el valor exacto que llega al shader al pasar un THREE.Color.
  • Estimar el coste de los uniforms en una escena con muchos materiales.

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.

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:

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:

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:

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:

// Despues de haber renderizado al menos un cuadro con este material
const 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.

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:

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.

// Lo mismo, pero calculado por Three.js
uColorA: { 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.

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.

El uniform que se cuela sin que lo declares y arruina la comparacion

Hay un uniform que casi nadie sabe que existe y que explica un comportamiento desconcertante: isOrthographic. Three.js lo declara en las dos etapas de todo ShaderMaterial y lo pone a true cuando la cámara es ortográfica. Su razón de ser es que la reconstrucción de la dirección de vista cambia por completo entre proyecciones: con perspectiva, la dirección hacia la cámara es normalize( cameraPosition - posicionMundo ), mientras que con ortográfica todos los rayos son paralelos y la dirección correcta es la tercera columna de la matriz de vista. Si escribes un shader con fresnel, con reflexión de entorno o con cualquier cosa que dependa del vector de vista y solo lo pruebas con PerspectiveCamera, funcionará; el día que alguien lo use en una vista ortográfica —una vista técnica, un plano de planta, un editor— el efecto se romperá de una forma que parece un problema de normales. La versión correcta cabe en una línea si trabajas en espacio de vista, que es donde Three.js resuelve el mismo problema en sus propios chunks: con la posición de vista guardada en una varying, vec3 V = isOrthographic ? vec3( 0.0, 0.0, 1.0 ) : normalize( -vPosicionVista );, porque en espacio de vista la cámara está en el origen mirando hacia el eje Z negativo y con proyección ortográfica todos los rayos son paralelos a ese eje. Y sí, ese ? es una condicional sobre un uniform, así que no diverge y es esencialmente gratis.