Saltar al contenido

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.

RenderPass

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

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

JS
const fondo = new RenderPass( escenaFondo, camara );const primerPlano = new RenderPass( escenaPrimerPlano, camara );primerPlano.clear = false;        // no borres lo que ya hayprimerPlano.clearDepth = true;    // pero si la profundidad, para que no se ocluyacomposer.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.

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

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

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

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

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

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