wandres.dev
POST-PROCESADO · EffectComposer y los passes

El catálogo de efectos y su coste

Bloom, SSAO, profundidad de campo, contornos y antialiasing: qué hace cada uno por dentro, sus parámetros reales y lo que cuesta cada uno.

⏱ 20 min

La carpeta de post-procesado de Three.js tiene treinta ficheros y ninguna guía sobre cuál usar. Cinco de ellos cubren el noventa por ciento de los casos reales, y conocer no solo qué hacen sino cómo lo hacen es lo que te permite predecir su coste antes de añadirlos y ajustarlos sin ir a ciegas.

🎯 Al terminar esta lección sabrás
  • Configurar bloom, SSAO, profundidad de campo, contornos y antialiasing con sus firmas reales.
  • Explicar el algoritmo interno de cada uno lo bastante para predecir su coste.
  • Identificar qué passes traen buffers propios y cuántos.
  • Elegir entre SMAA y FXAA con un criterio.

Efectos de luz y sombra

UnrealBloomPass

Aísla los píxeles cuya luminancia supera un umbral, los desenfoca a varias escalas y los suma de vuelta sobre la imagen. El nombre viene del algoritmo de Unreal Engine.

import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';

const bloom = new UnrealBloomPass(
  new THREE.Vector2( innerWidth, innerHeight ),   // resolution
  0.6,                                            // strength
  0.4,                                            // radius
  0.85                                            // threshold
);

El orden de los parámetros es ( resolution, strength, radius, threshold ), con la resolución primero, que es una fuente constante de confusión porque otros passes la ponen en otro sitio.

Por dentro construye cinco niveles de mipmap, cada uno a la mitad de resolución que el anterior, y en cada uno aplica un desenfoque gaussiano separable con kernels de 6, 10, 14, 18 y 22 muestras respectivamente. Eso son diez render targets más uno para el pase de extracción de brillo, todos en media precisión. La suma final pondera los cinco niveles según radius: valores bajos concentran el resplandor cerca de la fuente, valores altos lo extienden.

El coste es moderado precisamente por la pirámide: el nivel más caro es el primero, a media resolución, y cada nivel siguiente cuesta la cuarta parte del anterior. La serie converge, así que el total ronda un tercio del coste de un solo pase a resolución completa.

threshold merece atención. Con valores de 8 bits el umbral tiene que ser menor que 1 para que algo lo supere, y el resultado siempre es sucio porque los brillos ya venían recortados. Con los buffers de media precisión del composer, en cambio, un umbral de 1.0 o superior es perfectamente razonable y produce un bloom limpio que solo afecta a lo que de verdad es más brillante que el blanco. Es el argumento práctico más fuerte a favor de HDR en la cadena.

Su documentación advierte de algo importante: el bloom espera que el tone mapping esté activo en el renderer, porque sin él los valores sumados se recortan al llegar a pantalla y el efecto se ve como manchas planas.

needsSwap vale false: escribe en readBuffer.

SSAOPass

Oclusión ambiental en espacio de pantalla. Reconstruye la posición de cada píxel a partir del buffer de profundidad, toma un conjunto de muestras en una hemiesfera orientada por la normal, y cuenta cuántas caen detrás de geometría.

import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';

const ssao = new SSAOPass( escena, camara, innerWidth, innerHeight, 32 );
ssao.kernelRadius = 8;
ssao.minDistance  = 0.005;
ssao.maxDistance  = 0.1;

Firma real: ( scene, camera, width = 512, height = 512, kernelSize = 32 ). Aquí la resolución va en tercera y cuarta posición, no en la primera.

Es el pass más caro del catálogo, y por tres motivos acumulados. Primero, necesita volver a renderizar la escena dos veces para generar los buffers de profundidad y normales, lo que duplica el coste de geometría de tu frame. Segundo, ejecuta kernelSize muestras por píxel, cada una con una lectura de textura dependiente y por tanto con mala localidad de caché. Tercero, el resultado es ruidoso y necesita un pase de desenfoque adicional para ser presentable.

Sus tres parámetros interactúan de forma poco intuitiva. kernelRadius está en unidades de mundo, así que depende por completo de la escala de tu escena: en una escena de un metro, 8 significa que cada punto busca oclusión en un radio de ocho metros y el resultado es una mancha gris. minDistance y maxDistance recortan el rango de profundidad que se considera oclusión y sirven para evitar los halos oscuros alrededor de las siluetas.

Para depurar, tiene un modo de salida:

ssao.output = SSAOPass.OUTPUT.SSAO;   // 0 Default, 1 SSAO, 2 Blur, 3 Depth, 4 Normal

needsSwap vale false.

Lente y selección

BokehPass

Profundidad de campo. Usa el buffer de profundidad para calcular, en cada píxel, el radio del círculo de confusión, y desenfoca en consecuencia.

import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';

const bokeh = new BokehPass( escena, camara, {
  focus: 8.0,        // distancia enfocada, en unidades de mundo
  aperture: 0.00015, // apertura: mas alta, menos profundidad de campo
  maxblur: 0.01      // radio maximo del desenfoque
} );

// Ajustar en caliente:
bokeh.uniforms.focus.value = 5.0;

El tercer parámetro no es opcional: el constructor lo desreferencia sin comprobarlo, así que llamarlo sin él lanza. Los valores por defecto documentados son focus: 1, aperture: 0.025 y maxblur: 1, pero en la práctica hay que ajustarlos a la escala de la escena, y aperture se mueve en rangos muy pequeños que sorprenden la primera vez.

Como el SSAO, necesita un render adicional de la escena para la profundidad. Su coste de imagen es alto porque muestrea un vecindario amplio con radio variable, lo que impide toda optimización basada en un kernel fijo.

Un aviso de comportamiento: el desenfoque por profundidad en espacio de pantalla no puede saber qué hay detrás de un objeto enfocado, así que produce siluetas duras donde debería haber sangrado. Es una limitación de la técnica, no del pass.

OutlinePass

Dibuja contornos alrededor de objetos seleccionados, con soporte para bordes ocultos.

import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js';

const contorno = new OutlinePass(
  new THREE.Vector2( innerWidth, innerHeight ),   // resolution primero
  escena,
  camara,
  [ objetoSeleccionado ]
);

contorno.edgeStrength     = 3.0;
contorno.edgeGlow         = 0.0;
contorno.edgeThickness    = 1.0;
contorno.pulsePeriod      = 0;
contorno.visibleEdgeColor.set( 0xffffff );
contorno.hiddenEdgeColor.set( 0x190a05 );
contorno.downSampleRatio  = 2;

Firma: ( resolution, scene, camera, selectedObjects ). Otra vez resolución primero, al contrario que SSAOPass.

Renderiza los objetos seleccionados a una máscara, detecta sus bordes y los compone. Mantiene cuatro render targets propios. Su coste depende sobre todo de cuántos objetos hay seleccionados: con la lista vacía el pass no hace prácticamente nada, lo cual es la optimización obvia cuando no hay selección activa. downSampleRatio a 2 significa que el trabajo de bordes se hace a media resolución, que casi siempre basta.

needsSwap vale false.

Antialiasing por software

Antialiasing por software, necesario porque el multisampling del contexto no funciona sobre render targets.

import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';

const smaa = new SMAAPass();   // sin argumentos en r184

Ojo con esto: en r184 el constructor de SMAAPass no acepta parámetros. Las versiones antiguas tomaban ancho y alto; ahora se ignoran silenciosamente y la resolución llega por setSize, que el composer llama solo. Código copiado de un tutorial antiguo con new SMAAPass( innerWidth, innerHeight ) funciona por accidente, pero conviene limpiarlo.

SMAA detecta bordes, calcula pesos de mezcla consultando dos texturas precalculadas de área y búsqueda, y aplica la mezcla. Es de calidad claramente superior a FXAA, conserva mejor los detalles finos y cuesta unos tres pases de pantalla completa más la memoria de sus dos texturas de datos.

FXAA es un único pase que estima el borde a partir de la luminancia local y aplica un desenfoque direccional. Es más barato y más borroso.

La diferencia que de verdad decide dónde va cada uno es el espacio de color en el que operan, y aparece en la documentación de sus ficheros. SMAAPass dice literalmente que opera en linear-srgb y debe ejecutarse antes de OutputPass. OutputPass dice que un pass que necesite entrada en sRGB, como FXAA, debe ir después. Es la única pareja del catálogo cuyo orden relativo está documentado de forma explícita, y no es casualidad: es donde más gente se equivoca.

Tabla de coste

Pass Renders extra de escena Buffers propios Coste relativo needsSwap
RenderPass 0 el de tu escena false
ShaderPass 0 0 1 pase true
OutputPass 0 0 1 pase true
FXAAPass 0 0 1 pase true
SMAAPass 0 2 + 2 texturas 3 pases true
UnrealBloomPass 0 11 1 a 2 pases false
OutlinePass 1 a 2 4 medio, según selección false
BokehPass 1 1 alto true
SSAOPass 2 3 el más alto false
⚠️
Dos passes que rerenderizan la escena duplican tu coste de geometría

SSAOPass y BokehPass no son solo passes de imagen: vuelven a recorrer el grafo de escena para generar sus buffers auxiliares. En una escena con muchos draw calls, añadir los dos puede triplicar el trabajo de CPU del frame aunque el shader de post-procesado sea barato. Si vas a usar ambos, la solución correcta es generar el buffer de profundidad una sola vez y compartirlo, lo que en la biblioteca oficial de Three.js exige escribir tus propios passes y es una de las razones principales para mirar la alternativa de pmndrs.

Casi todos estos efectos son aproximaciones en espacio de pantalla, y sus artefactos vienen del mismo agujero

Bloom, SSAO, profundidad de campo, reflejos en espacio de pantalla y oclusión de contacto comparten una misma estructura: reconstruyen información tridimensional a partir de un buffer bidimensional de color y profundidad. Y todos fallan en el mismo sitio, por la misma razón, aunque los artefactos parezcan distintos. El buffer de profundidad guarda una sola muestra por píxel: la superficie visible más cercana. Todo lo que hay detrás de ella no existe. Por eso el SSAO produce halos oscuros en las siluetas —al buscar oclusión más allá del borde de un objeto, se encuentra con el fondo lejano y lo interpreta como espacio abierto—; por eso el bokeh no puede sangrar correctamente sobre un objeto enfocado —no sabe qué color tiene lo que está detrás—; por eso los reflejos en espacio de pantalla desaparecen cuando lo reflejado sale del encuadre. No son bugs de implementación: son la consecuencia inevitable de haber tirado la tercera dimensión. Esto tiene dos implicaciones prácticas que valen más que cualquier ajuste de parámetros. La primera: cuando un efecto de estos se ve mal, casi nunca se arregla subiendo la calidad. Más muestras de SSAO reducen el ruido pero no eliminan el halo, porque el halo no es ruido, es falta de información. La solución es ocultarlo —reduciendo el radio, ajustando maxDistance, oscureciendo menos— o cambiar de técnica. La segunda: el encuadre es un parámetro del efecto. Un objeto que sale parcialmente del borde de la pantalla pierde su oclusión, su bokeh y su reflejo en la zona cortada, y eso produce discontinuidades visibles al mover la cámara que ningún ajuste elimina. Los motores serios lo mitigan renderizando con un margen mayor que el visible y recortando al final. Es caro, y es el único arreglo real.