Saltar al contenido

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.

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

JS
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:

JS
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:

JS
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.

Insertar, quitar y reordenar

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

JS
composer.addPass( pass );              // al finalcomposer.insertPass( pass, 2 );        // en una posicion concretacomposer.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.

JS
gui.add( bloomPass, 'enabled' ).name( 'Bloom' );gui.add( ssaoPass, 'enabled' ).name( 'Oclusion ambiental' );