— Pixel art school, en un comentario de DeviantArt, 2014.
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.
FullscreenMaterial vs. Material2d: la diferencia conceptualMaterial2d (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.
Material2d | FullscreenMaterial | |
|---|---|---|
| Cuándo se corre | Por cada draw call de un sprite | Una vez por frame, sobre la pantalla completa |
| Recibe | UVs por vértice | UVs del quad que cubre la pantalla |
| Puede leer el frame previo | No (no existe todavía) | Sí, tiene una textura "screen" |
| Uso típico | Efectos por sprite | Viñeta, color grading, pixelado, aberración |
| Plugin | Material2dPlugin::<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").
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:
MATERIAL_BIND_GROUP). Es la convención de FullscreenMaterial.intensity y smoothness comparten el slot #[uniform(0)]. WGSL las lee como un solo buffer.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;
}
}
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.
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.
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.
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.
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.
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.
Cada FullscreenMaterial cuesta un draw call fullscreen + las samples que haga. Cosas a tener en cuenta:
RenderTarget a 1/2 o 1/4 si la performance es un problema.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í.if): en GPU, las branches dinámicas cuestan. Si podés, usá mix(a, b, factor) en lugar de if (cond) { a } else { b }.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).
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.
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.
❌ 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.
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:
Material2d (por sprite) y FullscreenMaterial (por pantalla).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.