El orden correcto de la cadena
Dónde va exactamente el tone mapping, dónde el antialiasing y por qué, con las reglas que nadie escribe, y la alternativa de pmndrs con su fusión de efectos.
Casi toda la documentación de post-procesado te dice qué hace cada efecto y ninguna te dice en qué orden ponerlos. Y sin embargo el orden decide si el bloom se ve como luz o como manchas, si el antialiasing suaviza bordes o los emborrona, y si los colores salen como los diseñaste o lavados. No es cuestión de gusto: hay una secuencia correcta y se deduce de qué espacio de color y qué rango de valores espera cada eslabón.
- Ordenar una cadena de passes justificando cada posición.
- Explicar por qué el tone mapping y la conversión de espacio de color van al final.
- Colocar SMAA y FXAA en los lados correctos de
OutputPass. - Evaluar cuándo la fusión de efectos de pmndrs compensa el cambio de librería.
La cadena canónica
flowchart TB A[RenderPass escena a HDR lineal] --> B[SSAOPass oclusion desde profundidad] B --> C[BokehPass desenfoque por profundidad] C --> D[UnrealBloomPass resplandor sobre valores altos] D --> E[OutlinePass contornos de seleccion] E --> F[SMAAPass antialiasing aun en lineal] F --> G[OutputPass tone mapping mas espacio de color] G --> H[FXAAPass alternativa que exige sRGB] H --> I[Pantalla] style A fill:#89b4fa,color:#11111b style B fill:#f9e2af,color:#11111b style C fill:#f9e2af,color:#11111b style D fill:#f9e2af,color:#11111b style E fill:#f9e2af,color:#11111b style F fill:#cba6f7,color:#11111b style G fill:#a6e3a1,color:#11111b style H fill:#cba6f7,color:#11111b style I fill:#a6e3a1,color:#11111b
En código:
composer.addPass( new RenderPass( escena, camara ) );
composer.addPass( ssaoPass );
composer.addPass( bokehPass );
composer.addPass( bloomPass );
composer.addPass( outlinePass );
composer.addPass( smaaPass );
composer.addPass( new OutputPass() );
Ahora la justificación de cada posición, que es lo que de verdad importa.
Las cuatro reglas
Regla 1: el tone mapping va al final, siempre
Esta es la regla que no se negocia y la que más se incumple.
Todos los buffers del composer son de media precisión con espacio de color NoColorSpace. Es decir: valores lineales sin recortar, que pueden ser mayores que 1. El píxel del reflejo especular de una lámpara puede valer 40. Eso es correcto y es lo que hace posible que los efectos funcionen.
El tone mapping es la operación que comprime ese rango abierto al rango que una pantalla puede mostrar, y la conversión de espacio de color es la que aplica la curva sRGB para que los valores se perciban bien. Son las dos últimas operaciones de todo el pipeline de imagen, en Three.js y en cualquier motor.
OutputPass hace exactamente esas dos cosas, y las hace leyendo tres propiedades del renderer:
this.uniforms[ 'toneMappingExposure' ].value = renderer.toneMappingExposure;
if ( this._outputColorSpace !== renderer.outputColorSpace || this._toneMapping !== renderer.toneMapping ) {
this._outputColorSpace = renderer.outputColorSpace;
this._toneMapping = renderer.toneMapping;
this.material.defines = {};
if ( ColorManagement.getTransfer( this._outputColorSpace ) === SRGBTransfer ) this.material.defines.SRGB_TRANSFER = '';
if ( this._toneMapping === LinearToneMapping ) this.material.defines.LINEAR_TONE_MAPPING = '';
else if ( this._toneMapping === ACESFilmicToneMapping ) this.material.defines.ACES_FILMIC_TONE_MAPPING = '';
// ... el resto de modos
this.material.needsUpdate = true;
}
Lee del renderer, así que basta con configurar renderer.toneMapping y renderer.outputColorSpace como siempre y OutputPass se adapta solo.
¿Qué pasa si lo pones antes del bloom? Que la imagen llega al bloom ya comprimida al rango de 0 a 1 y ya codificada en sRGB. El umbral de brillo deja de poder distinguir entre un blanco de papel y un sol, porque los dos valen 1. El desenfoque hace medias entre valores codificados, que es matemáticamente incorrecto. Y el resultado es el bloom lavado y sucio que se reconoce de lejos.
¿Y si lo omites? La imagen sale oscura y desaturada, porque los valores lineales llegan a pantalla sin la curva sRGB. Es el síntoma clásico de “monté el composer y ahora todo se ve mal”.
Si usas OutputPass, no añadas además un ShaderPass( GammaCorrectionShader ). GammaCorrectionShader sigue existiendo en r184 y hace la conversión sRGB pero no el tone mapping; era la solución antes de que OutputPass existiera. Ponerlos los dos aplica la curva dos veces y produce una imagen deslavada. Elige uno: OutputPass si quieres tone mapping, GammaCorrectionShader solo si por alguna razón no lo quieres.
Regla 2: el antialiasing después de todo lo que crea bordes
Un antialiasing por imagen busca discontinuidades y las suaviza. Si lo ejecutas antes de un efecto que introduce bordes nuevos —un contorno, un resplandor con corte duro, una viñeta abrupta—, esos bordes nuevos salen sin tratar y se ven con escalones justo al lado de los que sí suavizaste. Por eso el antialiasing va después de bloom, de contornos y de cualquier efecto que dibuje geometría o siluetas.
La otra mitad de la regla es el espacio de color, y aquí Three.js sí documenta el contrato de forma explícita, en dos ficheros que se responden entre sí.
SMAAPass dice que opera en linear-srgb y debe ejecutarse antes de OutputPass.
OutputPass dice que un pass que requiera entrada en sRGB, como FXAA, debe seguir a OutputPass en la cadena.
Es la única pareja del catálogo con el orden relativo escrito, y la razón es que si te equivocas no obtienes un error sino un antialiasing que funciona mal sin que sea evidente por qué: los algoritmos de detección de bordes calibran sus umbrales sobre diferencias de luminancia percibida, y esas diferencias son distintas antes y después de aplicar una curva.
Hay una tensión honesta que conviene conocer. Ejecutar SMAA sobre valores HDR sin recortar no es ideal: un píxel de valor 40 junto a uno de valor 1 es una diferencia enorme que puede desbordar la heurística de bordes, y el resultado son puntos brillantes aislados que el antialiasing no consigue suavizar. La solución industrial estándar es aplicar el antialiasing después del tone mapping. La implementación de Three.js no lo permite para SMAA, y esa es una de las razones para mirar la alternativa de la última sección.
Regla 3: los efectos que leen profundidad, pronto
SSAOPass y BokehPass reconstruyen información tridimensional del buffer de profundidad. Cuanto antes vayan, más se parece la imagen sobre la que operan a la escena original, y menos posibilidad hay de que un efecto anterior haya introducido cambios que descuadren la correspondencia entre color y profundidad.
Dentro de esos dos, el orden entre sí sigue la lógica óptica: la oclusión ambiental es una propiedad de la iluminación de la escena y va primero; la profundidad de campo es un efecto de la lente y va después, porque en una cámara real la luz ya iluminada es la que atraviesa el objetivo. Y el bloom va después de los dos, porque es un fenómeno del sensor y de la difusión interna del objetivo: un punto de luz desenfocado por la profundidad de campo debe resplandecer con su forma desenfocada, no con la enfocada.
Regla 4: el color grading, entre el tone mapping y el antialiasing
Si añades un ajuste de color —curvas, LUT, saturación, viñeteo— la posición depende de en qué espacio esté pensado. Un LUT calibrado sobre imagen final, que es lo habitual porque es lo que producen las herramientas de color, va después de OutputPass. Un ajuste físico, como una corrección de exposición, va antes, porque opera sobre radiancia.
Un viñeteo, que es geométrico y no tiene semántica de color, funciona en cualquiera de los dos sitios; después queda algo más contrastado.
La alternativa: postprocessing de pmndrs
Todo lo anterior describe una arquitectura con un problema estructural: cada efecto es un pase completo de pantalla. Como viste, el coste dominante del post-procesado es el ancho de banda, y una cadena de seis passes mueve la imagen doce veces por el bus de memoria.
La librería postprocessing de pmndrs ataca exactamente eso. Su idea central es separar el concepto de pass del de efecto: los efectos no son passes, son fragmentos de shader que un EffectPass fusiona en un único programa. Su README lo dice así:
This library provides an EffectPass which automatically organizes and merges
any given combination of effects. This minimizes the amount of render
operations and makes it possible to combine many effects without the
performance penalties of traditional pass chaining. Additionally, every
effect can choose its own blend function.
En código:
import { BloomEffect, EffectComposer, EffectPass, RenderPass } from 'postprocessing';
const composer = new EffectComposer( renderer );
composer.addPass( new RenderPass( escena, camara ) );
composer.addPass( new EffectPass( camara, new BloomEffect() ) );
Un EffectPass con cinco efectos dentro cuesta un pase, no cinco. Ese es todo el argumento, y es un argumento fuerte: en móvil y en GPU integrada la diferencia es del orden de dos a tres veces.
Su modelo de color también es distinto y conviene conocerlo antes de mezclar conceptos. Por defecto usa buffers de byte sin signo en sRGB, no de media precisión lineales, y hay que pedir HDR explícitamente:
const composer = new EffectComposer( renderer, { frameBufferType: HalfFloatType } );
Y su documentación recomienda dejar renderer.toneMapping en NoToneMapping y aplicar el tone mapping con un ToneMappingEffect al final de la cadena. Es lo contrario de lo que hace OutputPass, que lee el ajuste del renderer. Mezclar las dos convenciones es la fuente más común de imágenes mal expuestas al migrar.
La restricción práctica que hay que verificar siempre es la dependencia de versión. En su versión 6.39.1, la declaración es:
"peerDependencies": { "three": ">= 0.168.0 < 0.185.0" }
Es decir, r184 entra por los pelos: es la última versión soportada por ese rango. Antes de adoptarla en un proyecto que actualiza Three.js con frecuencia, comprueba el rango de la versión concreta que vas a instalar.
| Three.js addons | pmndrs postprocessing | |
|---|---|---|
| Coste con muchos efectos | un pase por efecto | un pase por EffectPass |
| Buffers por defecto | media precisión, lineal | byte sin signo, sRGB |
| Tone mapping | OutputPass lee del renderer |
ToneMappingEffect, renderer en NoToneMapping |
| Dependencias | ninguna extra | paquete aparte con rango de versión |
| Catálogo | 30 passes | efectos y passes propios, más amplio en algunos casos |
Lo que hace tan resbaladizo el orden de los passes es que la cadena no tiene tipos. Todo lo que circula por ella es WebGLRenderTarget, y una textura de valores lineales HDR y una de valores sRGB recortados son, para el sistema de tipos de JavaScript y para el composer, exactamente el mismo objeto. Pero para los efectos no lo son en absoluto: son tipos distintos con operaciones distintas definidas sobre ellos. La cadena real que estás construyendo, si la escribieras con tipos, sería algo así: RenderPass produce un ImagenLinealHDR; UnrealBloomPass consume un ImagenLinealHDR y produce otro; OutputPass consume ImagenLinealHDR y produce ImagenSRGB; FXAAPass consume ImagenSRGB. Un compilador rechazaría en el acto poner FXAA antes de OutputPass o bloom después. Como no hay compilador, el error se manifiesta como una imagen que “no acaba de verse bien” y que se intenta arreglar tocando parámetros durante horas. De ahí sale el consejo más útil que te puedo dar sobre esta área, y es de higiene, no de gráficos: escribe el tipo en un comentario junto a cada addPass, indicando en qué espacio y en qué rango entra y sale la imagen. Tres palabras por línea. Cuando dentro de seis meses alguien añada un efecto en medio, tendrá delante la información que el lenguaje no le da, y no tendrá que deducirla leyendo el código fuente de tres addons. Y si te acostumbras a pensar la cadena en esos términos, la regla del tone mapping al final deja de ser algo que memorizas y pasa a ser lo único que puede ser: la última conversión de tipo, la que sale del espacio en el que se calcula al espacio en el que se mira.
- Monta la cadena canónica completa y captura el resultado como referencia.
- Mueve
OutputPassantes del bloom y compara: mira sobre todo los reflejos especulares. - Quita
OutputPassdel todo y describe el síntoma con precisión. - Coloca SMAA después de
OutputPassy busca la diferencia en bordes de alto contraste. - Mide el frame time de cinco
ShaderPasstriviales encadenados y del mismo cálculo fusionado en uno solo.