Capítulo 26. Optimización: batching, LOD, culling, asset streaming

CAP 26 · Bevy 0.18/0.19
"El primer 90% del tiempo de desarrollo consume el primer 90% del presupuesto de optimización. El otro 10% consume el otro 90%."

— Ley de Conway sobre el profiling, paraphrased.

26.1 Profiling con Tracy + bevy_diagnostic

Optimizar 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 ...
    }
}

26.2 Render: batching + atlases

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:

  1. Diferentes Handle<Image> por sprite. Si cada entidad tiene su propia Image, el batcher no puede fusionar.
  2. Diferentes 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.

26.3 LOD visual: nivel de detalle manual

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:

  1. Tiles: muy cerca, dibujas tiles de 16 px con todo el detalle; lejos, tiles de 32 px (4 veces menos trabajo).
  2. Sprites animados: muy cerca, animas a 12 FPS; lejos, congelas la animación a un solo frame.
  3. Partículas: muy cerca, 200 partículas; lejos, 50.

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.

26.4 Frustum culling con VisibilityRange y VisibleEntities

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

26.5 Asset streaming con AssetLoader async + hot-reload

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

  1. Marcar la entidad con un componente StreamingAsset { path: String }.
  2. Un sistema en OnEnter(GameState::Playing) recorre esos componentes y llama asset_server.load(path).
  3. Cuando llega 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.

26.6 Paralelismo de queries con par_iter

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

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.

26.7 Resolución de ambigüedades: ambiguous_with y ambiguous_all

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

26.8 Glosario

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.

26.9 Trivia

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.

🎯 26.10 Patrón del capítulo: optimiza el cuello, no el código

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:

  1. Activa trace_tracy y captura 30 segundos de gameplay.
  2. Mira qué franja (CPU lógica / CPU render / GPU) domina.
  3. Si domina lógica → busca el sistema más largo, instrumento sus partes, optimiza solo ese.
  4. Si domina render → mira draw calls (los plugins de bevy_diagnostic exponen contadores; o cuenta entidades en VisibleEntities): si son >200, busca batching roto (sprites con texturas distintas).
  5. Si domina GPU → reduce overdraw (sprites grandes tapando pequeños) o tamaño de atlas.

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.

Lo que vimos

En el siguiente

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.