Render targets y el ping-pong
Los dos buffers que alterna el composer, por qué son de coma flotante de media precisión, y qué significa exactamente needsSwap.
Una GPU no puede leer y escribir la misma textura en la misma operación. Esa restricción del hardware, que parece un detalle técnico menor, es la que dicta toda la arquitectura del post-procesado: hacen falta dos buffers que se turnan los papeles de origen y destino, y casi todos los bugs raros de una cadena de passes se explican por haber perdido la cuenta de cuál era cuál.
- Explicar por qué hacen falta dos buffers y no uno.
- Describir el formato exacto de los render targets del composer y por qué es ese.
- Predecir el efecto de declarar mal
needsSwapen un pass propio. - Calcular el coste en memoria y en ancho de banda de una cadena de passes.
Dos buffers que se turnan
El composer crea dos render targets del mismo tamaño y los asigna así:
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.renderTarget2.texture.name = 'EffectComposer.rt2';
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
Y el intercambio es exactamente lo que parece:
swapBuffers() {
const tmp = this.readBuffer;
this.readBuffer = this.writeBuffer;
this.writeBuffer = tmp;
}
El bucle de render() recorre los passes, llama a cada uno con ( renderer, writeBuffer, readBuffer, deltaTime, maskActive ) y, si el pass declara needsSwap, intercambia. El resultado neto es que la imagen va rebotando de un buffer al otro, con cada pass leyendo lo que dejó el anterior.
La razón de que hagan falta dos y no uno es de hardware, no de diseño. Leer un téxel de la misma textura en la que estás escribiendo es comportamiento indefinido en WebGL: el orden de escritura de los fragmentos no está determinado, así que no hay ninguna garantía sobre qué valor leerías. La única forma segura es tener origen y destino separados, y como una cadena de passes tiene longitud arbitraria, la alternativa a alternar dos sería asignar uno nuevo por pass, que es peor en memoria y en tiempo de asignación.
El formato de los buffers
El render target por defecto se crea así:
renderTarget = new WebGLRenderTarget(
this._width * this._pixelRatio,
this._height * this._pixelRatio,
{ type: HalfFloatType }
);
Solo se especifica el tipo. Todo lo demás son los valores por defecto de RenderTarget: formato RGBAFormat y espacio de color NoColorSpace. Esos tres hechos importan mucho más de lo que parece.
HalfFloatType significa 16 bits de coma flotante por canal en lugar de 8 bits enteros. Es la decisión clave de todo el diseño. Un buffer de 8 bits solo puede representar valores entre 0 y 1, así que un píxel que en la escena tiene una luminancia de 12 —el reflejo especular de una fuente intensa— se recorta a 1 antes de que ningún efecto lo vea. Con eso, un bloom no puede distinguir un blanco de papel de un sol, y el resultado es plano y sucio. Con media precisión el rango llega hasta 65504 y los valores altos sobreviven intactos hasta el final de la cadena.
NoColorSpace significa que los valores que circulan son lineales y sin codificar. Nadie ha aplicado tone mapping y nadie ha aplicado la curva sRGB. Eso es lo correcto: los efectos de imagen son operaciones físicas sobre radiancia —sumas, medias ponderadas, convoluciones— y solo tienen sentido en un espacio lineal. Hacer una media entre dos colores ya codificados en sRGB da un resultado distinto y equivocado.
El pixel ratio se aplica al tamaño. Los buffers se crean con las dimensiones del composer multiplicadas por su pixel ratio, no con las dimensiones CSS. Si el composer no recibe setPixelRatio, usará 1 y la imagen saldrá borrosa en pantallas de alta densidad; si recibe el del renderer sin limitarlo, en un móvil con ratio 3 estarás asignando nueve veces más memoria de la que crees.
Los dos buffers del composer a 1920 por 1080 con ratio 2 y media precisión ocupan unos 66 MB entre los dos. Pero muchos passes traen los suyos: UnrealBloomPass crea cinco niveles de mipmap con dos render targets cada uno más uno de brillo, SSAOPass mantiene un buffer de normales, uno de profundidad y uno de desenfoque, OutlinePass tiene cuatro. Una cadena con bloom, SSAO y outline puede rondar los 200 MB de VRAM solo en buffers intermedios. En un móvil de gama media eso es el presupuesto entero.
needsSwap, que es donde se rompe
Esta es la propiedad que hay que entender de verdad para escribir un pass propio, y la que produce los bugs más desconcertantes cuando se declara mal.
needsSwap responde a una pregunta: ¿dónde has dejado el resultado?
Si el pass escribe en writeBuffer, el resultado está en el buffer que el siguiente pass va a recibir como writeBuffer, así que hay que intercambiar para que le llegue como readBuffer. Eso es needsSwap = true, el valor por defecto, y es lo que hace ShaderPass.
Si el pass escribe en readBuffer —normalmente porque dibuja encima de lo que ya había, o porque genera la imagen desde cero— el siguiente pass ya la encuentra donde la espera y no hay que intercambiar. Eso es needsSwap = false. Es lo que hacen RenderPass, UnrealBloomPass, SSAOPass y OutlinePass.
RenderPass lo hace explícito en su código:
renderer.setRenderTarget( this.renderToScreen ? null : readBuffer );
Dibuja la escena directamente en el buffer de lectura, porque es el primero de la cadena y no tiene nada que leer.
Ahora el modo de fallo. Si escribes un pass que dibuja en readBuffer pero dejas needsSwap = true, el composer intercambia igualmente. El siguiente pass recibirá como readBuffer el buffer antiguo, que contiene la imagen del frame anterior. No hay error, no hay pantalla negra: hay un retardo de un fotograma. En una escena estática es invisible; en una con movimiento se manifiesta como un fantasma sutil, una especie de eco. Es probablemente el bug más difícil de diagnosticar de toda esta área, porque lleva a revisar shaders que están perfectos.
La comprobación es mecánica: mira a qué buffer llama setRenderTarget tu pass y declara needsSwap en consecuencia. Escribes en writeBuffer, entonces true. Escribes en readBuffer, entonces false.
El coste real de un eslabón
Cada pass de pantalla completa lee al menos una textura del tamaño del viewport y escribe otra. A 1920 por 1080 con ratio 2 y media precisión, cada una de esas texturas son 33 MB. Un pass simple mueve por tanto unos 66 MB de tráfico de memoria por frame, y a 60 frames por segundo eso son casi 4 GB por segundo de ancho de banda por pass.
Ese número explica dos cosas que desconciertan a quien viene de optimizar geometría.
La primera es que un pass “trivial” no es gratis. Un ShaderPass que solo multiplica el color por una constante tiene un shader de dos instrucciones y aun así cuesta un milisegundo largo en una GPU integrada, porque el coste no está en el cálculo sino en mover los píxeles. Encadenar cinco de esos passes triviales cuesta cinco veces eso, mientras que fusionarlos en un único shader costaría lo mismo que uno solo.
La segunda es que las GPU integradas y los móviles sufren aquí de forma desproporcionada. No es que tengan menos capacidad de cálculo, es que comparten el bus de memoria con la CPU. En arquitecturas de renderizado por tiles, además, cada pass de pantalla completa obliga a volcar y recargar la memoria del tile, un coste que en una GPU discreta simplemente no existe.
Casi todo el material que encontrarás sobre post-procesado habla de shaders: cómo se calcula un blur gaussiano, cuántas muestras necesita un SSAO, qué kernel usa el bloom. Es la parte interesante y es la parte que casi nunca decide tu frame time. Lo que decide tu frame time es el número de veces que la imagen completa cruza el bus de memoria. Haz la cuenta con tus propios números: multiplica ancho por alto por el pixel ratio al cuadrado, por 8 bytes de RGBA en media precisión, por dos —lectura y escritura—, por el número de passes, por 60. Compara ese resultado con el ancho de banda de la GPU objetivo, que en una integrada de portátil ronda los 50 GB por segundo y en un móvil bastante menos. Verás que con cuatro o cinco passes a resolución completa ya te has comido una fracción incómoda del presupuesto antes de contar un solo ciclo de cálculo. De esa observación salen las tres optimizaciones que de verdad mueven la aguja en post-procesado, y las tres son sobre tráfico, no sobre matemáticas. Fusionar passes en un único shader, que es literalmente la propuesta de valor de la librería de pmndrs. Bajar la resolución de los passes que no necesitan nitidez: el bloom es un desenfoque, así que hacerlo a un cuarto de resolución no se distingue del original y cuesta la decimosexta parte; el SSAO igual. Y limitar el pixel ratio, que es la palanca más brutal de todas porque el coste crece con su cuadrado. Cuando alguien te diga que su escena va lenta con post-procesado, la primera pregunta útil no es qué efectos tiene, es a cuántos píxeles los está corriendo.