Qué es un shader y dónde se ejecuta de verdad
Un shader no es un material ni un efecto: es un programa que el driver compila y que millares de núcleos ejecutan a la vez. Qué implica eso desde la primera línea.
La palabra shader arrastra treinta años de mala traducción. No sombrea nada, no es un material, no es un efecto y no es un plugin. Es un programa pequeño escrito en un lenguaje propio, que un compilador dentro del driver traduce al código máquina de tu tarjeta gráfica, y que se ejecuta en un modelo de cómputo tan distinto del de la CPU que casi ninguna intuición de programación normal sobrevive. Esta lección establece ese modelo antes de escribir una sola línea de GLSL.
- Describir el camino completo desde una cadena de texto GLSL hasta un programa ejecutándose en la GPU.
- Situar las dos etapas programables dentro del pipeline y nombrar lo que hay entre ellas.
- Enumerar qué cosas un shader no puede hacer, y por qué.
- Medir y evitar el bloqueo de compilación del primer cuadro.
De cadena de texto a código máquina
Un shader empieza siendo una cadena de JavaScript. Lo que ocurre después son cinco pasos, todos en CPU y todos síncronos salvo el último:
Uno, tú entregas dos cadenas —vertex y fragment— a un ShaderMaterial. Dos, Three.js las procesa: resuelve las directivas #include, sustituye los contadores de luces, desenrolla los bucles marcados y antepone un prefijo con precisión, defines y declaraciones. Tres, se llama a gl.shaderSource y gl.compileShader por cada etapa; el compilador del driver hace análisis léxico, sintáctico y semántico y produce una representación intermedia. Cuatro, gl.linkProgram une las dos etapas, comprueba que las varyings coinciden en nombre y tipo, asigna localizaciones y genera el binario final. Cinco, en muchos drivers la generación de código real se pospone hasta el primer dibujado que use el programa.
Ese quinto paso es el que produce el tirón del primer cuadro. Un programa complejo puede tardar decenas de milisegundos en compilarse, y si eso ocurre dentro del bucle de render se ve como un salto.
// Compilar por adelantado, antes de entrar en el bucle
await renderer.compileAsync( scene, camera );
requestAnimationFrame( tick );
compileAsync devuelve una promesa y, cuando el navegador expone la extensión KHR_parallel_shader_compile, la compilación ocurre en un hilo del driver sin bloquear el principal. Sin la extensión, compileAsync sigue funcionando pero la espera es real.
Un detalle del prefijo que Three.js antepone y que sirve para depurar: si le pones nombre al material, ese nombre viaja al shader como una macro.
material.name = 'agua-procedural';
// dentro del shader compilado aparece: #define SHADER_NAME agua-procedural
Cuando el driver reporta un error de compilación, WebGLRenderer imprime el registro del compilador más doce líneas del código fuente alrededor de la línea del error, con la línea culpable marcada. Ese comportamiento está activo por defecto y se controla con renderer.debug.checkShaderErrors. En producción conviene desactivarlo, porque la comprobación fuerza una sincronización con el driver.
Dónde se ejecuta: núcleos, no hilos
Una GPU moderna tiene entre cientos y decenas de miles de unidades de ejecución. No son hilos independientes: están agrupadas en bloques que comparten un único contador de programa y ejecutan la misma instrucción sobre datos distintos. Ese bloque se llama warp en la terminología de NVIDIA, wavefront en la de AMD y grupo SIMD en la de Apple, y su tamaño típico es de 32 o 64 unidades.
Tu shader se ejecuta una vez por cada elemento de trabajo: una vez por vértice en la etapa de vértices, una vez por fragmento candidato en la etapa de fragmentos. Millones de veces por cuadro, todas con el mismo código y distintos datos de entrada. No hay un “hilo principal” del shader, no hay orden garantizado entre invocaciones, y no hay forma de que una invocación sepa qué está haciendo otra.
Las dos etapas programables y lo que hay entre ellas
// Vertex shader: se ejecuta una vez por vertice
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
// Fragment shader: se ejecuta una vez por fragmento candidato
varying vec2 vUv;
uniform vec3 uColorA;
uniform vec3 uColorB;
void main() {
gl_FragColor = vec4( mix( uColorA, uColorB, vUv.y ), 1.0 );
}
const material = new THREE.ShaderMaterial( {
uniforms: {
uColorA: { value: new THREE.Color( '#89b4fa' ) },
uColorB: { value: new THREE.Color( '#cba6f7' ) },
},
vertexShader,
fragmentShader,
} );
Entre las dos etapas hay una cantidad considerable de trabajo que no es programable y que conviene tener presente porque explica muchos comportamientos. El ensamblado de primitivas agrupa los vértices de tres en tres. El recorte descarta lo que cae fuera del volumen de visión y genera vértices nuevos en las aristas que lo cruzan. La división por la componente w convierte clip space en coordenadas normalizadas y es lo que produce la perspectiva. La transformación de viewport pasa a píxeles. La rasterización decide qué fragmentos cubre cada triángulo. Y la interpolación calcula el valor de cada varying en cada fragmento, con corrección de perspectiva.
Nada de eso lo escribes tú, y nada de eso se puede saltar. Cuando un efecto parece imposible en WebGL, casi siempre es porque exigiría intervenir en una de esas etapas fijas.
Lo que un shader no puede hacer
No puede reservar memoria. No hay new, no hay arrays de tamaño dinámico, no hay recursión —está prohibida por la especificación, no es una limitación del compilador.
No puede escribir en ningún sitio salvo en sus salidas declaradas. Un fragment shader escribe su color y, opcionalmente, su profundidad. Nada más. No hay variables globales persistentes, no hay ficheros, no hay red.
No puede comunicarse con otra invocación. No hay memoria compartida entre fragmentos, no hay barreras de sincronización, no hay atómicos. La única excepción son las derivadas, que leen valores de los fragmentos vecinos dentro de un bloque de dos por dos, y funcionan porque el hardware los procesa juntos por construcción.
No puede depurarse con console.log. La única salida es el color, así que depurar consiste en pintar el valor que sospechas. Es menos incómodo de lo que suena y es la técnica que todo el mundo usa:
// Depuracion por color: normalizar el valor sospechoso al rango visible
float sospecha = algunCalculo( vUv );
gl_FragColor = vec4( vec3( sospecha * 0.5 + 0.5 ), 1.0 );
// negro = -1, gris medio = 0, blanco = 1. Y si sale negro plano, es NaN.
Cuando una escena bien optimizada se queda clavada cuatro décimas al aparecer, el reflejo es culpar a la descarga del modelo. Casi nunca es eso. Es el driver compilando y enlazando cada permutación de programa la primera vez que se dibuja el material que la usa, y ocurre dentro del requestAnimationFrame, así que se ve como un salto y no como una carga. Se distingue en un segundo: en el panel de rendimiento, la tarea larga aparece con el nombre de una función de WebGL, no de red. Y se cuenta sin herramientas: renderer.info.programs.length te dice cuántos programas hay compilados. Si ese número crece durante los primeros segundos de la experiencia, tienes compilación diferida. La causa habitual es la explosión de permutaciones: cada combinación distinta de defines —mapas activos, número de luces, sombras, niebla, skinning, instancing— produce un programa distinto. Una escena con diez materiales y tres configuraciones de luz puede acabar con cuarenta programas. El remedio es await renderer.compileAsync( scene, camera ) antes del primer render, con la escena ya montada y las luces ya añadidas, porque compileAsync compila exactamente las permutaciones que la escena en ese estado necesita. Si añades una luz después, generas permutaciones nuevas y vuelves a tener el problema. Por eso el orden correcto en producción es: cargar, montar la escena completa, compilar, y solo entonces quitar la pantalla de carga.