wandres.dev
SHADERS III · El lenguaje GLSL

Tipos y precisión en GLSL

El catálogo de tipos, la verdad sobre la conversión implícita entre enteros y flotantes, y qué significan de verdad highp, mediump y lowp en el hardware de hoy.

⏱ 18 min

GLSL es un lenguaje pequeño y estricto donde el sistema de tipos hace más trabajo del que parece: los tipos vectoriales no son azúcar sintáctico sino la unidad real de cómputo del hardware, y los cualificadores de precisión son un contrato con el silicio, no una anotación. Esta lección establece el catálogo completo y desmonta dos creencias muy extendidas sobre él.

🎯 Al terminar esta lección sabrás
  • Enumerar los tipos escalares, vectoriales y matriciales y su forma de construcción.
  • Saber exactamente qué conversiones implícitas existen y cuáles no.
  • Explicar qué garantiza cada cualificador de precisión y qué inyecta Three.js.
  • Reconocer las tres construcciones que GLSL prohíbe y por qué.

Escalares: float, int, uint, bool. Los tres primeros tienen sus versiones vectoriales de dos, tres y cuatro componentes: vec2 a vec4 para flotantes, ivec2 a ivec4 para enteros con signo, uvec2 a uvec4 para sin signo, y bvec2 a bvec4 para booleanos.

Matrices: mat2, mat3 y mat4 para las cuadradas, y las rectangulares mat2x3, mat3x4 y el resto de combinaciones. La convención de GLSL es por columnas: m[ 0 ] es la primera columna, un vector, y m[ 2 ].xyz de una mat4 de transformación es el eje Z del sistema.

Samplers: sampler2D, samplerCube, sampler2DArray, sampler3D, más las variantes de sombra y las de enteros. Un sampler es opaco: no se puede operar con él, solo pasarlo a las funciones de muestreo, y solo puede venir de un uniform.

Estructuras y arrays existen y funcionan, pero un array tiene tamaño fijo conocido en compilación y no hay punteros a nada.

Los constructores son flexibles y componen:

vec3 a = vec3( 1.0 );                    // 1.0 en las tres componentes
vec3 b = vec3( 1.0, 0.5, 0.0 );
vec4 c = vec4( b, 1.0 );                 // un vec3 mas un escalar
vec2 d = vec2( c );                      // trunca a las dos primeras
mat3 m = mat3( 1.0 );                    // identidad, no todo unos
mat3 n = mat3( modelViewMatrix );        // esquina superior izquierda de una mat4
ivec2 p = ivec2( gl_FragCoord.xy );      // conversion explicita, trunca hacia cero

mat3( 1.0 ) construyendo la identidad es una convención específica de las matrices que sorprende la primera vez: un escalar en el constructor de una matriz llena la diagonal.

La verdad sobre las conversiones implícitas

Aquí conviene ser exacto, porque el consejo de siempre —“escribe 1.0, nunca 1”— es correcto pero la razón que se suele dar no lo es.

En GLSL ES 1.00, que es el dialecto por defecto de RawShaderMaterial, no hay ninguna conversión implícita. float x = 1; es un error de compilación y vec3 v = position * 2; también.

En GLSL ES 3.00, que es lo que compila un ShaderMaterial de Three.js r184, la especificación sí define conversiones implícitas de int a float, de uint a float y de los vectores enteros a los flotantes del mismo tamaño. Así que float x = 1; compila sin quejarse.

Lo que sigue siendo un desastre en las dos versiones es la división entera:

float mitad = 1 / 2;          // vale 0.0. Los dos operandos son int, la division
                              // es entera, y la conversion a float ocurre despues.
float bien = 1.0 / 2.0;       // 0.5
float tambien = 1.0 / 2;      // 0.5, el 2 se promociona a float antes de dividir

Y hay una segunda trampa que la promoción no arregla: no existe conversión implícita de float a int, ni en 3.00. Así que int n = 2.0; sigue fallando y hay que escribir int n = int( 2.0 );.

La conclusión práctica no cambia: escribe siempre el punto decimal en cualquier literal que quieras tratar como flotante. No porque el compilador vaya a rechazarlo —a veces no lo hará— sino porque el momento en que la promoción ocurre respecto de la operación decide el resultado, y razonar sobre eso cada vez es una fuente de errores que no compensa.

Precisión: qué garantiza cada cualificador

highp, mediump y lowp no dicen cuántos bits tiene un valor: dicen cuántos bits garantiza como mínimo. El hardware puede dar más.

highp en flotante corresponde a IEEE de treinta y dos bits en la práctica: veinticuatro bits de mantisa y exponente amplio. mediump garantiza un rango de al menos más menos dos elevado a catorce y una precisión relativa de dos elevado a menos diez, que es exactamente lo que ofrece el flotante de dieciséis bits. lowp garantiza el rango de menos dos a dos con ocho bits de precisión.

Three.js inyecta un bloque de precisión al principio de cada shader, y por defecto usa highp para todo:

precision highp float;
precision highp int;
precision highp sampler2D;
precision highp samplerCube;
precision highp sampler2DArray;

El valor sale de renderer.capabilities.precision, configurable al crear el renderer con new THREE.WebGLRenderer( { precision: 'mediump' } ).

Y aquí hay un hecho que invalida mucho consejo antiguo: en WebGL 2, highp en el fragment shader está garantizado. La especificación de GLSL ES 3.00 lo exige. Toda la literatura sobre comprobar getShaderPrecisionFormat antes de usar highp en fragmentos, y sobre los caminos de código alternativos para móviles que no lo soportaban, pertenece a WebGL 1.

Lo que sí sigue vigente es el efecto en rendimiento. En muchas GPU móviles, mediump se ejecuta a doble tasa: dos operaciones de dieciséis bits por ciclo donde cabría una de treinta y dos. En una GPU de escritorio, los cualificadores se ignoran y todo es de treinta y dos bits. Así que bajar a mediump es una optimización móvil que en el escritorio no hace nada, y que puede introducir artefactos difíciles de reproducir precisamente porque en la máquina del desarrollador no aparecen.

Los sitios donde mediump rompe son predecibles: coordenadas de textura sobre superficies grandes, cualquier acumulación, y las funciones trigonométricas con argumentos grandes.

// Una varying de UV en mediump sobre un plano de 1000 unidades:
// el paso minimo representable cerca de 1.0 es de una milesima, y con
// repeat alto eso produce un temblor visible de la textura.
varying highp vec2 vUv;   // se puede cualificar varying a varying

Lo que GLSL no tiene

No hay recursión. Está prohibida por la especificación, no es una limitación del compilador. La razón es que no hay pila: la profundidad de llamada tiene que ser conocida en compilación para reservar registros. Cualquier algoritmo recursivo hay que convertirlo a iterativo con una pila explícita en un array de tamaño fijo.

No hay memoria dinámica. Ni new, ni arrays de tamaño variable, ni estructuras enlazadas. Todo el almacenamiento se decide al compilar.

No hay excepciones ni forma de señalar un error. Una división por cero no lanza nada: produce infinito o NaN según el caso, y ese valor se propaga silenciosamente hasta el color final. Depurar consiste en encontrar dónde nació.

Y una diferencia con C que muerde: % no está definido para flotantes. Para enteros funciona desde GLSL ES 3.00; para flotantes hay que usar mod, que además tiene un comportamiento distinto con negativos.

Un NaN atraviesa clamp, min y max sin detenerse

La creencia más peligrosa sobre precisión no tiene que ver con bits sino con NaN. La intuición dice que clamp( x, 0.0, 1.0 ) deja el valor dentro del rango pase lo que pase, y por tanto que un NaN quedaría convertido en 0 o en 1. Es falso. clamp está especificado como min( max( x, minVal ), maxVal ), y la especificación de GLSL ES no define el resultado de min y max cuando uno de los operandos es NaN: el hardware puede devolver cualquiera de los dos. En la práctica, la mayoría de GPU implementan max( NaN, 0.0 ) devolviendo el segundo operando en unos casos y el primero en otros según la instrucción concreta que emita el compilador, así que el mismo shader puede limpiar el NaN en una tarjeta y propagarlo en otra. Y cuando se propaga hasta gl_FragColor, el resultado depende del modo de mezcla: sin blending suele pintarse negro, con blending aditivo puede pintarse blanco, y con transparencia puede desaparecer el objeto entero. El síntoma característico son triángulos negros o blancos que aparecen en posiciones concretas de la malla y desaparecen al mover la cámara. La única defensa fiable es no generar el NaN: sqrt( max( x, 0.0 ) ), pow( max( base, 0.0 ), e ), normalize solo sobre vectores que sepas no nulos, y dot( v, v ) comparado con un épsilon antes de dividir. Y para localizar uno que ya tienes, el truco de un fragmento es que NaN falla toda comparación consigo mismo: if ( x != x ) gl_FragColor = vec4( 1.0, 0.0, 1.0, 1.0 ); pinta de magenta exactamente los píxeles infectados.