— Anónimo, taller de gráficos en Gamelab, 2023.
El cap. 14 te enseñó a poner sprites en pantalla. El cap. 16 te enseñó a iluminarlos. Pero ninguno te enseñó a escribir el shader que se ejecuta entre bambalinas. ¿Por qué? Porque hasta ahora, ColorMaterial y StandardMaterial bastaban. Pero el día que quieres "un sprite que brilla con un patrón procedural", "un material que se deforma con el tiempo", "un efecto de agua que se mueve", o "el fuego de la espada del protagonista", ese día necesitas escribir un shader. Y ese día no debería pillarte con el libro cerrado.
WGSL es el lenguaje de shading de WebGPU, y Bevy lo usa por debajo a través de wgpu. Es relativamente nuevo (2022) y reemplazó a GLSL como estándar para web. La buena noticia: es menos críptico que GLSL y más legible. La mala: todavía no hay tanta documentación como de GLSL, así que este capítulo te va a hacer un favor doble.
Tres tipos de programa en WGSL:
@vertex → corre UNA vez por vértice. Decide dónde va cada esquina del sprite.
@fragment → corre UNA vez por píxel. Decide de qué color sale cada píxel.
@compute → corre en paralelo para datos. No dibuja nada; simula, transforma, calcula.
Tu sprite 2D típico usa @vertex y @fragment. @compute lo cubrimos más adelante, en el cap. 14E.6.
Empecemos por el caso más simple: un shader que pinta el sprite de un color sólido. Archivo assets/shaders/hello.wgsl:
//! cap-14E — sección 14E.2: shader de fragmento mínimo.
// El `VertexOutput` viene del shader de vértices por defecto de Bevy.
// Contiene la posición del píxel, las UVs, y un par de cosas más.
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 0.4, 0.7, 1.0); // rosa chicle.
}
Eso es todo. Un shader WGSL es una función @fragment (o @vertex) que recibe datos y devuelve un color (o una posición). El #import de la primera línea es la manera de traer tipos predefinidos de Bevy — VertexOutput es el que tiene las UVs y la posición del fragmento.
Ahora la pregunta del millón: ¿cómo le digo a Bevy que use este shader en lugar del de por defecto? Con un Material2d custom. Aquí está el lado Rust:
//! cap-14E — sección 14E.2: material 2D con shader custom.
use bevy::prelude::*;
use bevy::sprite::{
AlphaMode2d, Material2d, Material2dPlugin,
};
use bevy::render::render_resource::{AsBindGroup, ShaderRef};
use bevy::reflect::TypePath;
const SHADER_PATH: &str = "shaders/hello.wgsl";
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
struct HelloMaterial;
impl Material2d for HelloMaterial {
fn fragment_shader() -> ShaderRef {
SHADER_PATH.into()
}
}
fn main() {
App::new()
.add_plugins((
DefaultPlugins,
Material2dPlugin::<HelloMaterial>::default(),
))
.add_systems(Startup, setup)
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<HelloMaterial>>,
) {
commands.spawn(Camera2d);
commands.spawn((
Mesh2d(meshes.add(Rectangle::new(200.0, 100.0))),
MeshMaterial2d(materials.add(HelloMaterial)),
Transform::from_xyz(0.0, 0.0, 0.0),
));
}
Tres piezas a entender:
Material2d es un trait que define cómo se conecta tu struct a un shader. Lo único obligatorio es fragment_shader() (o vertex_shader() si querés override del vertex).AsBindGroup es la macro-derive que traduce los campos de tu struct a "bindings" en el shader (uniforms, texturas, samplers). Si tu material no tiene datos, igual tenés que derivarlo.Material2dPlugin::<HelloMaterial> registra el material en el sistema de render. Sin esto, Bevy no sabe cómo renderizarlo.¿Suena a mucho? Sí. Pero una vez que tenés el primer material funcionando, los demás son variaciones sobre el mismo tema.
Un shader en el vacío no hace nada útil. Lo útil viene cuando le pasás datos desde Rust: un color que cambia con el tiempo, una textura, un multiplicador. Estos datos van por tres canales:
| Tipo en Rust | Anotación | Tipo en WGSL | Uso típico |
|---|---|---|---|
f32, Vec2, etc. | #[uniform(0)] | var<uniform> mi_var: f32; | Constantes, tiempos, parámetros |
Handle<Image> | #[texture(1)] | var mi_tex: texture_2d<f32>; | Texturas |
Handle<Image> (sampler) | #[sampler(2)] | var mi_sampler: sampler; | Cómo se lee la textura (linear, nearest) |
Los números 0, 1, 2 son los índices de binding dentro del grupo. Si tenés múltiples materiales, cada uno tiene su propio grupo (el MATERIAL_BIND_GROUP que Bevy maneja por vos).
Ejemplo completo: un material con un color uniform + una textura:
//! cap-14E — sección 14E.3: material con color uniform + textura.
use bevy::prelude::*;
use bevy::sprite::{AlphaMode2d, Material2d, Material2dPlugin};
use bevy::render::render_resource::{AsBindGroup, ShaderRef};
use bevy::reflect::TypePath;
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
struct TintedTexture {
#[uniform(0)]
color: LinearRgba,
#[texture(1)]
#[sampler(2)]
texture: Handle<Image>,
}
impl Material2d for TintedTexture {
fn fragment_shader() -> ShaderRef {
"shaders/tinted_texture.wgsl".into()
}
fn alpha_mode(&self) -> AlphaMode2d {
AlphaMode2d::Blend
}
}
Y el shader:
//! cap-14E — sección 14E.3: shader de tinted texture.
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> material_color: vec4<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var base_texture: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var base_sampler: sampler;
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let tex_color = textureSample(base_texture, base_sampler, in.uv);
return tex_color * material_color;
}
El truco #{MATERIAL_BIND_GROUP} es una macro que Bevy resuelve en tiempo de compilación con el número del grupo real. Usalo siempre; evita el clásico "y este grupo cuál era".
Analogía: el grupo de bindings es como un "sobre" con apartados numerados. Rust rellena los apartados (#[uniform(0)], #[texture(1)]...). WGSL lee los apartados (@binding(0), @binding(1)...). El MATERIAL_BIND_GROUP es el número de sobre que Bevy te asigna.
Un sprite que cambia de color suavemente con el tiempo. La trampa clásica aquí es intentar meter el tiempo como un #[uniform(0)] dentro del propio material y actualizarlo "a mano". Eso se puede hacer, pero la forma idiomática en Bevy es más simple: guardá en el material solo los colores estáticos (que no cambian) y dejá que un sistema, una vez por frame, compute el color actual y lo escriba en un campo que sí es uniform.
//! cap-14E — sección 14E.4: material pulsante (correcto desde el inicio).
use bevy::prelude::*;
use bevy::sprite::{AlphaMode2d, Material2d, Material2dPlugin};
use bevy::render::render_resource::{AsBindGroup, ShaderRef};
use bevy::reflect::TypePath;
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
struct PulsingMaterial {
// Campo UNIFORM (va a la GPU) y es lo único que el shader lee.
// Lo reescribimos desde un sistema cada frame.
#[uniform(0)]
color: LinearRgba,
// Los colores extremos NO llevan #[uniform] porque no van al shader:
// son estado Rust puro que usamos para computar `color`.
color_a: LinearRgba,
color_b: LinearRgba,
}
impl Material2d for PulsingMaterial {
fn fragment_shader() -> ShaderRef { "shaders/pulse.wgsl".into() }
fn alpha_mode(&self) -> AlphaMode2d { AlphaMode2d::Blend }
}
// Sistema que se ejecuta en `Update`: computa el color actual.
fn pulse_material(
time: Res<Time>,
mut materials: ResMut<Assets<PulsingMaterial>>,
) {
for (_, mat) in materials.iter_mut() {
let t = (time.elapsed_secs() * 2.0).sin() * 0.5 + 0.5;
mat.color = mat.color_a.lerp(mat.color_b, t);
}
}
El shader entonces solo recibe un color y lo aplica al sprite:
//! cap-14E — sección 14E.4: shader del pulso.
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> material_color: vec4<f32>;
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
return material_color;
}
El detalle clave: el material no se modifica píxel a píxel. El sistema de Rust actualiza el color una vez por frame, y el shader recibe el color final. Para animaciones que dependen del tiempo, este es el patrón idiomático. La tentación de meter #[uniform(0)] time: f32 y modificarlo desde el sistema también funciona, pero para datos derivados (un lerp entre dos colores) es más limpio dejar el cálculo en Rust y exponer solo el resultado al shader.
Cuidado: si tu sistema de pulso corre en
Updatey el render corre después en el mismo frame, está bien. Si lo corrés enFixedUpdate, el pulso будет "escalonado" en frames fijos. Para animaciones puramente visuales,Update.
Hasta ahora solo escribimos @fragment. Vamos al @vertex, que se ejecuta una vez por vértice del mesh (cuatro veces para un quad). Esto te permite mover las esquinas del sprite: para hacer agua, llamas, una bandera al viento.
//! cap-14E — sección 14E.5: material con vertex shader custom.
use bevy::sprite::Material2d;
impl Material2d for WavyMaterial {
fn vertex_shader() -> ShaderRef {
"shaders/wavy.wgsl".into()
}
fn fragment_shader() -> ShaderRef {
"shaders/wavy.wgsl".into()
}
}
El shader wavy.wgsl:
//! cap-14E — sección 14E.5: vertex + fragment para un sprite ondulado.
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
struct WavyMaterial {
time: f32,
amplitude: f32,
frequency: f32,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> material: WavyMaterial;
@vertex
fn vertex(
@builtin(vertex_index) idx: u32,
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
) -> VertexOutput {
var out: VertexOutput;
// Onda sinusoidal en el eje Y según el X.
let wave = sin(position.x * material.frequency + material.time) * material.amplitude;
out.position = vec4<f32>(position.x, position.y + wave, 0.0, 1.0);
out.uv = uv;
return out;
}
@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(0.3, 0.6, 1.0, 1.0); // azul agua.
}
Y el lado Rust para que el tiempo fluya:
//! cap-14E — sección 14E.5: actualizando el tiempo en el material.
fn update_wavy(
time: Res<Time>,
mut materials: ResMut<Assets<WavyMaterial>>,
) {
let t = time.elapsed_secs();
for (_, mat) in materials.iter_mut() {
mat.time = t;
}
}
Cuando se renderiza, los cuatro vértices del quad se computan en el vertex shader; el efecto se propaga a los píxeles intermedios por interpolación (gratis, en hardware). Por eso se llama "deformación procedural": sin tocar la geometría real, los píxeles se ven movidos.
Por qué importa: un vertex shader para una bandera 2D es perfecto, pero también lo es para una barra de vida ondulada, un texto que se "deshace" al recibir daño, o un menú que rebota. Cualquier cosa que se pueda modelar con "los píxeles se mueven según una función del espacio y el tiempo" es candidata a un vertex shader.
FullscreenMaterialA veces el efecto que querés no es por sprite, sino sobre toda la pantalla: viñeta, aberración cromática, pixelado retro, screen-shake. Para esto existe el trait FullscreenMaterial (disponible en Bevy desde las versiones 0.13/0.14, path bevy::core_pipeline::fullscreen_material). Un cap. entero (14F) lo cubre en detalle, pero veamos la pinta:
//! cap-14E — sección 14E.6: fullscreen shader para viñeta.
use bevy::prelude::*;
use bevy::core_pipeline::fullscreen_material::{
FullscreenMaterial, FullscreenMaterialPlugin,
};
use bevy::render::render_resource::ShaderRef;
#[derive(Asset, TypePath, AsBindGroup, Default, Debug, Clone)]
struct VignetteMaterial {
#[uniform(0)]
intensity: f32,
}
impl FullscreenMaterial for VignetteMaterial {
fn fragment_shader() -> ShaderRef {
"shaders/vignette.wgsl".into()
}
}
// El plugin va con el material.
.add_plugins(FullscreenMaterialPlugin::<VignetteMaterial>::default())
El shader:
//! cap-14E — sección 14E.6: viñeta fullscreen.
#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;
@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let uv = in.uv;
let dist = distance(uv, vec2<f32>(0.5, 0.5));
let vignette = smoothstep(0.7, 0.3, dist * intensity);
let color = textureSample(screen, screen_sampler, uv);
return vec4<f32>(color.rgb * vignette, color.a);
}
Cosas a notar:
MATERIAL_BIND_GROUP. El FullscreenMaterial usa un sistema de bindings distinto.screen no la pasás vos. Bevy la conecta automáticamente al frame previo. Por eso podés samplear "lo que se acaba de renderizar".FullscreenVertexOutput viene del import. Es un struct distinto a VertexOutput porque no hay vértices de mesh: la pantalla completa es un quad implícito.Para una panorámica completa del post-processing y los pases de Bevy, mirá el cap. 14F. Lo importante aquí es que existe y que es un Material2d-like con un trait dedicado.
Un compute shader no dibuja nada. Corre en paralelo para hacer cálculos: simulaciones masivas, IA de miles de unidades, generación procedural de mapas, partículas GPU. Bevy 0.18/0.19 expone los RenderDevice y los compute pipelines de wgpu, así que se puede usar, pero la API no es de "primer nivel": para algo simple, mejor un crate como bevy_easy_compute; para algo serio, implementar el pipeline a mano.
Caso típico: 100.000 partículas de "falling sand" que caen por gravedad y se apilan. En CPU, sería 100.000 entidades con sus sistemas de física: 30 fps lamentables. En compute, una textura de 320×320, un compute shader que actualiza un píxel por frame, y listo.
El cap. 22B entra en partículas con bevy_hanabi (que internamente usa compute). Para una introducción low-level al compute pipeline en Bevy, mirá el ejemplo oficial compute_shader_game_of_life. Lo importante por ahora: sabé que existe y que para casos masivos es la respuesta.
Caveat: el soporte de compute en WebGPU/WASM es parcial. Algunos devices móviles no lo soportan. Si tu juego es web, validar antes de comprometerse.
Bevy batcha sprites automáticamente cuando comparten material y textura. Es el cap. 26, en acción. Cuando usás un Material2d custom, perdiste el batching automático para esos sprites. 200 sprites con un material custom = 200 draw calls. Eso puede tumbar la CPU.
¿Cómo mitigarlo?
materials.add(MiMaterial { ... }) una vez, y mat_handle.clone() para cada sprite. Bevy batcha por handle, no por struct.MaterialExtension solo si estás en pipeline 3D PBR (con Mesh3d + StandardMaterial). Heredás el batching y solo añadís tu capa. No aplica a sprites 2D puros. Ver 14E.9.Mesh2d con un material compartido y los datos únicos como vertex attributes. Esto es más avanzado; mirá el ejemplo oficial custom-gltf-vertex-attribute-2d de Bevy.Analogía: el batching es como un camión que entrega paquetes. Si todos los paquetes van al mismo destino (mismo material), un camión. Si cada uno va a uno distinto (material distinto por sprite), 200 mensajeros en moto. Caro.
MaterialExtension: extender sin reemplazar (solo pipeline 3D PBR)Aviso importante: MaterialExtension es un trait del pipeline 3D PBR de Bevy (bevy::pbr). NO aplica a sprites 2D puros. Lo incluimos aquí porque muchos juegos "2D" en realidad usan Mesh2d con StandardMaterial (escenas híbridas), y en ese contexto MaterialExtension sí es útil. Pero si trabajás con Sprite puro o con Material2d (cap. 14E.2), este apartado no te aplica — usá Material2d con shader custom.
El 80% de los efectos que un juego 2D quiere (resplandor, color shift, cuantización de paleta, rim light) podrían ser extensiones del StandardMaterial de Bevy, no materiales nuevos. Con MaterialExtension tomás el material existente y le añadís un fragmento de shader custom.
//! cap-14E — sección 14E.9: extensión que cuantiza la paleta.
// ⚠️ SOLO PARA 3D PBR. NO aplica a Material2d ni a sprites 2D puros.
use bevy::prelude::*;
use bevy::pbr::{MaterialExtension, MaterialExtensionPipeline};
use bevy::render::render_resource::{AsBindGroup, ShaderRef};
use bevy::reflect::TypePath;
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone)]
struct QuantizeExtension {
#[uniform(100)] // offset alto para no chocar con StandardMaterial.
steps: u32,
}
impl MaterialExtension for QuantizeExtension {
fn fragment_shader() -> ShaderRef {
"shaders/quantize.wgsl".into()
}
}
// En el setup (mesh 3D, NO mesh 2D):
// commands.spawn((
// Mesh3d(...),
// MeshMaterial3d(materials.add(StandardMaterial { base_color: ..., ..default() })),
// QuantizeExtension { steps: 4 },
// ));
Y el shader:
//! cap-14E — sección 14E.9: cuantización de color (solo 3D PBR).
#import bevy_pbr::forward_io::VertexOutput
@fragment
fn fragment(
in: VertexOutput,
@builtin(front_facing) is_front: bool,
) {
// Aplicar lighting estándar.
var pbr = pbr_input_from_standard_material(in, is_front);
out.color = apply_pbr_lighting(pbr);
// Cuantizar.
out.color.rgb = floor(out.color.rgb * f32(steps)) / f32(steps);
}
La ventaja: el batching del StandardMaterial se mantiene. La desventaja: solo aplica a meshes 3D por ahora (el MaterialExtension está pensado para el pipeline PBR 3D). Para 2D puro con sprites, hoy por hoy usás Material2d con su shader custom y aceptás el trade-off del batching.
Caveat verificado a 0.19:
MaterialExtensiones del pipeline PBR 3D. NO es un path oficial para 2D puro todavía. La comunidad lo pide, y se espera que evolucione. Si tu juego es 2D puro,Material2dcon shader custom sigue siendo la opción correcta.
El cap. 14 menciona hot-reload de assets. Para los shaders es crítico: la diferencia entre un día de trabajo y una semana. El flujo:
//! cap-14E — sección 14E.10: Cargo.toml para hot-reload de shaders.
[dependencies]
bevy = { version = "0.18", features = ["2d", "filesystem_watcher"] }
//! cap-14E — sección 14E.10: activando el watch de assets.
.add_plugins(DefaultPlugins.set(AssetPlugin {
watch_for_changes: true,
..default()
}))
Tres pasos para el loop virtuoso:
filesystem_watcher y ponés watch_for_changes: true..wgsl, guardás. Bevy lo recompila en milisegundos.Limitaciones:
#[uniform(0)]), hay que reiniciar (el layout del bind group cambió)./ vs \ pueden dar problemas con #import (issue #10500, resuelto en 0.12 pero con matices). Usá siempre / en los paths de WGSL.Escribir un shader que parece correcto y no ver nada es el peor debug de la historia. Tres herramientas:
bevy/trace_tracy y abrís la GUI de Tracy. Ves en qué pase se fue el tiempo (vertex, fragment, post-process). No te dice "qué píxel está mal", pero te dice "el pase de post-process come 12 ms".naga (built-in en Bevy): el compilador de WGSL. Si tu shader tiene un error de sintaxis, naga lo reporta en la consola. Si el shader compila pero produce algo raro, lo más rápido es escribir un shader mínimo que solo devuelva un color y verificar que ese color llega. Si sí, tu shader anterior tenía mal la lógica. Si no, el problema está en el binding.Consejo práctico: cuando un shader se porta mal, hacé "shader minimal debugging": cambiá el fragment a
return vec4(1.0, 0.0, 0.0, 1.0);(rojo plano). Si ves rojo, tu pipeline funciona y el problema es el shader. Si no ves rojo, el problema es el binding o el material.
Shader: programa que corre en la GPU. Tipos: vertex, fragment, compute.
WGSL: lenguaje de shading estándar de WebGPU. Lo que Bevy usa.
wgpu: librería de Rust que abstrae Vulkan/Metal/DX12/WebGPU. Bevy renderiza a través de ella.
GLSL / HLSL: lenguajes de shader más viejos (OpenGL / DirectX). Bevy NO los usa directamente.
SPIR-V: binario intermedio. Es lo que se compila el WGSL o el GLSL.
@vertex: atributo WGSL que marca la función de vértices.
@fragment: atributo WGSL que marca la función de fragmentos (píxeles).
@compute: atributo WGSL para cómputo sin render.
Material2d (trait, NO componente): trait de Bevy para definir un material 2D custom con shader. El componente que se inserta en la entidad es `MeshMaterial2d<T>`.
AsBindGroup: derive macro que traduce los campos de un struct a bindings de WGSL.
ShaderRef: enum que apunta a un shader embebido o a un archivo `.wgsl`.
Binding group: "sobre" numerado donde se ponen los recursos (uniforms, texturas).
@binding(N): dentro de un @group, indica el binding específico.
Uniform: dato que se pasa CPU→GPU, igual para todos los píxeles/vértices de un draw.
Texture: imagen en GPU. Se accede con un sampler.
Sampler: cómo se lee una textura (linear, nearest, con repeat, con clamp).
FullscreenMaterial: trait para post-processing custom (path `bevy::core_pipeline::fullscreen_material`). Disponible desde Bevy 0.13/0.14 (NO es nuevo de 0.18).
MaterialExtension: trait del pipeline 3D PBR (`bevy::pbr`) para añadir lógica custom a un `StandardMaterial` sin reescribirlo. NO aplica a sprites 2D puros.
Batching: agrupar sprites con mismo material en un solo draw call. Lo pierdes con Material2d custom.
Hot-reload: recargar un asset sin reiniciar el juego. Shaders lo soportan.
RenderDoc / Nsight / Xcode GPU: herramientas de captura de frame para debug de shaders.
Tracy: profiler de Rust/Bevy. Mide tiempo por pase.
Quake (id Software, 1996) — uno de los primeros juegos en tener un lenguaje de shading propio (el "QuakeC" era para scripts, pero sus efectos de luz usaban shaders pre-compilados en Assembler). Antes de Quake, los efectos visuales se hacían con "blitter" en CPU.
ShaderToy (Inigo Quilez, 2009) — sitio web donde la gente escribe shaders en GLSL y los ejecuta en el navegador. El espíritu de "un shader hace maravillas con matemática" se lo debemos a ShaderToy. Hoy, WGSL tiene equivalentes.
WebGPU (W3C, 2022) — estándar que unifica la programación de GPU en la web. Define WGSL. Bevy 0.18+ soporta WebGPU como backend (junto a Vulkan, Metal, DX12).
FNA / Fez (Polytron, 2012) — la estética de Fez se logró con un solo shader de fragmento que deformaba el mundo según la profundidad. Un vertex shader 2D bien hecho puede hacer magia con 30 líneas.
Bevy `Material2d` (0.15+) — la API de materiales 2D de Bevy se unificó en 0.15. Antes había `MaterialMesh2dBundle` con su propio trait. La nueva API es lo que usamos en este capítulo.
rust-gpu (Embark Studios, 2020) — proyecto de escribir shaders en Rust. Compilador de Rust a SPIR-V. Aún experimental. `bevy-rust-gpu` lo integra con Bevy. A fecha de esta edición, no se recomienda para producción.
❌ Olvidar `Material2dPlugin::<MiMaterial>::default()` en el `add_plugins`.
✅ Agregar el plugin junto con `DefaultPlugins`.
💡 Por qué: sin el plugin, Bevy no sabe cómo renderizar tu material. El juego compila, no da error, pero el sprite es invisible.
❌ `#[uniform(0)] color: Vec3` (3 floats) y `#[uniform(0)] other: f32` (otro binding 0).
✅ Cada `#[uniform(N)]` agrupa campos en un **mismo buffer**; usar slots distintos si son grupos distintos.
💡 Por qué: WGSL requiere que el layout del uniform buffer en Rust coincida con el `var<uniform>` en el shader. Dos campos con binding 0 van al mismo buffer; usá `@align(16)` si necesitás padding.
❌ Devolver `vec4<f32>` en el fragment con valores > 1.0 y "no se ve más brillante".
✅ En sRGB, los valores > 1.0 no se ven en la mayoría de monitores sin HDR. Usar HDR / tonemap si querés brillos.
💡 Por qué: el framebuffer típico es sRGB sin HDR. Para brillos, necesitás un pipeline HDR + tonemap (cap. 16, 14F).
❌ Cargar 200 sprites con 200 `Material2d` distintos y quejarse de que va a 5 fps.
✅ Reutilizar el mismo material (`handle.clone()`) entre sprites; o usar `MaterialExtension` cuando puedas.
💡 Por qué: cada `Material2d` distinto es un draw call. 200 draw calls de 1 sprite cada uno = CPU muerta. El batching automático del cap. 26 no aplica a `Material2d` custom.
Problema: querés añadir un efecto visual a tu juego. La tentación es escribir un shader enorme que lo haga todo. El resultado: un shader que tarda 6 ms en compilar, 4 ms en ejecutar, y nadie más lo entiende.
Solución: tratá el shader como un amplificador de algo que ya existe, no como un sustituto. Si querés "el fuego de la espada brilla", no escribas un shader de fuego procedural. Escribí un sprite de fuego, ponelo en una entidad, y dejá que el shader lo único que haga sea teñirlo / hacer glow / modular su intensidad.
┌──────────────────────────────────────────┐
│ "Quiero que la pantalla se vea rara" │
│ ↓ │
│ ¿Es un efecto por sprite? → Material2d │
│ (Custom Material + shader por sprite) │
│ ↓ │
│ ¿Es un efecto sobre la pantalla? │
│ → FullscreenMaterial (cap. 14F) │
│ ↓ │
│ ¿Es un cálculo masivo sin dibujar? │
│ → compute shader (cap. 22B) │
└──────────────────────────────────────────┘
Cuándo sí:
Cuándo no:
@vertex, @fragment, @compute.Material2d + AsBindGroup + ShaderRef: el trío para un material custom.FullscreenMaterial para post-processing (intro; cap. 14F lo cubre).MaterialExtension para no reinventar StandardMaterial.Capítulo 14F: el post-processing en serio. Vamos a ver FullscreenMaterial a fondo, dónde se inserta en el render graph, cómo hacer viñeta, aberración cromática, pixelado retro, y screen-shake. Spoiler: si tu juego se ve "cinematográfico", el 80% de eso es post-process, no sprites.