Draw calls y overdraw: 17 FPS más en Android de gama media
Mi escena de tower defense se quedaba en 41 FPS en un Android de gama media. Así medí draw calls, materiales y overdraw hasta llegar a 58 FPS estables.
La escena de tower defense atascada en 41 FPS
El invierno pasado me tocó la última pasada de optimización de un proyecto de tower defense. En un Redmi Note 10 — Snapdragon 678, Adreno 612 — el tiempo de fotograma subía a 24,3 ms en la oleada 12, con una media de 41 FPS. Mi primera suposición fue la de siempre: hay demasiados enemigos en pantalla, así que la IA y la física deben de ser caras. Perfilé el planificador de tareas durante dos días y no encontré nada; el hilo de juego terminaba en 6,1 ms.
El panorama se aclaró cuando el Unity Profiler marcó 14,8 ms en el render thread y 26 ms en la GPU. El Frame Debugger contaba más de 780 draw calls, y cerca de 400 eran anillos de alcance de las torres, números de daño, decals en el suelo y partículas de humo. Casi toda la pantalla estaba cubierta de capas transparentes apiladas, y cada una repintaba la misma zona desde cero. Reducir a la mitad el número de enemigos solo me devolvió 1,2 ms; el culpable estaba en otra parte.
El error de verdad estaba en mi propia definición de optimización de juegos móviles: durante años la había entendido como «bajar el número de polígonos». La escena tenía 210 mil triángulos en total y la Adreno 612 dibujaba esa geometría sin quejarse. La carga venía de dos frentes distintos: el coste de CPU por cada draw call y el ancho de banda escrito a memoria principal por cada píxel. Lo que sigue es el registro de cómo separé esos dos frentes y de cuántos milisegundos devolvió realmente cada cambio.
Cuándo entra en juego cada tipo de batching
Hay tres mecanismos y no hacen el mismo trabajo. SRP Batcher no reduce el número de draw calls; mantiene las constantes de material en un buffer de GPU persistente para los dibujados que comparten una variante de shader, lo que abarata la preparación de CPU por llamada. Las 780 llamadas siguen siendo 780, pero cada una pesa bastante menos. Lo que cuenta es el número de variantes de shader, no de materiales; por no verlo estuve mucho tiempo fusionando lo que no debía.
El que sí baja la cuenta de verdad es el GPU instancing: mismo mesh, mismo material, hasta 1023 instancias en una sola llamada. La trampa está aquí: en URP, si el shader es compatible con SRP Batcher, la ruta de instancing no llega a ejecutarse nunca, porque el SRP Batcher tiene prioridad. No me di cuenta hasta que dibujé las bases de las torres a mano con Graphics.DrawMeshInstanced; veía filas «SRP Batch» en el Frame Debugger y daba por hecho que el instancing estaba haciendo su trabajo.
El static batching fusiona los meshes en un único vertex buffer grande durante la build. La ganancia es real, pero tiene precio: 38 MB extra de memoria de mesh en nuestra escena y la pérdida de granularidad del culling, porque un grupo fusionado se dibuja entero o no se dibuja. Lo dejé activado solo en las piezas del suelo que no se mueven nunca y que además siempre se ven juntas en cámara. Desactivarlo en árboles y rocas bajó tanto la memoria como los triángulos realmente enviados.
Y hay un rompedor de batches silencioso: MaterialPropertyBlock. Teníamos uno colgado de cada renderer para tintar los niveles de torre, y eso por sí solo sacaba a esos objetos de la compatibilidad con SRP Batcher. Después de mover la variación de color a los datos de instancia y al vertex color, el render thread pasó de 14,8 ms a 11,0 ms, sin cambiar una sola línea de shader.
Número de materiales y diseño del texture atlas
Lo que de verdad marcaba la cuenta de draw calls era el número de materiales. La escena tenía 34 y la mayoría se diferenciaban solo por otra textura de 512x512. Los agrupé en tres texture atlas de 2048x2048: entorno, torres, y efectos junto con la UI. Cuantos más objetos comparten un mismo material, mayor es la bolsa disponible para el instancing y el batching.
Hacer el atlas es menos mecánico de lo que parece. Al reempaquetar las UV tuve que dejar fuera todos los meshes que dependen de tiling, porque el wrap mode repeat no funciona dentro de un atlas: se cuelan los píxeles de la isla vecina. Dejé un material aparte para las baldosas del suelo y esas las dibujé con instancing. También di 8 píxeles de padding a cada isla para evitar el sangrado de mipmaps; con 4 píxeles aparecían finas costuras de color a distancia.
En compresión pasé de ETC2 a ASTC 6x6, visiblemente más limpio con el mismo presupuesto de memoria, sobre todo en los degradados dentro del atlas. Tras el atlas, los materiales bajaron de 34 a 6 y los draw calls de 780 a 210. El render thread cayó de 11,0 ms a 7,4 ms. Fue la mayor ganancia individual de toda la pasada, y no escribí ni una línea de shader.
La factura de overdraw de las capas transparentes
El overdraw es el número de veces que se escribe el mismo píxel en un fotograma. Los objetos transparentes tienen ZWrite desactivado, así que el test de profundidad no descarta a nadie: el de detrás se sombrea igual que el de delante. En la vista de overdraw del Rendering Debugger, el centro de la escena marcaba 11x, es decir, algunos píxeles se pintaban once veces por fotograma. Ahí vivía la mayor parte de esos 26 ms de GPU.
Conté las capas una a una: anillo de alcance, decal de suelo, humo, chispas, flash de daño y, encima de todo, un quad de viñeta a pantalla completa. Metí la viñeta en el shader final de post-proceso y borré el quad aparte; por sí solo ya es una capa a pantalla completa en 1080p. El anillo de alcance pasé a dibujarlo solo con la torre seleccionada. Y bajé las partículas de humo de 240 a 90 agrandando cada una: la misma densidad visual con un tercio del coste de fill.
No trates el alpha test como la solución. Usar clip() en materiales de follaje y vallas rompe el early-z y la eliminación de superficies ocultas en una GPU tile-based; en las dos pruebas que medí, volver a alpha blend ganó 0,6 ms. Por la misma razón, ordenar los transparentes de delante hacia atrás no aporta nada, porque ninguno escribe profundidad. La ganancia solo llega al reducir el número de capas y el área de pantalla que ocupan.
En GPU tile-based el límite real es el ancho de banda
Las GPU móviles dividen el fotograma en tiles, procesan cada tile en una memoria pequeña y rápida dentro del chip, y escriben el resultado a memoria principal. Lo caro no son las matemáticas del shader, sino ese tráfico de escritura y lectura. En 1080p, un único target RGBA32 son unos 8 MB por fotograma, o 500 MB/s a 60 FPS, y eso es una sola pasada. Cuando el dispositivo se calienta, lo primero que se recorta es el reloj de memoria, así que el ancho de banda es también un problema térmico.
Por eso cambiar de render target a mitad de fotograma sale caro: cada cambio obliga a resolver la memoria de tile hacia memoria principal. Nuestra cadena de post-proceso tenía tres blits separados; fusionar el downsample del bloom con la corrección de color en una sola pass quitó 1,7 ms de tiempo de GPU. Por lo mismo, pasar RenderBufferLoadAction.DontCare en los targets cuyo contenido previo da igual es una ganancia gratis: un load innecesario significa leer el target entero desde memoria principal hacia los tiles.
El depth prepass suele salir mal en móvil. La técnica que recorta el overdraw en escritorio nos costó 0,9 ms aquí, porque procesa la geometría dos veces y genera tráfico de profundidad extra en una arquitectura tile-based. El rechazo Z de baja resolución de la propia Adreno ya hace un trabajo parecido gratis. Lo probé, lo medí y lo revertí: uno de esos sitios donde los reflejos de escritorio simplemente no sirven.
La solución real para las capas transparentes fue renderizar las partículas a media resolución. Las dibujé en un render target de la mitad de tamaño y las compuse de vuelta con un upsample depth-aware: los píxeles sombreados bajan a la cuarta parte, la composición cuesta 0,4 ms y la ganancia neta fue de 3,1 ms. Hay un ligero dentado en los bordes, pero no se nota en efectos de baja frecuencia como humo y polvo. Los efectos finos y nítidos, como chispas y trazas de bala, se quedaron a resolución completa.
El resultado medido en un Android de gama media
Con la misma grabación de 60 segundos de la oleada 12 y el mismo Redmi Note 10: el tiempo de fotograma pasó de 24,3 ms a 16,4 ms, y la media de 41 FPS a 58 FPS. Los draw calls bajaron de 780 a 190, los materiales de 34 a 6 y el overdraw máximo medido de 11x a 4x. En una sesión de 15 minutos la temperatura de la batería se quedó en 39 °C en lugar de 44 °C, así que el dispositivo no llegó a hacer throttling y desapareció la caída de FPS de los últimos cinco minutos.
Me importa cómo se reparte esa ganancia, porque decide por dónde empiezo en el siguiente proyecto: alrededor del 40% vino del trabajo de materiales y atlas, el 35% de reducir overdraw más las partículas a media resolución, el 15% de los ajustes de ancho de banda y el resto de recuperar la compatibilidad con SRP Batcher. La reducción de polígonos no aparece en ningún punto de esa lista. No toqué ni un mesh y dejé la configuración de LOD tal cual estaba.
En tu propio proyecto seguiría este orden: primero apunta el número de materiales y los motivos de ruptura de batch en el Frame Debugger, luego busca las tres peores capas en la vista de overdraw, y solo después toca un shader. Mide cada paso en un dispositivo real, con la misma escena grabada y a la misma temperatura; he tenido muchos cambios que marcaban 200 FPS en el editor y perdían 0,2 ms en el teléfono. Y no mires un solo número: draw calls, overdraw y ancho de banda son límites distintos, y arreglar uno rompe el otro con facilidad.