Capítulo 14F. Post-processing 2D con FullscreenMaterial: cómo hacer que tu juego se vea cinematográfico

CAP 14F · Bevy 0.18/0.19
"Cualquier juego 2D con sprites decentes se ve bien. Cualquier juego 2D con sprites decentes Y post-process se ve como arte."

— Pixel art school, en un comentario de DeviantArt, 2014.

14F.1 Qué es post-processing y por qué te importa

Imagina tu juego terminado. Los sprites se ven, la cámara sigue al player, la luz cae del sol. Funciona. Pero se ve "plano", como una pintura sin barniz. La diferencia entre "se ve" y "se ve cinematográfico" es, en el 80% de los casos, lo que pasa después de que la escena se renderizó pero antes de que llegue a la pantalla.

Eso es el post-process: un pase extra (o varios) que toma el framebuffer ya dibujado y le aplica un efecto: viñeta para oscurecer las esquinas, aberración cromática para un look "loco", pixelado retro para un look de 1995, screen-shake para drama, corrección de color para ambientar (rojo en el infierno, azul en el hielo).

El trait FullscreenMaterial (y su plugin, FullscreenMaterialPlugin) es la API de alto nivel para esto, disponible desde Bevy 0.13/0.14 (NO es nuevo de 0.18, aunque a veces se cite así). Vive en bevy::core_pipeline::fullscreen_material. Antes de su llegada, tenías que pelearte con el render graph a mano. Con el trait es "escribí un shader + un struct, agregá un plugin, listo".

Render de Bevy en orden simplificado:
1. Camera2d → extrae view + projection.
2. Culling por visibilidad.
3. Ordena sprites (Z, Y, etc.).
4. Batches de sprites.
5. Renderiza a un framebuffer intermedio.
6. POST-PROCESSING (acá entra FullscreenMaterial).  ← este capítulo.
7. Tonemap (HDR → LDR).
8. Pantalla final.

14F.2 FullscreenMaterial vs. Material2d: la diferencia conceptual

Material2d (cap. 14E) es un material por sprite: cada entidad con un MeshMaterial2d(mi_mat) usa ese material. FullscreenMaterial es un material por pantalla: una sola vez por frame, se corre sobre la imagen completa.

Material2dFullscreenMaterial
Cuándo se correPor cada draw call de un spriteUna vez por frame, sobre la pantalla completa
RecibeUVs por vérticeUVs del quad que cubre la pantalla
Puede leer el frame previoNo (no existe todavía)Sí, tiene una textura "screen"
Uso típicoEfectos por spriteViñeta, color grading, pixelado, aberración
PluginMaterial2dPlugin::<T>::default()FullscreenMaterialPlugin::<T>::default()

Para usar FullscreenMaterial, agregás un plugin, definís tu struct con AsBindGroup, y Bevy hace el resto. La inserción en el render graph la controlás vos (podés decir "ejecutá este pase antes del tonemap" o "después").

14F.3 Viñeta: el "Hola Mundo" del post-process

Una viñeta oscurece los bordes de la pantalla. Es el efecto más simple y el más vistoso:

//! cap-14F — sección 14F.3: fullscreen vignette material.
use bevy::prelude::*;
use bevy::core_pipeline::fullscreen_material::{
    FullscreenMaterial, FullscreenMaterialPlugin,
};
use bevy::render::render_resource::{AsBindGroup, ShaderRef};
use bevy::reflect::TypePath;

#[derive(Asset, TypePath, AsBindGroup, Default, Debug, Clone)]
struct VignetteMaterial {
    #[uniform(0)]
    intensity: f32,
    #[uniform(0)]
    smoothness: f32,
}

impl FullscreenMaterial for VignetteMaterial {
    fn fragment_shader() -> ShaderRef {
        "shaders/vignette.wgsl".into()
    }
}

// En el App::new:
.add_plugins(FullscreenMaterialPlugin::<VignetteMaterial>::default())

Y el shader:

//! cap-14F — sección 14F.3: shader de viñeta.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput

@group(0) @binding(0) var screen: texture_2d<f32>;
@group(0) @binding(1) var screen_sampler: sampler;
@group(0) @binding(2) var<uniform> intensity: f32;
@group(0) @binding(2) var<uniform> smoothness: f32;

@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
    let uv = in.uv;
    let center = vec2<f32>(0.5, 0.5);
    let dist = distance(uv, center);
    // smoothstep(borde_fuerte, borde_suave, dist): 0 en el centro, 1 en los bordes.
    let v = smoothstep(0.4, 0.7, dist * intensity);
    let color = textureSample(screen, screen_sampler, uv);
    return vec4<f32>(color.rgb * (1.0 - v * smoothness), color.a);
}

Tres detalles a notar:

  1. El binding group es 0 (no MATERIAL_BIND_GROUP). Es la convención de FullscreenMaterial.
  2. Las dos variables intensity y smoothness comparten el slot #[uniform(0)]. WGSL las lee como un solo buffer.
  3. screen y screen_sampler no las pasás vos. Bevy las conecta automáticamente al frame previo.

Para modificar el efecto en runtime (animar la intensidad en un "daño" al player), un sistema de Rust normal:

//! cap-14F — sección 14F.3: pulsando la viñeta al recibir daño.
fn pulse_vignette(
    time: Res<Time>,
    damage_cooldown: Res<DamageCooldown>,
    mut materials: ResMut<Assets<VignetteMaterial>>,
) {
    let intensity = if damage_cooldown.just_got_hit {
        3.0 + (time.elapsed_secs() * 20.0).sin() * 1.5
    } else {
        1.0
    };
    for (_, mat) in materials.iter_mut() {
        mat.intensity = intensity;
    }
}

14F.4 Aberración cromática: lente rota, gratis

La aberración cromática simula una lente que separa los canales RGB. En la práctica: los píxeles rojos se desplazan un poquito a la izquierda, los azules a la derecha. Es el efecto que ves cuando un personaje "ve mal" o está "aturdido".

//! cap-14F — sección 14F.4: aberración cromática.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput

@group(0) @binding(0) var screen: texture_2d<f32>;
@group(0) @binding(1) var screen_sampler: sampler;
@group(0) @binding(2) var<uniform> offset: f32;

@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
    let uv = in.uv;
    let dir = uv - vec2<f32>(0.5, 0.5);
    let r = textureSample(screen, screen_sampler, uv + dir * offset).r;
    let g = textureSample(screen, screen_sampler, uv).g;
    let b = textureSample(screen, screen_sampler, uv - dir * offset).b;
    return vec4<f32>(r, g, b, 1.0);
}

Tres textureSample por píxel. Es más caro que la viñeta (un sample), pero GPUs modernas lo tragan sin pestañear para resoluciones 1080p. En 4K o con offset alto, lo notás.

Truco: para "efecto sutil" usá offset = 0.005. Para "el mundo se está rompiendo" usá offset = 0.05. Para "esto es un shadertoy", offset = 0.2.

14F.5 Pixelado retro: de 1080p a 320×240

Si querés un look de 1995 (PSX, PS1, SNES a alta resolución), este es tu shader. Cuanto mayor el "factor", más pixelado:

//! cap-14F — sección 14F.5: pixelado retro.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput

@group(0) @binding(0) var screen: texture_2d<f32>;
@group(0) @binding(1) var screen_sampler: sampler;
@group(0) @binding(2) var<uniform> pixel_size: f32;

@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
    // Redondeamos UV al píxel más cercano.
    let uv = floor(in.uv / pixel_size) * pixel_size + pixel_size * 0.5;
    return textureSample(screen, screen_sampler, uv);
}

Si pixel_size = 0.005, dividís la pantalla en 200×200 píxeles lógicos (un look SNES a 1080p). Si pixel_size = 0.05, es prácticamente Minecraft. Más alto, más "8 bits".

Cuidado: el look retro no es solo "poco detalle". También quiere paleta limitada. Para eso, sumás una cuantización de color (lo vimos en el cap. 14E con MaterialExtension). La combinación "pixelado + cuantización" es el santo grial del look 16 bits.

14F.6 Inserción en el render graph: ¿antes o después del tonemap?

A veces querés que tu post-process corra antes del tonemap (ej.: blur sobre un HDR), a veces después (ej.: viñeta sobre el LDR final). Bevy 0.18+ te deja especificar esto:

//! cap-14F — sección 14F.6: controlando la posición del pase.
use bevy::core_pipeline::fullscreen_material::FullscreenMaterial;
use bevy::render::render_graph::NodeLabel;

#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct MyPostProcessLabel;

impl FullscreenMaterial for VignetteMaterial {
    fn fragment_shader() -> ShaderRef { ... }
    // Por defecto corre al final del MainTransparentPass.
    // Para correr antes, devolvé un label de nodo:
    fn node_label() -> Option<NodeLabel> { ... }
}

En la práctica, para el 90% de los efectos (viñeta, aberración, color grading final), el orden por defecto está bien. Si tu efecto necesita manipular HDR, mirá el cap. 16 sobre iluminación 2D y cómo se coordinan con el post-process.

14F.7 Combinar varios efectos: el "shader chain"

Rara vez querés un solo efecto. Lo común es: viñeta + color grading + aberración sutil. Dos opciones para combinarlos:

Opción A: un solo shader gordo que los hace todos.

//! cap-14F — sección 14F.7: shader "todo-en-uno" (vignette + chromatic).
@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
    // Paso 1: aberración cromática.
    let dir = in.uv - vec2<f32>(0.5, 0.5);
    let r = textureSample(screen, screen_sampler, in.uv + dir * offset).r;
    let g = textureSample(screen, screen_sampler, in.uv).g;
    let b = textureSample(screen, screen_sampler, in.uv - dir * offset).b;
    var color = vec4<f32>(r, g, b, 1.0);

    // Paso 2: viñeta.
    let dist = distance(in.uv, vec2<f32>(0.5, 0.5));
    let v = smoothstep(0.4, 0.7, dist * intensity);
    color = vec4<f32>(color.rgb * (1.0 - v * smoothness), color.a);

    return color;
}

Pro: un solo material, una sola ejecución. Contra: cualquier cambio toca todo el shader.

Opción B: varios materiales en cadena.

Pluginás tres FullscreenMaterial y Bevy los encadena. Más caro (varios pases), más modular. Útil cuando distintos efectos se activan en distintos momentos (ej.: la aberración solo cuando el player está aturdido).

Para empezar, la opción A. Cuando tengas 4+ efectos, pasá a la B.

14F.8 Color grading: ambientar con un LUT

El "color grading" es el arte de recolorear la imagen para dar mood. Una forma barata y efectiva: usar una LUT (Look-Up Table), una textura 3D (o 2D apilada) que mapea cada color de entrada a un color de salida.

//! cap-14F — sección 14F.8: fullscreen material con LUT.
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
struct ColorGradeMaterial {
    #[texture(0)]
    #[sampler(1)]
    lut: Handle<Image>,
    #[uniform(2)]
    intensity: f32,
}

impl FullscreenMaterial for ColorGradeMaterial {
    fn fragment_shader() -> ShaderRef { "shaders/color_grade.wgsl".into() }
}

El shader samplea la LUT según el color del píxel:

//! cap-14F — sección 14F.8: aplicar LUT.
@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
    let color = textureSample(screen, screen_sampler, in.uv);
    // Asumimos una LUT 2D horizontal de 256x16 (16 slices de 16x16 colores).
    let lut_size = vec2<f32>(256.0, 16.0);
    let b = color.b * (lut_size.y - 1.0);
    let slice = floor(b);
    let offset = slice / lut_size.y;
    let uv = vec2<f32>((color.r * 15.0 + 0.5) / 16.0, (color.g * 15.0 + 0.5) / 16.0) + offset;
    let graded = textureSample(lut, lut_sampler, uv);
    return mix(color, graded, intensity);
}

Generar la LUT es tema de un capítulo aparte (Photoshop, GIMP, o el plugin gratuito de GIMP GIMP-LUT-Generator). Para un juego 2D indie, una LUT de 32x32x32 es suficiente.

14F.9 Screen shake: el post-process más simple del mundo

El "screen shake" no es un post-process de pixel: es un offset de la cámara. Lo mencionamos acá porque a menudo se combina con un flash de color o una viñeta dramática.

//! cap-14F — sección 14F.9: screen shake con random offset.
fn screen_shake(
    time: Res<Time>,
    mut shake: ResMut<ScreenShake>,
    mut camera: Query<&mut Transform, With<Camera2d>>,
) {
    shake.trauma = (shake.trauma - time.delta_secs() * 1.5).max(0.0);
    let trauma = shake.trauma.powi(2);  // cuadrático: más dramático al final.

    let offset_x = (time.elapsed_secs() * 47.3).sin() * trauma * shake.max_offset;
    let offset_y = (time.elapsed_secs() * 53.7).cos() * trauma * shake.max_offset;

    for mut xform in camera.iter_mut() {
        xform.translation.x += offset_x;
        xform.translation.y += offset_y;
    }
}

El truco: un valor de "trauma" que sube con el evento (impacto, explosión) y baja con el tiempo. El offset es proporcional al trauma al cuadrado. Un trauma de 0.0 = quieto; 1.0 = caos total.

El cap. 19 (input) y el cap. 14 (render 2D) ya mostraron variantes más simples. Acá la versión "profesional" con trauma y curvas.

14F.10 Performance: post-process no es gratis

Cada FullscreenMaterial cuesta un draw call fullscreen + las samples que haga. Cosas a tener en cuenta:

  1. Resolución interna: si tu monitor es 4K, el fullscreen es 4K. Bajá la resolución del pase con RenderTarget a 1/2 o 1/4 si la performance es un problema.
  2. Número de samples: tres textureSample por píxel (aberración cromática) es 3× el costo de uno. Para 1080p, no se nota; para 4K con offset alto, sí.
  3. Evita branches (if): en GPU, las branches dinámicas cuestan. Si podés, usá mix(a, b, factor) en lugar de if (cond) { a } else { b }.
  4. Activá solo cuando hace falta: la viñeta permanente es barata, pero un bloom pesado (cap. 16) solo en momentos de magia. Pluginás/despluginás, o cambiás el intensity a 0.

Regla práctica: si tu juego va a 60 fps sin post-process y baja a 40 con viñeta + aberración, no es el shader. Es que estás corriendo en una resolución muy alta o tu batch está roto. Optimizá primero; perfilá con Tracy (cap. 26B).

14F.11 Glosario del capítulo

Post-process: pase de render que se aplica al frame final, después de los sprites.
Fullscreen material: material de Bevy 0.18+ que se ejecuta sobre la pantalla completa.
FullscreenVertexOutput: el struct que reciben los shaders fullscreen (vs. VertexOutput de los sprites).
Viñeta: oscurecimiento radial de los bordes, para centrar la atención.
Aberración cromática: separación de canales RGB para look "lente rota".
Pixelado retro: sampleo de UVs a menor resolución para look 16/8 bits.
Color grading: recolorear la imagen para dar mood (rojo en infierno, azul en hielo).
LUT (Look-Up Table): textura 3D (o 2D apilada) que mapea colores entrada→salida.
Tonemap: paso final que convierte HDR (rango alto) a LDR (rango bajo) para monitores sRGB.
HDR: High Dynamic Range, valores de color fuera de [0,1]. Necesita tonemap.
LDR: Low Dynamic Range, [0,1] estándar.
Render graph: DAG de pases de render. Cada post-process es un nodo.
Node label: etiqueta de un nodo del render graph, útil para ordenar.

14F.12 Trivia histórica

Prince of Persia (Jordan Mechner, 1989) — el rotoscopio manual sobre el que se animó el personaje usó una técnica de "color grading" implícita: el frame se filmaba con un filtro de color para que toda la animación quedara con un look coherente. Es el abuelo del color grading moderno.
Silent Hill (Konami, 1999) — uno de los primeros juegos mainstream en usar post-process deliberadamente para generar miedo. Niebla + viñeta + grain. Lo que ves en cada esquina del juego es un shader fullscreen.
INSIDE (Playdead, 2016) — el color grading del juego es un solo shader fullscreen con una LUT. Cambian la LUT por nivel: el naranja del nivel 1, el azul del nivel 3, el rojo del final. Es el efecto que más "habla" en el juego.
Celeste (Matt Thorson, 2018) — el screen shake del juego (cuando morís o pegás un dash) es un post-process, no un offset de cámara. Lo combinan con un flash de blanco y un zoom para el "dash". Tres efectos por el precio de uno.
Bevy 0.13/0.14 (2023/2024) — introdujo `FullscreenMaterial` como API de alto nivel (path `bevy::core_pipeline::fullscreen_material`). Antes, tenías que pelearte con `RenderGraph` y nodos custom. La comunidad lo pidió por años; llegó varias versiones antes de lo que a veces se cita.

14F.13 Metedura de pata

❌ Asumir que el binding group es `MATERIAL_BIND_GROUP` (como en `Material2d`).
✅ En `FullscreenMaterial`, el binding group es siempre 0.
💡 Por qué: el material fullscreen usa su propio sistema de bindings. Mezclar las convenciones es la causa #1 de "mi shader fullscreen no se ve nada".
❌ Olvidar `FullscreenMaterialPlugin::<T>::default()` en el `add_plugins`.
✅ Agregar el plugin.
💡 Por qué: igual que `Material2d`, sin el plugin Bevy no registra el material. El juego compila, no hay error, pero el efecto no aparece.
❌ Usar post-process para tapar un sprite mal renderizado.
✅ Resolver el problema de raíz (Z, batching, etc.).
💡 Por qué: el post-process amplifica, no arregla. Si tu sprite tiene bandas raras, el post-process las hará más raras.
❌ Apilar 5 shaders fullscreen en serie en una pantalla de 4K.
✅ Combinar los efectos en un solo shader, o bajar la resolución del pase.
💡 Por qué: cada pase fullscreen es un draw call. 5 = 5 draws adicionales. En 4K con efectos pesados, esto te tira abajo el framerate. Perfilá con Tracy.

🎯 14F.14 Patrón del capítulo: "el orden importa"

Problema: tu juego se ve raro. El color no es el correcto, hay banding, el bloom no se ve. Sospechás que es "un shader mal escrito", pero el shader es correcto.

Solución: en el 80% de los casos, el problema es el orden en que los post-process se ejecutan. La cadena correcta suele ser:

1. Render escena (sprites, iluminación).
2. HDR (si hay, cap. 16).
3. Bloom (si hay).
4. Efectos de color (color grading con LUT).
5. Aberración cromática, screen-shake visual.
6. Viñeta.
7. Tonemap (HDR → LDR).
8. Pantalla final.

Cuándo sí:

Cuándo no:

Lo que vimos

En el siguiente

Capítulo 15: tilemaps. Pasamos del "sprite suelto" al "mapa entero con miles de tiles". El cap. 14 te enseñó a poner una imagen; el 15 te enseña a poner un mundo. Spoiler: bevy_ecs_tilemap es un plugin de la comunidad, no parte de Bevy, y vale cada línea de Cargo.toml.