Capítulo 14. Renderizado 2D: sprites, atlas, capas y cámara

CAP 14 · Bevy 0.18/0.19
"En un juego 2D todo se reduce a lo mismo: dibujar un cuadrado con una textura, en la posición correcta, en el orden correcto. La parte difícil es que el jugador no lo note."

— Anónimo, en una sala de chat de Romhacking.net, 2009.

14.1 El pipeline de render 2D en Bevy 0.18

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 tocas todo el tiempo son tres componentes:

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.

14.2 Cargo features: empezar pequeño

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.

14.3 Sprite e Image: el dato y la textura

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

14.4 TextureAtlasLayout y TextureAtlas: muchos sprites, una sola imagen

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

  1. 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.
  2. El 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.
  3. El 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.

14.5 Transform y GlobalTransform: dónde estoy y dónde quedo

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

//! 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.

14.6 Orden y capas: Z-order, Y-sort y layer cake

Bevy ordena los sprites para resolver el "qué está delante de qué". Por defecto:

  1. Menor Transform::z gana (más al fondo). Si todo está en Z=0.0, no hay orden claro y todo se ve mal.
  2. En caso de empate, menor Y gana (más arriba en pantalla queda atrás). Esto es el comportamiento sort-y opcional.
  3. En último empate, el orden de inserción define.

Tres estrategias para ordenar capas en un juego 2D real:

EstrategiaVentajaCosto
Z manual (Z=0 fondo, Z=1 mid, Z=2 player, Z=3 HUD)Simple, deterministaOlvidaste un Z=0.5 y el enemigo quedó detrás de un arbusto
Y-sort (orden por Y del pivote)Plataformas tipo StardewNecesita Y-sort plugin y más queries
Layer cake (un render layer por capa conceptual)Aísla el HUD del mundoRequiere 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=0 a la mitad de la velocidad del player. Es lo que se llama parallax 1D. Lo necesitás en cualquier platformer con "profundidad" visual.

14.7 Camera2d como entidad: el ojo que mira el mundo

La 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étodo with_scaling recibe un ScalingMode (enum), no un f32. Y el campo OrthographicProjection::scale se eliminó en 0.15 (PR #15075). Las variantes válidas de ScalingMode son: 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 Velocity y le sumás vel.normalize() 30.0 al target. Es Celeste* puro.

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

  • Minimapa: una segunda cámara apunta al mismo mundo desde arriba, con zoom, y renderiza a un Image de 256×256 que después mostrás en la esquina de la pantalla.
  • Render de un personaje antes del post-process (cap. 16): pintás el personaje aparte para aplicarle blur solo a él.
  • Mirror effect (Geometry Wars, Hyper Light Drifter): un shader muestra el mundo duplicado y con un sample raro.

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.

14.9 Múltiples cámaras y RenderLayers

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

  1. *No querés que el HUD sufra post-process***. La cámara del HUD va a la pantalla sin tocar; la del mundo sí pasa por bloom y chromatic aberration.
  2. Querés ocultar objetos durante cinemáticas. Les cambiás el layer a LAYER_HUD y desaparecen del mundo, pero siguen "existiendo" para un query.
  3. *Querés un minimap*** sin tener que spawnearlo offscreen. Le das a los tiles un layer aparte y la cámara del minimapa los renderiza.

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.

14.10 Color spaces: Linear vs sRGB, y por qué te importa

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:

  • PNG/JPG importados: se asume sRGB. Bevy los marca como Rgba8UnormSrgb y el shader los "lineariza" al leerlos.
  • Colores de código (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(...).
  • Post-process (cap. 16): trabaja en lineal y convierte a sRGB en el último pass (el tonemap o color grading final).
  • Mezcla con luces: si tu luz tiene 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.

14.11 Glosario del capítulo

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.

14.12 Trivia histórica

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.

14.13 Metedura de pata

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

🎯 14.14 Patrón del capítulo: "layer cake" (capas como un pastel)

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

  • Juegos con HUD, cinemáticas, minimapas o distintas condiciones climáticas.
  • Cuando querés aplicar post-process solo a una parte de la pantalla.
  • Equipos con varios programadores: cada uno toca una capa sin pisar al otro.

Cuándo no:

  • Prototipos o game jams (overhead cognitivo).
  • Juegos donde TODO comparte el mismo post-process (un twin-stick puro, por ejemplo).
  • Si tenés una sola capa y ya, no hace falta.

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.

Lo que vimos

  • El pipeline 2D: cámara → culling → batching → framebuffer → post-process.
  • 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.
  • Z-order manual, Y-sort y 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.
  • Color spaces Linear vs sRGB: cuando mezclar a mano, respetá las conversiones.

En el siguiente

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