— Anónimo, en una sala de chat de Romhacking.net, 2009.
Antes de escribir un solo Sprite, conviene entender qué pasa entre que tu sistema suelta una posición y el píxel aparece en pantalla. Bevy 0.18 reorganizó el render de arriba abajo y, en 0.19, el cambio más visible es que el RenderTarget dejó de ser un dato escondido del plugin de cámara para ser un componente más, observable, modificable y testeable. Bien.
Piensa en el render 2D como una línea de montaje de camisetas. Llega una camiseta blanca (el framebuffer, la memoria de vídeo), un operario le estampa un sprite, el siguiente le estampa otro más arriba, el siguiente aplica un filtro de luz, y al final del pasillo sale la camiseta terminada hacia el monitor. Si los operarios no se coordinan, te encuentras una camiseta estampada al revés; si lo hacen bien, tienes un juego que se ve bonito.
[Cámara] → [extrae View + Projection] → [culling por visibilidad]
→ [orden por Z y luego por Y si querés "depth-y"]
→ [batch por material/textura] → [dibuja quads]
→ [pasa por post-process si hay] → [framebuffer final]
Cada una de esas fases vive en un node del render graph de Bevy (bevy_render::render_graph). Tú no tocas esos nodos en el 99 % de los casos, pero entender el orden te salva cuando algo "no se pinta donde debe".
Lo que sí tocas todo el tiempo son tres componentes:
Sprite — el rectángulo visual con su textura y su rect de recorte.Transform — la posición, rotación y escala locales.GlobalTransform — la transformación acumulada con la del padre (vive en el padre si la entidad es hija de otra).Transform es lo que tú escribes; GlobalTransform lo calcula Bevy durante el PostUpdate propagando jerarquías. No lo modifiques a mano: el sistema propagate_transforms se encarga y cualquier escritura tuya será pisada al final del frame.
Bevy se compila con feature flags (banderas opcionales que el usuario activa en el Cargo.toml). El error más caro de un novato es añadir bevy = "0.18" a secas y llorar cuando ve el binario de 200 MB. La regla práctica: tu Cargo.toml debería parecerse al del snippet 14.1, no al desastre de la metedura de pata 14.A.
//! cap-14 — sección 14.2: Cargo.toml minimalista para un juego 2D.
[package]
name = "mi_juego_2d"
version = "0.1.0"
edition = "2021"
[dependencies]
bevy = { version = "0.18", features = ["2d"] } # ← esto es lo ÚNICO que cambia.
# si necesitás tilemaps, después agregás "bevy_ecs_tilemap".
# si necesitás un plugin de física, después agregás "avian2d" como crate aparte.
La feature 2d activa bevy_sprite, bevy_camera_2d y el plugin ImagePlugin con sampler por defecto lineal. No activa física, ni audio, ni UI avanzada, ni tilemaps. Esto es un manifiesto: cada KB extra de binario debe ganarse su lugar. (Nota: el submódulo bevy_sprite_render dejó de exponerse como crate separado; hoy todo vive en bevy::sprite.)
Analogía: el Cargo.toml es la lista de la compra. Si vas a hacer espaguetis, no compres langostinos. Si vas a hacer un platformer en 2D, no traigas 3d ni bevy_pbr.
Sprite e Image: el dato y la texturaUn Sprite no es una textura. Es un contrato: "dibuja este rectángulo (en píxeles de la imagen) en mi Transform, con esta textura (Handle<Image>), este color de tinte, este flip horizontal/vertical, y este modo de anclaje (anchor)". Esa separación entre el componente lógico (Sprite) y el asset (Image) es lo que permite que dos sprites distintos compartan la misma textura y aun así se comporten de forma independiente.
//! cap-14 — sección 14.3: spawnear un sprite "a mano" (sin atlas).
use bevy::prelude::*;
fn spawn_player(
mut commands: Commands,
asset_server: Res<AssetServer>,
) {
commands.spawn((
Sprite::from_image(asset_server.load("player.png")),
Transform::from_xyz(0.0, 0.0, 1.0), // Z=1: al frente del fondo.
));
}
Sprite::from_image (0.18) reemplaza al viejo Sprite { image, .. }. La intención es que el tipo te empuje hacia el caso común. Si necesitás flags más raras (anchor, custom_size, rect), usás Sprite { image, custom_size, color, .. }.
Image es el asset en sí: un Handle<Image> vive en el Assets<Image> global, y el ImagePlugin lo carga del disco (vía AssetServer) o lo crea a mano. En producción probablemente lo creés con un loader de png o svg.
La metedura de pata 14.B (más abajo) muestra el error clásico: olvidarse del anchor y que el sprite "salga corrido" del cursor.
TextureAtlasLayout y TextureAtlas: muchos sprites, una sola imagenUna texture atlas (o spritesheet) es una sola imagen que contiene muchos subframes. ¿Por qué? Porque la GPU prefiere recibir una textura grande a doscientas pequeñas. Cada cambio de textura es un bind (asignación de recurso gráfico a un shader), y cada bind cuesta. Si metés 200 sprites con la misma imagen, Bevy los batcha (los dibuja juntos en una sola orden de dibujo a la GPU) y la CPU respira.
//! cap-14 — sección 14.4: construir un atlas en runtime.
use bevy::prelude::*;
use bevy::image::ImageLoader;
fn spawn_atlas(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
let texture: Handle<Image> = asset_server.load("characters.png");
let layout = TextureAtlasLayout::from_grid(
UVec2::new(32, 32), // tamaño de cada tile en píxeles.
8, // columnas.
4, // filas.
None, // padding entre tiles.
None, // offset.
);
let layout_handle = layouts.add(layout);
// Atlas para el personaje, primer frame.
// Ojo: `TextureAtlas` NO es un componente independiente desde Bevy 0.15.
// Es un *campo* dentro de `Sprite` (campo `texture_atlas`). Se construye
// como un struct literal con `layout` + `index`, y se pasa a `Sprite::from_atlas_image`.
commands.spawn((
Sprite::from_atlas_image(
texture,
TextureAtlas {
layout: layout_handle,
index: 0,
},
),
Transform::from_xyz(0.0, 0.0, 1.0),
));
}
Tres detalles a memorizar:
from_grid calcula el UVec2 por celda. Si tu atlas es de 256×128 y cada tile es 32×32, son 8 columnas y 4 filas. La fórmula está en la firma.index es el número lineal. Fila 0, columna 0 = 0. Fila 0, columna 7 = 7. Fila 1, columna 0 = 8. Si querés matemática fila-columna, recordá: index = fila * columnas + columna.Sprite::from_atlas_image requiere 0.15+. La nueva API toma la Image y un TextureAtlas por valor. Recordá: TextureAtlas ya no es un componente — vive dentro de Sprite.texture_atlas: Option<TextureAtlas> desde 0.15.Para animaciones (saltar, correr, idle) vas a cambiar el index cada N frames. Eso lo veremos en el cap. 15 con tiles, pero aplica igual a un sprite suelto.
Transform y GlobalTransform: dónde estoy y dónde quedoTransform es el dato local de cada entidad: posición (Vec3), rotación (Quat) y escala (Vec3). GlobalTransform es la composición recursiva con el padre. Escribes el primero; Bevy calcula el segundo.
Hay tres reglas que si las olvidás vas a sufrir:
Transform es relativa al padre. Si el padre está en (100, 0) y vos ponés Transform::from_xyz(10, 0, 0), el hijo queda en (110, 0).Transform::from_rotation lo acepta: Quat::from_rotation_z(ángulo_radianes). Un 0.0 significa "sin rotación" (el sprite queda orientado tal cual); un PI/2 en espacio 2D con Y arriba rota el sprite 90° en sentido antihorario (el lado derecho del sprite queda apuntando hacia arriba). Si tu sprite apunta "a la derecha" por defecto y querés que apunte "arriba", usás PI/2; para "a la izquierda", PI; para "abajo", -PI/2 o 3*PI/2.Transform::from_scale con valores distintos por eje (ej. Vec3::new(-1, 1, 1)) sirve para flip horizontal sin escribir un shader. Más barato que cambiar el flip_x del Sprite.//! cap-14 — sección 14.5: tres Transform típicos.
Transform::from_xyz(0.0, 0.0, 0.0) // origen.
Transform::from_xyz(50.0, -20.0, 1.0).with_scale(Vec3::splat(2.5)) // doble de tamaño.
Transform {
translation: Vec3::new(0.0, 0.0, 0.0),
rotation: Quat::from_rotation_z(std::f32::consts::FRAC_PI_4), // 45°.
scale: Vec3::ONE,
}
Si querés mirar a un punto, calculá Quat::from_rotation_arc(forward, dirección_normalizada). Más adelante, cuando tengas steering o un enemigo que sigue al jugador, esta función es la que aparece.
Bevy ordena los sprites para resolver el "qué está delante de qué". Por defecto:
Transform::z gana (más al fondo). Si todo está en Z=0.0, no hay orden claro y todo se ve mal.Tres estrategias para ordenar capas en un juego 2D real:
| Estrategia | Ventaja | Costo |
|---|---|---|
| Z manual (Z=0 fondo, Z=1 mid, Z=2 player, Z=3 HUD) | Simple, determinista | Olvidaste un Z=0.5 y el enemigo quedó detrás de un arbusto |
| Y-sort (orden por Y del pivote) | Plataformas tipo Stardew | Necesita Y-sort plugin y más queries |
| Layer cake (un render layer por capa conceptual) | Aísla el HUD del mundo | Requiere organizar cámaras y RenderLayers por capa |
El cap. 16 usa RenderLayers para aislar la capa de luces. En 2D plano, basta con Z manual y la convención del snippet 14.6.1.
//! cap-14 — sección 14.6.1: convención de Z para plataformas.
const Z_FONDO: f32 = 0.0; // decorado lejano.
const Z_SUELO: f32 = 1.0; // suelo y paredes.
const Z_OBJETOS: f32 = 2.0; // cajas, monedas.
const Z_PLAYER: f32 = 3.0; // el personaje.
const Z_HUD: f32 = 10.0; // UI (aunque la UI va en otro sistema).
Si querés un background con parallax, en realidad estás componiendo varias entidades con Z distintos y moviéndolas a velocidades distintas. Nada de magia: solo matemáticas.
Mini-ejercicio: escribí un sistema que mueva
fondo.z=0a la mitad de la velocidad del player. Es lo que se llama parallax 1D. Lo necesitás en cualquier platformer con "profundidad" visual.
Camera2d como entidad: el ojo que mira el mundoLa cámara es una entidad con el componente Camera2d. Desde Bevy 0.15 (con los required components, cap. 8), Camera2d arrastra automáticamente una Projection::Orthographic configurada por OrthographicProjection::default_2d(), además de Camera, GlobalTransform, Frustum, etc. Por lo tanto, para la cámara 2D mínima basta con spawnear Camera2d y dejar que Bevy haga el resto:
//! cap-14 — sección 14.7: cámara 2D mínima.
fn setup_camera(mut commands: Commands) {
// Esto solo: Camera2d trae, vía required components,
// la Projection ortográfica por defecto + el resto del bundle.
commands.spawn(Camera2d);
}
Solo si querés override (ej. 1 metro = 32 píxeles, o un viewport cuadrado), escribís la Projection a mano. Para cambiar el scaling, usás el campo scaling_mode con uno de los ScalingMode válidos:
//! cap-14 — sección 14.7: cámara 2D con escalado explícito.
use bevy::camera::{OrthographicProjection, Projection, ScalingMode};
fn setup_camera_32px(mut commands: Commands) {
// Ejemplo: tile de 32×32 px == 1 unidad de mundo.
// `ScalingMode::Fixed { width, height }` fija la resolución lógica.
commands.spawn((
Camera2d,
Projection::Orthographic(OrthographicProjection {
scaling_mode: ScalingMode::Fixed { width: 800.0 / 32.0, height: 600.0 / 32.0 },
..OrthographicProjection::default_2d()
}),
));
}
Cuidado con la API vieja:
OrthographicProjection::with_scaling(1.0 / 32.0)NO existe. El métodowith_scalingrecibe unScalingMode(enum), no unf32. Y el campoOrthographicProjection::scalese eliminó en 0.15 (PR #15075). Las variantes válidas deScalingModeson:Fixed,WindowSize,AutoMin,AutoMax,FixedVertical,FixedHorizontal.
Una cámara que sigue al player es de las primeras cosas que escribís en cualquier juego:
//! cap-14 — sección 14.7: cámara que sigue al player con smoothing.
fn follow_player(
time: Res<Time>,
player: Query<&Transform, With<Player>>,
mut camera: Query<&mut Transform, (Without<Player>, With<Camera2d>)>,
) {
let Ok(player_xform) = player.single() else { return; };
let Ok(mut cam_xform) = camera.single_mut() else { return; };
let target = player_xform.translation;
let k = 5.0; // velocidad del lerp.
cam_xform.translation = cam_xform.translation
.lerp(target, (time.delta_secs() * k).min(1.0));
}
El .lerp(target, alpha) es lineal: para un ease-out suave cambiá a smoothstep o Vec3::lerp con un factor exponencial. La versión production-grade usa exponential_decay o un spring damper (modelo resorte-amortiguador), pero con un lerp de factor 5 * dt se siente bien al 99 % de los jugadores.
Truco: si querés que la cámara "mire siempre un poco adelante de la dirección de movimiento", guardás la velocidad del player en un componente
Velocityy le sumásvel.normalize() 30.0al target. Es Celeste* puro.
RenderTarget como componente separado (0.18+)Hasta 0.17, decidir si una cámara renderizaba a pantalla o a una textura era cuestión de métodos en el plugin. En 0.18, es un componente: RenderTarget. En 0.19, además, lo conectaron a required components (cap. 8), así que Camera2d lo trae automáticamente.
Analogía: el RenderTarget es como el sobre de un envío postal. La carta es la cámara (con sus parámetros); el sobre dice "esto va a la pantalla" o "esto va a un buzón interno".
//! cap-14 — sección 14.8: render a una textura (offscreen) en 0.18.
use bevy::camera::{OrthographicProjection, Projection, ScalingMode};
use bevy::render::render_resource::TextureDescriptor;
use bevy::render::texture::ImageFormat;
fn setup_minimap_camera(
mut commands: Commands,
mut images: ResMut<Assets<Image>>,
) {
let minimap_image = Image {
texture_descriptor: TextureDescriptor {
label: Some("minimap"),
size: Extent3d { width: 256, height: 256, depth_or_array_layers: 1 },
dimension: TextureDimension::D2,
format: ImageFormat::Rgba8UnormSrgb,
mip_level_count: 1,
sample_count: 1,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
..default()
};
let minimap_handle = images.add(minimap_image);
commands.spawn((
Camera2d,
Projection::Orthographic(OrthographicProjection {
// Zoom del minimapa: media ventana visible.
scaling_mode: ScalingMode::WindowSize(2.0), // 2 = el contenido se ve a la mitad.
..OrthographicProjection::default_2d()
}),
RenderTarget::from(minimap_handle),
));
}
¿Para qué querés renderizar a una textura? Tres casos típicos:
Image de 256×256 que después mostrás en la esquina de la pantalla.La metedura 14.C (al final del capítulo) muestra el error típico: olvidar el flag TEXTURE_BINDING y que la textura salga negra porque nadie puede samplearla.
RenderLayersLos RenderLayers existen en Bevy desde hace muchas versiones (desde 0.6), pero a menudo pasan desapercibidos: cada entidad y cada cámara tiene un layer id (un entero de 32 bits, así que tenés 32 capas distintas). Si el layer mask (máscara binaria de capas activas) del objeto intersecta con el de la cámara, se ve; si no, se ignora.
//! cap-14 — sección 14.9: aislar el HUD en su propio layer.
use bevy::camera::visibility::RenderLayers;
const LAYER_WORLD: u8 = 0;
const LAYER_HUD: u8 = 1;
fn setup(mut commands: Commands) {
commands.spawn((
Camera2d,
RenderLayers::from_layers(&[LAYER_WORLD, LAYER_HUD]), // ve ambas.
));
}
fn spawn_hud(mut commands: Commands) {
commands.spawn((
Sprite::from_image(/* tu HUD */),
Transform::from_xyz(0.0, 0.0, 100.0),
RenderLayers::layer(LAYER_HUD), // solo la cámara del HUD.
));
}
Esto resuelve tres problemas clásicos:
bloom y chromatic aberration.LAYER_HUD y desaparecen del mundo, pero siguen "existiendo" para un query.La regla práctica: si en tu juego tenés más de dos "profundidades" (mundo, hud, cinemática), usá RenderLayers desde el día uno. Vas a ahorrarte meses de rehacer jerarquías.
Este es el apartado que nadie lee hasta que un día la pantalla queda demasiado oscura. Bevy 0.18 migró la convención por defecto a Srgb para el color de Sprite (el tinte), pero el framebuffer se considera lineal en operaciones internas (mezclas, post-process, iluminación). ¿Por qué? Porque la matemática de luz es lineal, y si la hacés en sRGB terminás con bandas visibles en los degradés.
Reglas duras:
Rgba8UnormSrgb y el shader los "lineariza" al leerlos.Color::srgb(0.5, 0.5, 0.5)): en 0.18 con la nueva convención, podés escribir Color::srgb(0.5, 0.5, 0.5) y Bevy lo interpreta como sRGB. Para colores lineales puros usás Color::linear_rgb(...).Color::srgb(1.0, 0.9, 0.7), al multiplicar por la textura en lineal te queda un resultado más oscuro de lo que esperás si venías de Unity. La regla: bajá la luz un poco (srgb(0.7, 0.65, 0.55)) para compensar.Analogía: sRGB es el "idioma del monitor", y lineal es el "idioma de las matemáticas". Bevy traduce entre los dos automáticamente, pero si sabés cuándo se traduce y cuándo no, evitás colores lavados o quemados.
Sprite (componente): rectángulo visual con textura, color de tinte, anchor y (desde 0.15) un campo opcional `texture_atlas`.
Image (asset): textura GPU, `Handle<Image>` + descriptor de formato.
TextureAtlas (struct, NO componente desde 0.15): referencia a un `Handle<TextureAtlasLayout>` + índice del frame actual. Vive dentro de `Sprite.texture_atlas`.
TextureAtlasLayout (asset): estructura con el grid (cols, filas, tamaño).
Anchor (concepto): punto del sprite que coincide con su `Transform` (centro, top-left, etc.).
Camera2d (componente): marcador que define una cámara ortográfica 2D. Por required components inserta `Projection::Orthographic`, `Camera`, `GlobalTransform`, etc.
RenderTarget (componente): a dónde "entrega" la imagen la cámara (pantalla o textura).
RenderLayers (componente): máscara de capas que decide qué ve cada cámara.
Render graph (concepto): DAG (grafo acíclico dirigido) de nodos de render conectados.
Batch / batching (técnica): agrupar sprites con mismo material en un solo draw call.
Cull / culling (técnica): descartar entidades fuera de la cámara antes de dibujarlas.
Parallax (técnica): capas que se mueven a distintas velocidades para simular profundidad.
Linear RGB (espacio de color): espacio donde la luz se mezcla de forma matemáticamente correcta.
sRGB (espacio de color): espacio "humano" que usa el monitor; gamma-encoded.
Tone mapping (técnica): compresión del rango HDR (alto rango dinámico) a LDR (rango bajo) en el último pass.
Super Mario Bros (Nintendo, 1985) — uno de los primeros juegos mainstream con *spritesheet* en una sola imagen. Cada cuadro de Mario era un recorte de 16×32 px; sin atlas, la NES habría muerto de RAM.
Stardew Valley (ConcernedApe, 2016) — toda la escena renderiza en una sola pasada con un *tilemap* de 16×16, pero el efecto de *lighting* diario se logra con un *overlay* por capa (`RenderLayers` puro, hecho a mano en MonoGame).
Hyper Light Drifter (Heart Machine, 2016) — la cámara sigue con un *spring damper* casero; nada de cinemática. Un *lerp* simple (cap. 14.7) habría sido demasiado rígido para su estética de "personaje herido".
Inside (Playdead, 2016) — todo el *color grading* (recoloración tonal de la imagen) es un *post-process* aplicado a una escena renderizada en lineal. Esa escena es la "la misma" que este capítulo describe, pero con un nodo extra al final.
Braid (Number None, 2008) — su render usaba *render-to-texture* por niveles: cada nivel era una textura pintada y después distorsionada. La idea es la misma que `RenderTarget::from(...)` en 0.18.
❌ Cargo.toml con `bevy = "0.18"` sin features.
✅ `bevy = { version = "0.18", features = ["2d"] }`.
💡 Por qué: sin `2d`, Bevy no incluye `bevy_sprite` ni `bevy_camera_2d`. El binario compila, pero `use bevy::sprite::Sprite` falla con `unresolved import`.
❌ `Sprite::from_image(asset_server.load("player.png"))` con un `Image` de 64×64 y `Transform::from_xyz(mouse_x, mouse_y, 0.0)`. El sprite queda "corrido" del cursor.
✅ Agregá `Anchor::CENTER` o usá `Transform::from_xyz` ajustado a la mitad del sprite.
💡 Por qué: el anchor por defecto es el centroide en 0.18, pero si esperás comportamiento de versiones viejas (esquina top-left), la posición del sprite se desfasará.
❌ `RenderTarget::from(image_handle)` pero el `Image` fue creada con `usage: TextureUsages::RENDER_ATTACHMENT` solamente.
✅ Agregá también `TextureUsages::TEXTURE_BINDING` para que un `Sprite` pueda leerla, o `TextureUsages::COPY_DST` si la vas a actualizar por CPU.
💡 Por qué: el *sampler* de un sprite no se puede enlazar si la textura no permite bind. Resultado: cuadro negro y llanto.
Problema: tu escena crece. Empiezas con player y suelo. Pronto tienes fondo, niebla, HUD, cinemática, minimapa. Si todo vive en el mismo World con Z arbitrarios, terminas con un sistema de capas imposible de razonar y "el HUD se ve raro solo cuando activo el bloom".
Solución: estructura el render como un pastel de capas independientes, cada una con su cámara (si hace falta), su RenderLayers, y opcionalmente su RenderTarget. La cámara "raíz" compone el pastel al final.
┌──────────────────────────────────────┐
│ Capa HUD (LAYER_HUD) │ Z 100, sin post-process.
├──────────────────────────────────────┤
│ Capa Player + efectos (LAYER_WORLD) │ Z 1-3, post-process.
├──────────────────────────────────────┤
│ Capa mundo (LAYER_WORLD) │ Z 0-2, post-process.
└──────────────────────────────────────┘
Cuándo sí:
Cuándo no:
Los RenderLayers (que ya estaban desde 0.6) hacen este patrón trivial. Con la API moderna de cámara basada en required components (0.15+), spawnear varias cámaras con distintos RenderLayers es aún más barato. Usalo en serio.
Cargo.toml con features = ["2d"] para no inflar el binario.Sprite/Image/TextureAtlas como el contrato entre tu lógica y la GPU.Transform (local) vs GlobalTransform (propagado); no pises el global.RenderLayers como tres maneras de ordenar.Camera2d como entidad y lerp simple para que siga al player.RenderTarget como componente en 0.18, ideal para minimapas y composición.Ya tenemos sprites sueltos. Capítulo 15: el caso masivo. Vamos a meter miles de tiles en un mundo, con bevy_ecs_tilemap, chunks para no freír la RAM, animaciones por tile, y la artimaña del autotiling con máscaras de bits. Después de leer ese cap, el "fondo" de tu juego pasa de ser "muchos sprites" a "una matriz con identidad".