Si la forma por defecto de armar multitudes en un motor ECS como Bevy suele ser «cada cosa que se mueve, primero Entity; Mesh3d, Transform y Visibility colgados; y que el motor haga el batch», entonces el 16 de octubre de 2026 el desmontaje de Matt Busel en Tensorust puso esa factura por defecto sobre la mesa y rodeó en rojo la línea cara. El juego se llama *LAWN WAR*, corre en Rust y Bevy 0.19.1: una guerra sin fin de plantas contra zombis. El documento de diseño pide cientos de miles de unidades por bando. Este ensayo no ha jugado un build terminado; las escenas y cifras vienen del artículo público del autor, del espejo en DEV y de las condiciones de medición allí dichas.
Comprobé lo que se puede clavar. El 2 y 3 de octubre de 2026 el juego lo construyó en gran parte un agente de código a partir de las notas de diseño de Matthew; el código está en GitLab. El primer build jugable hizo el camino ECS normal: una entidad por ranura de zombi. Las capturas de estrés que guardaron muestran 974 zombis a 36 fps y 916 a 8 fps. Unas dos horas y diez minutos después, toda la tubería se reescribió alrededor del instancing personalizado y las unidades dejaron de ser entidades. En cinco corridas de tope el 16 de octubre, 21:11–21:17 ET, en RTX 4070, i7-13700KF, 1600×900, `LW_LOG=1`, wave 28 stage 30, 638.734 unidades aún aguantaban unos 25 fps, con 562.727 instancias y unos 27 MB por fotograma.
La imagen que atraviesa este texto es una papeleta de alistamiento de cuarenta y ocho bytes. Suena a nombre de struct; como ética de producción es la negativa a pagar por «parecer uso correcto del motor». Primero devolver las unidades a datos; luego decidir qué entra en el mundo de render. Lo ahorrado debe dejar prueba, y la línea cara debe nombrarse —no culparse por costumbre a «demasiados draw calls».

1. Por defecto industrial: unidad = entidad; optimizar = adivinar draw calls
En la enseñanza cotidiana de Bevy, el instancing automático ya basta. Si muchas entidades comparten el mismo handle de malla y el mismo material, el motor agrupa. Para unos miles de objetos móviles, el propio autor dice: usa eso y deja de leer. El por defecto es limpio y, por eso, perezoso. Traduce «unidad» a «ciudadano del grafo de escena». Los ciudadanos pagan propagación de transform, visibilidad, detección de cambios y extracción al mundo de render cada fotograma. Jugadores y desarrolladores aprenden el mismo vocabulario para los tirones: «demasiadas entidades», «explotaron los draw calls», «el batch no funciona». Ese acuerdo vuelve perdonable la gramática de «adivinar».
*LAWN WAR* encarece esa adivinanza. El diseño quiere un mar. Ambos ejércitos crecen hasta que el campo es un mar de unidades. Cada una camina, ataca, arde, muere y vuelca al explotar algo. El primer spawn, del commit del 2 de octubre a las 14:58 ET, creaba por capacidad `Mesh3d`, material, `Transform` enterrado y `Visibility::Hidden`. Bajo estrés, unos cientos de zombis podían bajar el frame a un dígito. El autor no clava la culpa en un sistema de Bevy —ese build nunca se perfiló bien—. Importa la decisión que forzó: dos horas y diez minutos después, las unidades dejaron la ciudadanía.
La bifurcación está aquí. El por defecto pregunta cómo meter más entidades en menos draw calls. Ellos preguntaron si, pasados unos miles, la entidad misma ya cobra impuesto cada fotograma, y si lo que debe llegar a la GPU es solo una papeleta lo bastante delgada.

2. Romper el guion: Vec, cuarenta y ocho bytes, un draw y un traspaso sin copia
Tras la reescritura, cada ejército es un `Vec` plano asignado una vez a plena capacidad, de structs `Copy` simples, más lista libre y marca de agua alta `hw`. El tope por defecto es 600.000 por ejército, pero las primeras oleadas usan unos cientos de ranuras. Todo bucle corre sobre `units[..hw]`; una escaramuza no paga por los 599.000 vacíos. La simulación recorre esos slices en paralelo con rayon; los vecinos salen de una rejilla de 2 m reconstruida cada fotograma con counting sort —sin hash, sin `Vec` por celda, ambos ejércitos a la vez—.
Una unidad visible se vuelve 48 bytes de `Inst`: tres `vec4`. Posición más yaw; escala, fase y tipo de animación; tinte y glow. Sin matriz 4×4 completa, sin huesos. Solo giran en el eje vertical; el yaw es un float y el shader arma la rotación. Con 562.727 instancias la lista ronda 27 MB; a 25 fps son unos 680 MB/s hacia la GPU.
Cómo se construyen las listas rápido es la función de la que habla de verdad el gráfico posterior. Los ejércitos se parten en trozos de 8.192 en todos los núcleos; se salta lo que la cámara no ve; LOD por distancia; fotogramas de malla horneados por fase; cada unidad cae en una lista privada por malla y reduce las pega, intercambiando lados vacíos. El culling es un producto punto, no un frustum completo: se tira lo que está a más de unos 20 m y detrás de la cámara; el resto lo recorta la GPU. LOD a 40 m y 120 m. Caminar, atacar y morir del zombi vienen del kit de cementerio CC0 de Kenney; en lugar de skinning en GPU, los clips se hornean en mallas aparte —8 de caminata, 4 de ataque, 1 de muerte— y cada fotograma es otra malla con su propia lista de instancias.
El traspaso ya no copia 27 MB. Las listas van en un `Arc`; la extracción clona el puntero. Aún hay entidad, pero una por malla, con `NoFrustumCulling`, o Bevy cullearía el lote entero por los bounds de la malla en el origen. A 639.000 unidades ese paso midió 0,08 ms. Los buffers GPU tampoco son `create_buffer` cada fotograma: `write_buffer` in situ, reasignar solo al crecer, con 50% de holgura. El pipeline es el de malla normal de Bevy más un buffer de vértices `VertexStepMode::Instance`; el draw es un solo `draw_indexed` sobre toda la lista. La animación vive en el vertex shader WGSL: la CPU escribe fase y tipo; el tambaleo del zombi, el vaivén de la planta, el cadáver plano y los escombros nacen en la GPU. El humo es lo listo: sin blending alfa. Un «emissive» negativo significa traslúcido; el fragment descarta una parte de píxeles con Bayer 4×4. Medio millón de cosas transparentes nunca hace falta ordenarlas.
Hasta aquí el método está completo. Los datos los mantienen vivos. Las papeletas los hacen verse. Arc niega una segunda copia. Un draw posee cada malla. Bayer deja que el humo no haga cola.
3. La hoja de cargos: honestidad a 25 fps, y las listas cuestan más que la guerra
La noche del 16 de octubre el autor corrió cinco guerras en modo QA: un bot juega, `LW_LOG` cada dos segundos, tiempo GPU vía `RenderDiagnosticsPlugin` de Bevy. Misma oleada, misma etapa; solo cambia el tope del ejército. Cada cifra promedia 16–21 muestras tras los primeros doce segundos.
5.568 unidades: 50 fps. La mayor parte de 186.000 instancias son partículas de explosión. 27.934: 61 fps, CPU 9,17 ms, GPU 10,56. 115.217: 49 fps, 11,31 / 15,60. 277.469: 40 fps, 19,17 / 20,19. 638.734: 25 fps (23–28), CPU 34,82, GPU 35,08, 562.727 instancias por fotograma. La corrida de 115k encaja con la nota del diseño del 2 de octubre («~110k unidades, 9–13 ms CPU y 10–14 ms GPU») en otro backend gráfico: un sanity check limpio.
La sorpresa no son los draw calls. De 28k a 639k (~23×), `think()` —toda la simulación de dos ejércitos— pasó de 1,13 ms a 10,09 ms, menos de 9×, gracias a la rejilla de counting sort. `render()` —el bucle que solo arma listas de instancias— pasó de 0,69 ms a 18,85 ms, unas 27×. A tamaño pleno, construir listas cuesta más CPU que simular la guerra. La GPU se ralentizó con la guerra, de forma estable, y acabó en 35,08 ms, codo a codo con la CPU. Nada en los tiempos apuntó a «demasiados draw calls».
Hubo roturas. Las mallas sin colores de vértice tumban el pipeline; el shader lee `@location(5) color` y primitivas Bevy como Sphere o Cuboid no lo tienen —tres líneas `inst::white()` añaden blanco—. El reciclaje de ranuras no puede usar un HP mágico; el daño tardío lo atravesó (el HUD mostró 45,8 billones de HP), así que las unidades que salen de la ventana de batalla ponen `gone = true` y vuelven a la lista libre sin contar como muerte. El 2 de octubre el PC no tenía adaptador Vulkan; Bevy usó DX12 y FXC tardó unos 40 s por arranque; el DXC más rápido rompió el crate de partículas `bevy_hanabi`, así que la espera se esconde tras una pantalla de carga. Esa noche el driver NVIDIA más nuevo (617.42) eligió Vulkan. Dos QA en paralelo mienten: ralentizan el compile de shaders de ambos y tuercen los tiempos de captura; las cinco corridas se midieron de una en una.
La siguiente apuesta es honesta: sin probar. El gráfico apunta al Step 3, no a más trucos de render. Mantener listas vivas entre fotogramas y reescribir solo lo que se movió, o saltarse la lista en CPU y hacer culling y LOD en un compute que escriba directo al buffer de instancias. El renderer propio de Bevy va hacia GPU-driven desde 0.16. Cualquiera podría quitar 10+ ms del fotograma.

Cierre: independencia es clavar el cargo en la línea correcta del recibo
Devuelve este desmontaje de octubre al otoño 2026 del indie Rust/Bevy y lo primero que golpea es una inercia oculta. Por defecto la unidad es entidad, por defecto el tirón es culpa de los draw calls, por defecto «ya hay batch = se puede publicar», y rara vez se pregunta: pasados unos miles, ¿el impuesto de ciudadanía ya es más caro que la guerra; cuando los fps caen a veinticinco, ¿es cara la GPU o el bucle de CPU que solo llena tablas?
Para equipos pequeños el material sirve especialmente. No hace falta tener 600.000 plantas ni una RTX 4070. Tienes tu propia lista de «el por defecto basta». Unidades masivas aún colgadas como Entity. Una reunión de optimización que abre con draw calls. Un demo que hace `create_buffer` cada fotograma. Medio millón de partículas translúcidas que aún quieren ordenarse. Sin spans por sistema, y aun así un cuello escrito en el informe semanal. El trabajo público de Matt Busel escribe los costes en tierra llana. Instancing automático hasta que duela el número de entidades. Papeletas a 48 bytes. Listas privadas en paralelo, traspaso con Arc, reutilizar buffers, animación en vértice, humo Bayer. Lo caro es loguear desde el día uno, y admitir en el recibo que a tamaño pleno la lista cuesta más que la guerra.
Consejos accionables, cortos.
- Empieza con el instancing automático de Bevy. Mismo handle de malla, mismo material. Pasa a custom solo cuando duelen las entidades, no los triángulos.
- Mantén la masa como datos. Un `Vec` de structs `Copy`, lista libre, marca de agua alta. Entidades para lo poco que las necesita.
- Adelgaza la instancia. Posición más yaw, no un 4×4. Cuarenta y ocho bytes frente a medio millón hablan solos.
- Arma listas en paralelo con buffers privados. `par_chunks`, un `Vec` por malla por tarea, merge al final. Sin locks.
- Traspaso con `Arc`, y `NoFrustumCulling` en la única entidad por malla.
- Reutiliza buffers GPU. `write_buffer` con holgura; reasignar solo al crecer.
- Anima en el vertex shader; cuando explote lo transparente, dither en lugar de ordenar alfa.
- Registra tiempos por sistema desde el día uno. Sin spans, casi seguro optimizarás la línea equivocada.
Novecientas setenta y cuatro entidades ciudadanas, una reescritura de dos horas y diez, una papeleta de cuarenta y ocho bytes, 638k unidades a 25 fps, y un `render()` más caro que `think()`. Antes de que hable el texto de tienda, el autor ya escribió el estándar de auditoría en un desmontaje público. Devuelve primero las unidades a datos; clava el cargo en la línea correcta.

Fuentes y lectura adicional
Matt Busel / DEV (espejo) mismo título
- Ejemplo Bevy custom_shader_instancing (automático vs manual)