— Ley de Conway sobre el profiling, paraphrased.
bevy_diagnosticOptimizar a ciegas es como intentar vaciar un pantano con una cuchara: si no sabes dónde está el agujero, no sabes dónde clavar el tapón. Por eso, antes de tocar nada, medimos. La herramienta por excelencia en 2026 para profiling en juegos escritos en Rust es Tracy, un sampler en tiempo real con timeline, anotaciones manuales y captura de frames a 60 Hz. Bevy 0.18 y 0.19 se integran con ella de dos formas complementarias.
La primera forma es automática: si activas el feature trace_tracy en Bevy, el motor empieza a mandar a Tracy zonas (tracy_zone!) cada vez que entra y sale de un schedule, un sistema o un render pass. No tienes que hacer nada en tu código, solo añadir la dependencia:
# Cargo.toml
[dependencies]
bevy = { version = "0.19", features = ["trace_tracy"] }
Y arrancar el binario de Tracy en otra terminal. Verás un gráfico con tres colores: azul para sistemas lógicos, rojo para render, amarillo para GPU. Si el rojo se come el gráfico, el problema es render; si el azul es enorme, es lógica. Antes de optimizar, decide qué franja atacar.
Profiling (técnica): medición sistemática de tiempos, frecuencias y cuellos de botella de un programa en ejecución.
Tracy (herramienta): profiler externo en C++ con GUI que recibe trazas vía red (TCP) o archivo; muy usado en gamedev Rust/C++.
Sampler (técnica): captura periódica del estado del programa (callstack, registros) para reconstruir qué se ejecuta en cada instante.
Frame time (métrica): milisegundos que tarda un frame completo; objetivo 16.6 ms (60 FPS) o 8.3 ms (120 FPS).
CPU-bound (estado):瓶颈 del programa está en la CPU (lógica, IA, física); síntoma: GPU ociosa.
GPU-bound (estado): cuello de botella en la tarjeta gráfica; síntoma: CPU esperando vsync.
La segunda forma es manual: anotas zonas concretas en tu código con la macro tracing::instrument o con spans explícitos de Bevy. Es útil cuando un sistema grande oculta un trabajo caro. Por ejemplo, en un sistema de pathfinding A*:
//! cap-26 — sección 26.1: instrumentar A* con tracing.
use bevy::prelude::*;
use tracing::instrument;
#[instrument(skip_all, fields(open_set = tracing::field::Empty))]
fn pathfinding_system(mut query: Query<(&mut Path, &Position)>) {
// ... tu código ...
// tracing::Span::current().record("open_set", &open_set_len); // opcional.
}
Eso le dice a Tracy "este sistema se llama pathfinding_system y ha procesado N nodos abiertos". Útil cuando un sistema cuesta 0.3 ms y quieres saber qué parte: la apertura del heap, el cómputo de la heurística o la reconstrucción del camino.
❌ Confiar solo en `print!("frame took {:?}", start.elapsed())`.
Imprime en stdout, satura el I/O, y a 60 FPS contaminas la medición.
✅ Usar `info_span!` o `tracing::instrument` y dejar que Tracy agregue.
Las anotaciones van por canal aparte (no interfieren con stdout) y se grafican.
💡 Por qué: en hot loops, un println puede costar más que el sistema que intentas medir.
bevy_diagnostic es el complemento oficial: expone un recurso DiagnosticsStore con métricas automáticas (FPS, frame time, ECS entity count, memoria de assets) que puedes leer desde código o mostrar en un HUD. Se activa con el plugin FrameTimeDiagnosticsPlugin y LogDiagnosticsPlugin:
//! cap-26 — sección 26.1: HUD mínimo con FPS.
use bevy::diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy::prelude::*;
fn setup(app: &mut App) {
app.add_plugins(FrameTimeDiagnosticsPlugin);
}
fn show_fps(diagnostics: Res<DiagnosticsStore>) {
if let Some(fps) = diagnostics.get(&FrameTimeDiagnosticsPlugin::FPS) {
let value = fps.smoothed().unwrap_or(0.0);
// ... renderizar `value` en pantalla ...
}
}
El enemigo público número uno de un juego 2D Bevy es el número de draw calls (una orden de dibujo que la CPU envía a la GPU por cada material/mesh distinto). Cada draw call cuesta: cambio de estado de la pipeline, bind groups, subida de uniforms. 500 sprites sueltos = 500 draw calls = CPU saturada aunque la GPU esté ociosa. La solución estándar en Bevy es agrupar sprites en un atlas (una imagen grande con muchos frames) y dejar que el motor haga batching automático.
Un atlas se define con TextureAtlasLayout y se aplica con TextureAtlas en el componente Sprite:
//! cap-26 — sección 26.2: crear atlas desde un spritesheet.
use bevy::prelude::*;
use bevy::asset::RenderAssetUsages;
fn spawn_enemies(
mut commands: Commands,
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
mut images: ResMut<Assets<Image>>,
) {
let image = images.load("enemies.png"); // spritesheet 8x4 frames.
let layout = TextureAtlasLayout::from_grid(UVec2::new(32, 32), 8, 4, None, None);
let handle = layouts.add(layout);
for i in 0..32 {
let sprite = Sprite::from_atlas_image(
image.clone(),
TextureAtlas { layout: handle.clone(), index: i },
);
commands.spawn((sprite, Transform::from_xyz(i as f32 * 40.0, 0.0, 0.0)));
}
}
La clave está en el RenderAssetUsages (qué uso se le da al asset: GPU, CPU, persistente) y en que todos los sprites comparten el mismo handle de material (MeshMaterial2d<T>, donde T implementa el trait Material2d) o, en el caso de sprites sin material custom, el mismo "atlas handle". Bevy, al ver que el material y la textura son iguales, los agrupa en una sola draw call. Ese es el batching automático: zero-config cuando estructuras bien los assets.
Draw call (render): orden de la CPU a la GPU para dibujar un mesh con un material; cara si hay muchas.
Atlas / spritesheet (asset): imagen grande con N frames empaquetados; clave para batching.
Batching (técnica): agrupar varias entidades con el mismo material/textura en una sola draw call.
Material2d (trait): trait de bevy::sprite que define el shader y los parámetros de un material 2D. NO es un componente.
MeshMaterial2d<T> (componente): el componente real que se inserta en la entidad para usar un material T: Material2d.
RenderAssetUsages (API): hint al cargador de assets sobre dónde residirá el asset (GPU, CPU, ambas).
Pero el batching tiene dos enemigos:
Handle<Image> por sprite. Si cada entidad tiene su propia Image, el batcher no puede fusionar.TextureAtlas por sprite. Cada layout crea un batch separado.Regla de oro: un atlas por "familia visual" (personajes, enemigos, props, tiles) y reusar el Handle<TextureAtlasLayout> en todas las entidades de esa familia.
❌ Cargar 64 imágenes individuales `enemy_01.png`, `enemy_02.png`, ..., `enemy_64.png`.
El batching muere; cada enemigo es un draw call.
✅ Empaquetar las 64 frames en `enemies.png` (512×512) y un solo `TextureAtlasLayout::from_grid(...)`.
Bevy fusiona y pasas de 64 draw calls a 1.
💡 Por qué: los cambios de estado de pipeline son O(estado), no O(píxeles); lo que cuesta es la transición.
Otro consejo: ordena por material en tus queries cuando puedas. Bevy re-ordena automáticamente al final del frame, pero si tu sistema ya entrega entidades ordenadas, evitas reordenaciones internas. Un Query<&Sprite, With<Enemy>> con .sort_unstable_by_key() por el handle de la imagen puede ahorrar microsegundos.
Y recuerda: Visibility y VisibilityRange (que verás en §26.4) son la otra mitad del rendimiento. Si un sprite no está visible, no llega ni al batching.
LOD (Level of Detail, nivel de detalle) es un patrón prestado del 3D: cuando algo está lejos, lo dibujas más barato. En 2D no siempre aplica, pero hay tres escenarios clásicos:
Implementarlo en Bevy es tan simple como un sistema que ajusta TextureAtlas::index o el número de hijos según distancia al Camera:
//! cap-26 — sección 26.3: LOD manual por distancia.
use bevy::prelude::*;
#[derive(Component)]
struct LodThreshold { close: f32, mid: f32 }
#[derive(Component)]
struct LodLevel(u8); // 0 = close, 1 = mid, 2 = far.
fn update_lod(
cameras: Query<&Transform, With<Camera2d>>,
mut lods: Query<(&Transform, &LodThreshold, &mut LodLevel)>,
) {
let cam = cameras.single();
for (t, th, mut lvl) in &mut lods {
let d = t.translation.distance(cam.translation);
let new_lvl = if d < th.close { 0 } else if d < th.mid { 1 } else { 2 };
if lvl.0 != new_lvl {
lvl.0 = new_lvl;
// aquí ajustarías el sprite o el número de partículas.
}
}
}
La métrica para saber si el LOD merece la pena es frecuencia de cambio: si los sprites saltan entre niveles cada pocos píxeles, el LOD empeora la experiencia. Se suaviza con un histéresis (no cambiar hasta superar el umbral en X píxeles) o se computa por chunks, no por entidad.
LOD / Level of Detail (técnica): renderizar versiones más simples de un objeto según su distancia a la cámara.
Histéresis (técnica): requerir que el valor supere el umbral en una cantidad extra antes de cambiar de estado; evita parpadeo.
Chunk (espacial): agrupación rectangular de tiles/entidades procesada como unidad; evita trabajo por-entidad cuando es innecesario.
VisibilityRange y VisibleEntitiesBevy ya hace frustum culling (descartar entidades fuera de la cámara) por ti: cada frame, el motor calcula el Aabb (axis-aligned bounding box, caja envolvente alineada a los ejes) de cada entidad y lo compara con el frustum de la cámara. Las que quedan fuera no se envían a la GPU. Pero hay un segundo nivel, más explícito, llamado visibility range (rango de visibilidad): una entidad solo se muestra si la cámara está dentro de cierto rango de distancia o zoom.
//! cap-26 — sección 26.4: VisibilityRange para tilemap chunks.
use bevy::prelude::*;
fn setup_chunk(commands: &mut Commands, position: Vec2) {
commands.spawn((
Sprite { /* ... */, ..default() },
Transform::from_translation(position.extend(0.0)),
VisibilityRange {
start: 0.0..=200.0, // visible si la cámara está entre 0 y 200 px.
// use_aabb: false, // opcional.
..default()
},
));
}
El caso de uso estrella es tilemaps con chunks (agrupaciones de tiles que se activan o desactivan según el viewport). En lugar de comprobar manualmente qué chunks están "en pantalla", Bevy lo hace solo: marcas cada chunk con un VisibilityRange ajustado al tamaño del chunk y el motor decide.
VisibleEntities es la query que Bevy usa internamente para saber qué entidades pasan el culling. Si quieres depurar qué se está dibujando y qué no, puedes leerla desde código:
//! cap-26 — sección 26.4: contar entidades visibles.
use bevy::prelude::*;
use bevy::render::view::VisibleEntities;
fn count_visible(query: Query<&VisibleEntities, With<Camera2d>>) {
for vis in &query {
let n: usize = vis.iter().count();
info!("Cámara dibujando {n} entidades");
}
}
❌ Desactivar el frustum culling con `NoFrustumCulling` en todas las entidades.
Pierdes el 80% del rendimiento en escenas grandes.
✅ Dejar el culling por defecto; añadir `NoFrustumCulling` solo a elementos
que sabes que siempre están visibles (el HUD, por ejemplo).
💡 Por qué: el culling por AABB cuesta microsegundos por entidad; saltárselo cuesta dibujar miles de pixeles extra.
Frustum culling (técnica): descartar entidades cuya AABB está fuera del volumen visible de la cámara.
AABB (Axis-Aligned Bounding Box, estructura): caja rectangular alineada a los ejes X/Y/Z; rápida de calcular y testear.
VisibilityRange (componente): rango de distancia/zomm en el que una entidad se considera visible.
VisibleEntities (componente): lista interna que Bevy usa para pasar de "entidades en el mundo" a "entidades a dibujar".
AssetLoader async + hot-reloadPara juegos pequeños, cargar todos los assets al inicio está bien. Pero cuando tienes 200 MB de sprites, audio y tilemaps, no puedes bloquear el arranque. Ahí entra el streaming: cargar assets bajo demanda, en background, y avisar cuando están listos.
En Bevy, los assets se cargan con AssetServer::load(path), que devuelve un Handle<T>. Mientras se carga, ese handle apunta a un loader en estado "loading"; cuando termina y todas sus dependencias están listas, Bevy dispara un evento AssetEvent::LoadedWithDependencies { id } al que puedes suscribirte. Ojo: el antiguo AssetEvent::Loaded { id } se eliminó en Bevy 0.13, sustituido por LoadedWithDependencies, porque el anterior no esperaba a las dependencias y provocaba accesos prematuros.
//! cap-26 — sección 26.5: reaccionar a assets cargados.
use bevy::asset::{AssetEvent, Assets};
use bevy::prelude::*;
fn on_asset_loaded(
mut events: EventReader<AssetEvent<Image>>,
images: Res<Assets<Image>>,
) {
for evt in events.read() {
match evt {
AssetEvent::LoadedWithDependencies { id } => {
info!("Imagen {:?} lista (con dependencias)", id);
// puedes spawnear entidades que dependían de este asset.
}
AssetEvent::Modified { id } => {
info!("Imagen {:?} modificada en disco (hot-reload)", id);
// Bevy ya recarga el handle; tú decides qué hacer.
}
AssetEvent::Removed { id } => {
info!("Imagen {:?} removida", id);
}
_ => {}
}
}
}
El hot-reload (recarga en caliente) es el primo hermano del streaming. Si activas la feature hot_reloading y file_watcher de Bevy, cualquier cambio en un asset del disco (un PNG sobreescrito, un .scn.ron editado, un .ogg reemplazado) provoca un AssetEvent::Modified con el handle correspondiente, y Bevy reemplaza los datos en vivo. Es oro durante desarrollo: editas un spritesheet en Aseprite, guardas, y el juego lo ve sin reiniciar.
//! cap-26 — sección 26.5: app con hot-reload.
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(
AssetPlugin {
file_watcher: Some(FileWatcher::Poll(Duration::from_millis(200))),
..default()
}
))
.run();
}
Para streaming manual (cargar cuando el jugador entra en una zona, no al inicio), lo idiomático es:
StreamingAsset { path: String }.OnEnter(GameState::Playing) recorre esos componentes y llama asset_server.load(path).AssetEvent::LoadedWithDependencies, reemplazas el placeholder por el sprite real.//! cap-26 — sección 26.5: streaming manual por zona.
#[derive(Component)]
struct StreamingAsset { path: String }
#[derive(Component)]
struct AssetReady { handle: Handle<Image> }
fn kick_streaming(
mut commands: Commands,
asset_server: Res<AssetServer>,
query: Query<(Entity, &StreamingAsset), Without<AssetReady>>,
) {
for (e, s) in &query {
let h: Handle<Image> = asset_server.load(&s.path);
commands.entity(e).insert(AssetReady { handle: h });
}
}
Asset streaming (técnica): cargar assets bajo demanda (por zona, por nivel) en lugar de upfront.
Hot-reload (técnica): recargar assets modificados en disco sin reiniciar el juego; útil en desarrollo.
FileWatcher (API): servicio de Bevy que vigila cambios en archivos del proyecto.
AssetEvent (evento): LoadedWithDependencies / Modified / Removed que Bevy dispara cuando cambia el estado de un asset.
par_iterEl schedule de Bevy ya paraleliza entre sistemas: si dos sistemas no compiten por los mismos componentes, se ejecutan en hilos distintos cortesía del executor de bevy_ecs. Pero dentro de un sistema, el for normal es secuencial. Cuando iteras decenas de miles de enemigos, partículas o proyectiles, ese bucle se convierte en el cuello de botella y la API para romperlo es Query::par_iter / par_iter_mut, basada en rayon.
//! cap-26 — sección 26.6: actualizar IA de muchos enemigos en paralelo.
use bevy::prelude::*;
fn enemy_ai(
time: Res<Time>,
mut enemies: Query<&mut Velocity, With<Enemy>>,
) {
enemies.par_iter_mut().for_each(|mut vel| {
// trabajo independiente por entidad.
vel.0 += Vec2::new(0.0, -9.8) * time.delta_secs();
});
}
Conviene saber cuándo no usarlo:
iter_mut() normal gana.f32): el costo por iteración es tan bajo que el paralelismo no se amortiza.NonSendMut, el borrow checker lo impide; lo correcto ahí es partir el trabajo en varios sistemas.El umbral práctico para que merezca la pena par_iter suele rondar los 5.000–10.000 entidades con un cuerpo medio. Lo ideal es medirlo con Tracy antes y después: si el span del sistema baja, bien; si sube (por overhead), vuelve a iter secuencial. Si tienes un task pesado asíncrono (pathfinding masivo, generación procedural), en lugar de par_iter lo correcto es AsyncComputeTaskPool, que te devuelve una Task<T> pollable desde un sistema.
❌ Usar `par_iter_mut` en una query de 50 entidades "porque es más rápido".
El overhead de rayon lo hace más lento que `iter_mut`. Mide siempre.
✅ Usar `par_iter_mut` solo cuando el perfil muestra que el iter "caliente" tiene N > 5000.
💡 Por qué: rayon divide el rango, lanza tareas y las une. Eso cuesta. Para N pequeño no se amortiza.
ambiguous_with y ambiguous_allOtro tema que apenas se menciona en el resto del libro: cuando dos sistemas podrían acceder a los mismos datos pero el autor sabe que en la práctica no chocan, Bevy emite un warning de ambiguous system order. El scheduler no puede demostrar que el orden no importa, así que avisa. Si tu caso es legítimamente seguro, lo declaras explícitamente y el warning desaparece sin que el scheduler tenga que imponer un orden arbitrario.
//! cap-26 — sección 26.7: dos sistemas que mutan `Velocity` sobre entidades disjuntas.
use bevy::prelude::*;
fn particle_physics(mut q: Query<&mut Velocity, With<Particle>>) { /* ... */ }
fn enemy_physics(mut q: Query<&mut Velocity, With<Enemy>>) { /* ... */ }
fn main() {
App::new()
.add_systems(Update, (
particle_physics,
enemy_physics,
))
// Ambos mutan `Velocity`, pero sobre conjuntos disjuntos. El scheduler
// no puede verlo; nosotros sí, así que declaramos el par como seguro:
.run();
}
Variante .ambiguous_all(): declara que un sistema es seguro frente a todos los demás del schedule. Útil para sistemas de sólo lectura que el análisis estático no reconoce como tales (por ejemplo, lectura a través de un NonSendMut opaco). En CI, puedes exigir cero ambigüedades con ScheduleBuildSettings en modo error on ambiguous, de modo que cualquier ambigüedad nueva rompa el build en lugar de esconderse en un warning.
par_iter / par_iter_mut (métodos de Query): iteración paralela rayon-style sobre las filas de una query.
AsyncComputeTaskPool (recurso): pool para tareas pesadas asíncronas; devuelve Task<T> pollable.
Ambiguous system order (warning): Bevy no puede demostrar que el orden de dos sistemas no importa.
ambiguous_with (método): marca un sistema como seguro frente a otro sistema/set concreto.
ambiguous_all (método): marca un sistema como seguro frente a todos los demás del schedule.
assert_schedule (modo de build): convierte ambigüedades en error en CI.
Profiling (técnica): medición sistemática de tiempos y cuellos de botella.
Tracy (herramienta): profiler externo con GUI y captura de timeline.
Frame time (métrica): ms por frame; objetivo 16.6 (60 FPS) o 8.3 (120 FPS).
Draw call (render): orden CPU→GPU para dibujar un mesh+material; cara si hay muchas.
Batching (técnica): agrupar varias entidades con mismo material en una sola draw call.
Atlas / spritesheet (asset): imagen grande con N frames; clave del batching.
TextureAtlasLayout (asset): descriptor de grid (UVec2 + columnas + filas + padding).
Material2d (trait): trait que define shader + parámetros de un material 2D. NO es un componente.
MeshMaterial2d<T> (componente): componente que vincula una entidad a un material T: Material2d.
RenderAssetUsages (API): hint al cargador sobre dónde residirá el asset (GPU/CPU).
LOD / Level of Detail (técnica): versión más simple según distancia a la cámara.
Histéresis (técnica): requerir sobrerango antes de cambiar de estado; evita parpadeo.
Chunk (espacial): agrupación rectangular procesada como unidad.
Frustum culling (técnica): descartar entidades fuera del volumen visible.
AABB (estructura): caja alineada a los ejes; rápida de testear contra el frustum.
VisibilityRange (componente): rango de distancia/zoom en el que algo es visible.
VisibleEntities (componente): lista interna con las entidades que pasan el culling.
NoFrustumCulling (componente): desactivar el culling para una entidad específica.
Asset streaming (técnica): cargar bajo demanda en lugar de upfront.
Hot-reload (técnica): recargar assets modificados sin reiniciar.
FileWatcher (servicio): vigila cambios en archivos del proyecto.
AssetEvent (evento): LoadedWithDependencies / Modified / Removed sobre un asset.
AssetServer (recurso): fachada de Bevy para cargar assets.
Handle<T> (tipo): referencia opaca a un asset; puede estar loading o ready.
Doom (id Software, 1993) — implementó uno de los primeros bus de eventos interno para separar lógica de render, anticipando 30 años a los ECS modernos.
Into the Breach (Subset Games, 2018) — dibuja cientos de unidades en pantalla con tan pocas draw calls que es referencia obligada de batching 2D.
Hades (Supergiant, 2020) — su sistema de "rooms" carga assets en streaming conforme el jugador avanza por cada cámara.
RollerCoaster Tycoon (Chris Sawyer, 1999) — escrito en x86 ensamblador a mano y aún así mantenía 60 FPS con miles de sprites; prueba de que batching es lo que importa, no el lenguaje.
Problema: un juego 2D en Bevy va a 25 FPS en un PC modesto y no sabes por qué. Cambias algoritmos, reduces resolución, reescribes sistemas y nada mejora.
Solución:
trace_tracy y captura 30 segundos de gameplay.bevy_diagnostic exponen contadores; o cuenta entidades en VisibleEntities): si son >200, busca batching roto (sprites con texturas distintas).Cuándo sí:
Cuándo no:
Cierre:
Optimizar es un superpoder medible: si no puedes graficar la mejora, no la hiciste. Bevy te da todas las herramientas para medir (Tracy, diagnostics, contadores), y este capítulo te ha mostrado las cuatro técnicas que cubren el 90% de los casos: batching por atlas, LOD por distancia, culling por visibilidad y streaming por demanda. El resto (shaders, paralelismo, ECS optimizations internas) es para otro libro.
bevy_diagnostic te dan los números; el instinto te da las hipótesis.bevy_ecs son columnares, y archetype permite iterar miles de entidades en paralelo con cache-line friendly access..before() / .after() / .in_set() reorganizan sistemas sin cambiar su lógica, y al reorganizar puedes descubrir ordenamientos subóptimos.Query con With<T> mal puesto, un ResMut global que serializa el frame, o un alloc dentro del hot path.bevy_diagnostic (métricas de Bevy) son las dos herramientas que necesitas. Crono, superframe, frame budget, GPU timings. Pinta los counters en pantalla con bevy_egui.En el cap 27 (patrones avanzados) cerramos el ciclo de Bevy puro: plugin composition, app settings, hot-reload, y los patrones que no encajan en un cap anterior (state machines con States y SubStates, scene management, save data, undo/redo). Es el cap "madurez": ya sabes hacer un juego; ahora aprende a mantenerlo. El cap 28 es el proyecto final (un mini metroidvania paso a paso que junta todo). Y el 28B es el release: cómo empaquetar, firmar, subir a Steam/itch.io, y dormir tranquilo. Si el cap 26 era "mide y arregla", el cap 27 es "estructura para no tener que arreglarlo". Manos a la obra.