wandres.dev
POST-PROCESADO · EffectComposer y los passes

EffectComposer y el modelo de passes

Qué problema resuelve el post-procesado, cómo está diseñado el composer de Three.js, y qué contrato cumple exactamente un pass.

⏱ 16 min

Hay efectos que no se pueden calcular mientras dibujas un objeto, porque necesitan saber cosas de sus vecinos. Un resplandor necesita el brillo de los píxeles de alrededor; un desenfoque de profundidad necesita el color de una región entera; un antialiasing analítico necesita ver el borde. La solución de todo el sector es la misma desde hace veinte años: dibujar la escena a una textura y trabajar sobre la imagen.

🎯 Al terminar esta lección sabrás
  • Explicar por qué ciertos efectos solo son posibles después del render.
  • Montar un EffectComposer mínimo y sustituir la llamada a renderer.render.
  • Enumerar el contrato completo que cumple un Pass.
  • Distinguir qué passes escriben a pantalla y cuál lo decide.

Por qué existe una segunda pasada

El fragment shader de un objeto conoce un solo fragmento. Sabe su posición, su normal, sus UV, sus uniforms. Lo que no sabe, y no puede saber, es qué hay en el píxel de al lado: los fragmentos se procesan en paralelo, sin orden garantizado, y muchos ni siquiera coexisten en el tiempo.

Eso descarta de raíz toda una familia de efectos. Un blur gaussiano es una media ponderada de un vecindario. La oclusión ambiental en espacio de pantalla compara la profundidad de un punto con la de sus vecinos. El bloom aísla los píxeles brillantes y los difunde. Un antialiasing morfológico busca patrones de borde en la imagen. Ninguno de ellos cabe dentro del shader del objeto.

La salida es dibujar a una textura en lugar de a la pantalla, y después ejecutar shaders que leen esa textura completa y escriben otra. Cada uno de esos shaders ve la imagen entera y puede muestrear donde quiera. Ese es todo el concepto, y EffectComposer es la maquinaria que lo organiza.

El montaje mínimo

import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass }     from 'three/addons/postprocessing/RenderPass.js';
import { OutputPass }     from 'three/addons/postprocessing/OutputPass.js';

const renderer = new THREE.WebGLRenderer( { antialias: false } );
renderer.setPixelRatio( Math.min( devicePixelRatio, 2 ) );
renderer.setSize( innerWidth, innerHeight );
renderer.toneMapping = THREE.ACESFilmicToneMapping;

const composer = new EffectComposer( renderer );
composer.setPixelRatio( renderer.getPixelRatio() );
composer.setSize( innerWidth, innerHeight );

composer.addPass( new RenderPass( escena, camara ) );
composer.addPass( new OutputPass() );

renderer.setAnimationLoop( () => {
  composer.render();          // en lugar de renderer.render( escena, camara )
} );

addEventListener( 'resize', () => {
  renderer.setSize( innerWidth, innerHeight );
  composer.setSize( innerWidth, innerHeight );
  camara.aspect = innerWidth / innerHeight;
  camara.updateProjectionMatrix();
} );

Tres cambios respecto a una escena normal. renderer.render desaparece del bucle y lo sustituye composer.render(). Hay que redimensionar el composer además del renderer, porque mantiene sus propios buffers. Y antialias: false en el renderer, porque el antialiasing por hardware del contexto no funciona cuando dibujas a un render target: el multisampling del framebuffer por defecto no se aplica a buffers intermedios, y solo estarías pagando memoria.

Ese OutputPass al final no es decorativo. Sin él, la imagen sale oscura y desaturada, y la razón es el tema de la quinta lección de este nivel.

El contrato de un pass

Todo pass hereda de la clase Pass y tiene cuatro propiedades públicas que gobiernan cómo lo trata el composer.

Propiedad Por defecto Qué significa
enabled true si es false, el composer lo salta entero
needsSwap true si el pass escribe en el buffer de escritura y hay que intercambiar
clear false si limpia su destino antes de dibujar
renderToScreen false si escribe al framebuffer por defecto en vez de a un buffer

Y un único método obligatorio, con esta firma exacta:

render( renderer, writeBuffer, readBuffer, deltaTime, maskActive ) { }

readBuffer es la imagen que entra: el resultado de todo lo anterior. writeBuffer es el destino. El pass lee de uno, escribe en el otro, y el composer se encarga de rotar los papeles.

Sobre renderToScreen hay un malentendido muy extendido. Puedes ponerlo a true en un pass, pero no sirve de nada: el composer lo sobrescribe en cada frame, para cada pass, con este cálculo:

pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) );
pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive );

Es decir, solo el último pass habilitado escribe a pantalla, y lo decide el composer, no tú. isLastEnabledPass mira hacia adelante buscando cualquier pass con enabled === true. La consecuencia práctica es útil: desactivar el último pass con enabled = false no rompe nada, el anterior toma automáticamente el relevo y pinta a pantalla.

ℹ️
El composer no gestiona el ciclo de vida de tus passes

composer.dispose() libera sus dos render targets y su pass de copia interno, pero no llama a dispose() en los passes que le has añadido. Un UnrealBloomPass tiene doce render targets propios y varios materiales; si destruyes la escena sin recorrer composer.passes llamando a dispose() en cada uno, has filtrado bastantes megabytes de VRAM. En una aplicación de una sola escena da igual; en una que monta y desmonta vistas, no.

Insertar, quitar y reordenar

La API de gestión de la cadena es pequeña y completa:

composer.addPass( pass );              // al final
composer.insertPass( pass, 2 );        // en una posicion concreta
composer.removePass( pass );
composer.passes;                       // el array, accesible directamente

Tanto addPass como insertPass llaman inmediatamente a pass.setSize() con el tamaño actual del composer multiplicado por su pixel ratio, así que un pass añadido en caliente ya nace con las dimensiones correctas.

Para activar y desactivar efectos en tiempo real, enabled es la vía correcta y es barata: no reconstruye nada, solo hace que el bucle salte ese pass.

gui.add( bloomPass, 'enabled' ).name( 'Bloom' );
gui.add( ssaoPass, 'enabled' ).name( 'Oclusion ambiental' );
Un pass no es un efecto, es un contrato de propiedad sobre dos buffers

El nombre “pass” invita a pensar en una lista de efectos que se aplican en orden, como filtros de una foto. Ese modelo mental funciona mientras todo vaya bien y falla en cuanto algo se rompe, porque oculta lo único que de verdad hay que entender: un pass es una función que consume una textura y produce otra, y el composer solo administra de quién es cada una en cada momento. En cuanto lo ves así, tres comportamientos que parecen caprichosos se vuelven inevitables. Primero: needsSwap no es una opción de configuración sino una declaración de dónde has escrito. RenderPass lo tiene a false porque escribe en readBuffer, no en writeBuffer, y por tanto el siguiente pass ya encuentra su entrada donde la espera sin necesidad de rotar nada. Si escribes un pass propio y te equivocas en esta declaración, no obtienes un error: obtienes que el siguiente pass lee la imagen del frame anterior, con un retardo de un fotograma que se manifiesta como un fantasma sutil imposible de diagnosticar mirando shaders. Segundo: por eso los efectos que necesitan acumular entre frames —motion blur, trails, reproyección temporal— no pueden vivir solo con los dos buffers del composer y traen los suyos propios; el ping-pong del composer se resetea conceptualmente en cada frame, y la memoria entre frames hay que gestionarla aparte. Y tercero, lo más importante en la práctica: el composer no sabe nada de la semántica de la imagen que circula. No sabe si está en lineal o en sRGB, si es HDR o está recortada, si contiene profundidad o normales. Todo eso es un contrato implícito entre passes vecinos, sin ninguna comprobación que lo verifique, y es exactamente la razón por la que el orden de la cadena —la quinta lección de este nivel— importa tanto y se equivoca tan a menudo.