wandres.dev
POST-PROCESADO · EffectComposer y los passes

RenderPass, ShaderPass y uno propio

Los dos passes fundamentales por dentro, FullScreenQuad y por qué es un triángulo, y cómo escribir un pass que se comporte bien en la cadena.

⏱ 18 min

Toda la biblioteca de efectos se apoya en dos piezas: una que convierte una escena en una textura y otra que convierte una textura en otra textura. Con esas dos y un shader propio se puede escribir cualquier efecto de imagen, y hacerlo una vez es la mejor forma de entender por qué los efectos del catálogo hacen lo que hacen.

🎯 Al terminar esta lección sabrás
  • Configurar un RenderPass con material de sustitución y color de limpieza.
  • Escribir un ShaderPass con un shader propio y uniforms actualizables.
  • Usar FullScreenQuad para construir un pass desde cero.
  • Declarar correctamente needsSwap, setSize y dispose en un pass propio.

RenderPass

Es el eslabón que convierte una escena tridimensional en una imagen. Su firma completa:

new RenderPass( scene, camera, overrideMaterial = null, clearColor = null, clearAlpha = null )

Los tres últimos parámetros no son decorativos. overrideMaterial sustituye el material de todos los objetos durante ese render, que es la forma estándar de generar buffers auxiliares: un pase de normales, un pase de profundidad, un pase de máscara por objeto. clearColor y clearAlpha permiten limpiar con un color distinto al del renderer, lo cual importa mucho cuando el resultado se va a componer sobre otra cosa.

Sus propiedades públicas relevantes son clear, que vale true por defecto, y clearDepth, que vale false. Y needsSwap vale false, porque como viste, escribe en readBuffer.

Un patrón útil: dos RenderPass en la misma cadena para separar el fondo del primer plano, con el segundo limpiando solo la profundidad.

const fondo = new RenderPass( escenaFondo, camara );

const primerPlano = new RenderPass( escenaPrimerPlano, camara );
primerPlano.clear = false;        // no borres lo que ya hay
primerPlano.clearDepth = true;    // pero si la profundidad, para que no se ocluya

composer.addPass( fondo );
composer.addPass( primerPlano );

ShaderPass

Es el eslabón genérico: toma un objeto de shader, lo convierte en material, dibuja un triángulo a pantalla completa y escribe el resultado.

new ShaderPass( shader, textureID = 'tDiffuse' )

El segundo parámetro es el nombre del uniform donde el pass inyectará la textura de entrada en cada frame. La convención en todo Three.js es tDiffuse y no hay razón para cambiarla salvo que estés adaptando un shader ajeno.

El primero admite dos formas: un objeto plano con uniforms, vertexShader y fragmentShader, o directamente una instancia de ShaderMaterial, que se reutiliza tal cual. La primera es la habitual:

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

const ViñeteoShader = {

  uniforms: {
    tDiffuse:    { value: null },
    uIntensidad: { value: 1.0 },
    uSuavidad:   { value: 0.6 }
  },

  vertexShader: /* glsl */`
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    }
  `,

  fragmentShader: /* glsl */`
    uniform sampler2D tDiffuse;
    uniform float uIntensidad;
    uniform float uSuavidad;
    varying vec2 vUv;

    void main() {
      vec4 col = texture2D( tDiffuse, vUv );

      // Distancia radial al centro, corregida para que el vinieteo sea circular.
      float d = length( vUv - 0.5 ) * 1.4142;
      float v = smoothstep( 1.0, 1.0 - uSuavidad, d );

      col.rgb *= mix( 1.0, v, uIntensidad );
      gl_FragColor = col;
    }
  `

};

const vinieteo = new ShaderPass( ViñeteoShader );
composer.addPass( vinieteo );

// Actualizar en caliente:
vinieteo.uniforms.uIntensidad.value = 0.8;

Fíjate en dos detalles del vertex shader. Usa projectionMatrix * modelViewMatrix aunque la geometría ya esté en coordenadas de recorte: es correcto porque FullScreenQuad usa una cámara ortográfica de -1 a 1 cuyas matrices son la identidad, así que el producto no cambia nada y el shader sigue siendo un ShaderMaterial normal. Y tDiffuse se declara como uniform pero se inicializa a null: el pass lo rellena en cada render() con la textura de readBuffer.

ShaderPass deja needsSwap en su valor por defecto, true, porque escribe en writeBuffer.

FullScreenQuad

Es la utilidad que dibuja el triángulo, y se exporta desde el mismo módulo que Pass:

import { Pass, FullScreenQuad } from 'three/addons/postprocessing/Pass.js';

Su geometría en r184 es un triángulo, no un quad, pese al nombre:

this.setAttribute( 'position', new Float32BufferAttribute( [ - 1, 3, 0, - 1, - 1, 0, 3, - 1, 0 ], 3 ) );
this.setAttribute( 'uv', new Float32BufferAttribute( [ 0, 2, 0, 0, 2, 0 ], 2 ) );

Tres vértices que forman un triángulo grande cuya intersección con el cuadrado visible lo cubre entero, con las UV extrapoladas para que valgan de 0 a 1 exactamente en la zona visible. La razón de preferirlo a dos triángulos es que un quad tiene una diagonal donde ambos triángulos se tocan, y como las GPU rasterizan en bloques de dos por dos fragmentos, esa diagonal se procesa dos veces. Con un fragment shader de post-procesado, que se ejecuta millones de veces, ese desperdicio es medible.

La geometría y la cámara ortográfica son constantes a nivel de módulo, compartidas por todas las instancias. Cada FullScreenQuad solo aporta su material.

Un pass propio, completo

Vamos a escribir un pass que hace algo que ShaderPass no puede: mezclar la imagen actual con la del frame anterior, es decir, un rastro temporal. Necesita un render target propio, y eso obliga a implementar setSize y dispose.

import * as THREE from 'three';
import { Pass, FullScreenQuad } from 'three/addons/postprocessing/Pass.js';

class RastroPass extends Pass {

  constructor( persistencia = 0.85 ) {

    super();

    this.uniforms = {
      tActual:      { value: null },
      tPrevio:      { value: null },
      uPersistencia: { value: persistencia }
    };

    this.material = new THREE.ShaderMaterial( {
      uniforms: this.uniforms,
      vertexShader: /* glsl */`
        varying vec2 vUv;
        void main() {
          vUv = uv;
          gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
        }
      `,
      fragmentShader: /* glsl */`
        uniform sampler2D tActual;
        uniform sampler2D tPrevio;
        uniform float uPersistencia;
        varying vec2 vUv;

        void main() {
          vec4 a = texture2D( tActual, vUv );
          vec4 p = texture2D( tPrevio, vUv );
          gl_FragColor = max( a, p * uPersistencia );
        }
      `
    } );

    this.quad = new FullScreenQuad( this.material );

    // Historia: un render target propio que sobrevive entre frames.
    this.historia = new THREE.WebGLRenderTarget( 1, 1, { type: THREE.HalfFloatType } );

    // Material de copia para refrescar la historia.
    this.copiaMaterial = new THREE.MeshBasicMaterial();
    this.quadCopia = new FullScreenQuad( this.copiaMaterial );

  }

  render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {

    this.uniforms.tActual.value = readBuffer.texture;
    this.uniforms.tPrevio.value = this.historia.texture;

    // 1. Mezcla actual + historia hacia el buffer de escritura.
    renderer.setRenderTarget( this.renderToScreen ? null : writeBuffer );
    if ( this.clear ) renderer.clear();
    this.quad.render( renderer );

    // 2. Guarda el resultado como nueva historia.
    this.copiaMaterial.map = this.renderToScreen ? readBuffer.texture : writeBuffer.texture;
    renderer.setRenderTarget( this.historia );
    this.quadCopia.render( renderer );

  }

  setSize( width, height ) {

    this.historia.setSize( width, height );

  }

  dispose() {

    this.historia.dispose();
    this.material.dispose();
    this.copiaMaterial.dispose();
    this.quad.dispose();
    this.quadCopia.dispose();

  }

}

needsSwap se queda en true porque el pass escribe en writeBuffer. setSize es imprescindible: el composer lo llama al añadir el pass y en cada redimensionado, y sin él la historia se quedaría en un píxel. Y dispose es responsabilidad tuya, porque como viste, el composer no lo llama por ti.

💡
Depura un pass mirando lo que produce, no lo que calcula

La forma más rápida de encontrar un bug en un pass propio es aislarlo: pon renderToScreen = true a mano en él, desactiva todos los posteriores con enabled = false y mira su salida cruda. Si eso no basta, sustituye tu fragment shader por uno que devuelva texture2D( tDiffuse, vUv ) sin tocar nada; si la imagen sale bien, el problema es tu matemática, y si sale mal o desfasada, el problema es el buffer o el needsSwap.

El material de un pass es el único sitio de Three.js donde el estado global te muerde

Hay una clase de fallo que solo aparece en passes escritos a mano y que desconcierta porque el shader es obviamente correcto: la imagen sale bien un frame y mal el siguiente, o sale bien en tu máquina y mal en otra. La causa casi siempre es que un pass hereda estado del renderer que el pass anterior dejó puesto. renderer.setRenderTarget() es global. renderer.autoClear es global. renderer.setClearColor() es global. El estado de blending, el test de profundidad y la máscara de escritura viven en el material, pero el viewport y el scissor viven en el renderer. Y EffectComposer no restaura nada entre passes: cada uno hereda exactamente lo que el anterior dejó. La biblioteca de passes oficial funciona porque sus autores acordaron implícitamente unas convenciones —cada pass fija su render target al principio, nadie deja autoClear en false sin restaurarlo— pero esas convenciones no están escritas en ninguna parte ni verificadas por nada. En cuanto metes un pass propio en la cadena, entras en ese acuerdo tácito sin haberlo leído. La disciplina que evita el problema es corta y vale la pena adoptarla desde el primer pass que escribas: fija explícitamente todo lo que te importe al empezar tu render(), y restaura al salir cualquier ajuste global que hayas cambiado. Si tocas autoClear, guárdalo y devuélvelo. Si cambias el color de limpieza, igual. Si usas setScissorTest, desactívalo antes de volver. Cuesta seis líneas y te ahorra la clase de bug que solo se reproduce cuando el usuario activa un efecto concreto en un orden concreto.