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.
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 bucleawait 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-proceduralCuando 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 verticevarying vec2 vUv;void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );}// Fragment shader: se ejecuta una vez por fragmento candidatovarying 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 visiblefloat 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.