Volver al Blog
Motores Gráficos14 de julio de 20267 min de lectura

Profiling en Unity: encontrar el cuello de botella real

Subir los FPS a ciegas es perder el tiempo. Cómo usar el Unity Profiler para encontrar el cuello de botella real (CPU, GPU, draw calls o GC) antes de tocar una línea de código.

IM
Ignacio MelendezDesarrollador Full-Stack y de Videojuegos
Profiling en Unity: encontrar el cuello de botella real

La primera vez que un juego me iba a 30 FPS y "debería" ir a 60, hice lo que hace casi todo el mundo: empecé a optimizar cosas que sonaban lentas. Bajé la resolución de unas texturas, metí object pooling en un sistema que apenas se usaba, reescribí un par de bucles. Gané exactamente cero FPS. El cuello de botella estaba en otro sitio y yo ni lo había mirado.

Optimizar sin medir es picar piedra con los ojos cerrados. Este artículo va de lo contrario: usar el Profiler de Unity para saber con datos quién se está comiendo el frame, antes de tocar una sola línea.

1. El error de optimizar sin medir

Un frame a 60 FPS dura 16,6 milisegundos. A 30 FPS, 33,3 ms. Ese presupuesto de tiempo se reparte entre muchas cosas: la lógica de juego, la física, el renderizado, el recolector de basura. Cuando el juego va lento, alguna de esas partes se está pasando del presupuesto. El problema es que la intuición es malísima para adivinar cuál.

El código que "parece" caro muchas veces no lo es, y el que normalmente se ignora (un GetComponent en un Update, un Debug.Log olvidado, un shader transparente de más) es el que hunde. La única forma fiable de saberlo es medir.

No es buena idea comenzar a optimizar nada hasta tener un número delante. Cada optimización añade complejidad, y si no se ataca el cuello de botella real, esa complejidad no devuelve ni un FPS. Mide, arregla, vuelve a medir.

2. CPU vs GPU: quién manda el frame

Antes de abrir el Profiler hay que entender una idea: en cada frame, la CPU y la GPU trabajan en paralelo, y el frame tarda lo que tarde el más lento de los dos. Si el juego va a 40 FPS, lo primero que se necesita saber es si está CPU-bound o GPU-bound, porque las soluciones son opuestas.

  • CPU-bound: la CPU no termina a tiempo de preparar el frame. Suele ser lógica de juego pesada, demasiados scripts en Update, física, o (muy típico) demasiados draw calls que la CPU tiene que enviar uno a uno.
  • GPU-bound: la GPU no termina de pintar. Suele ser overdraw, shaders caros, resolución alta, o demasiados píxeles con transparencias.

Si el caso es de GPU-bound y la aproximación es optimizar scripts, no se gana nada: la CPU ya iba sobrada esperando a la GPU. Por eso esta es la primera pregunta que el Profiler tiene que responder.

Una pista rápida sin Profiler: baja la resolución de la ventana de juego a la mitad. Si los FPS suben mucho, eres GPU-bound (menos píxeles que pintar). Si no cambian casi nada, eres CPU-bound. Esto no es un diagnóstico al 100%, pero ayuda a identificar ese primer cuello de botella.

3. Leer el Profiler

El Profiler se abre en Window > Analysis > Profiler (o Ctrl/Cmd + 7). Cada fila es un módulo (CPU, Rendering, Memory, etc.) y el eje horizontal es el tiempo, un frame por columna. Lo primero es darle a play, dejar que pase algo representativo y luego pausar y clicar en un frame que tenga un pico.

Ventana del Profiler de Unity con el módulo CPU Usage seleccionado y la vista Timeline mostrando los hilos del frame
El módulo CPU Usage arriba y, abajo, la vista Timeline con el desglose por hilo del frame seleccionado

El módulo que generalmente más se usa es el de CPU Usage. Con el frame seleccionado, abajo cambia la vista a Timeline para ver qué hilo está ocupado, o a Hierarchy para ver una tabla ordenable por coste. En Hierarchy se puede ordenar por Time ms o Self ms de mayor a menor: lo que esté arriba del todo es por donde se debe empezar a trabajar.

Hay dos columnas que conviene mirar siempre:

  • Self ms: tiempo gastado dentro de ese método, sin contar lo que se llama. Si una función tiene mucho Self, el problema está en ella.
  • GC Alloc: memoria reservada en el heap administrado en ese frame. Cualquier número distinto de cero aquí es deuda que se pagará luego.

El Profiler en el editor miente un poco: el propio editor añade overhead y algunas cosas (como el shader de la escena) no representan el build final. Para números de verdad, se puede hacer un development build con Autoconnect Profiler y perfila en el dispositivo real, sobre todo cuando se desarrolla para dispositivos móviles.

4. Draw calls y batching

Cada vez que la CPU le dice a la GPU "píntame esto", eso es una llamada de dibujo (draw call). Cada llamada tiene un coste fijo de preparación en CPU (cambiar de material, subir estado), y ese coste se multiplica. Mil objetos con materiales distintos pueden ser mil llamadas, y ahí es donde muchísimos juegos se vuelven CPU-bound sin que el desarrollador se dé cuenta.

En el Profiler, el módulo de Rendering muestra el número de Batches y SetPass Calls por frame. El SetPass Call es el caro: es cada cambio de estado de render que obliga a la GPU a reconfigurarse. Bajar ese número es casi siempre la mayor victoria de rendimiento en CPU.

Algunas de las herramientas que tiene Unity para agrupar dibujos en menos llamadas y que son bastante útiles son:

  • SRP Batcher (URP/HDRP): agrupa objetos que comparten el mismo shader (aunque tengan distinto material), siempre que el shader sea compatible. Es lo primero que hay que activar.
  • GPU Instancing: para muchas copias de la misma malla con el mismo material (árboles, rocas, balas). Se activa con un check en el material.
  • Static Batching: para geometría que no se mueve. Unity la combina en mallas grandes en build.

Además, un patrón muy común para instancing manual de muchos objetos idénticos podría ser el siguiente ejemplo:

C#
1// En vez de instanciar 1000 GameObjects (1000 draw calls potenciales),
2// dibuja la misma malla 1000 veces en una sola llamada instanciada.
3public class BulletRenderer : MonoBehaviour
4{
5    [SerializeField] Mesh mesh;
6    [SerializeField] Material material; // con "Enable GPU Instancing" activado
7
8    readonly Matrix4x4[] matrices = new Matrix4x4[1000];
9
10    void Update()
11    {
12        // ... rellenar matrices con la posicion de cada bala ...
13
14        // Una sola llamada para las 1000 balas, en bloques de 1023 (limite de la API).
15        Graphics.DrawMeshInstanced(mesh, 0, material, matrices);
16    }
17}

El SRP Batcher y el GPU Instancing no se combinan: un objeto va por uno o por otro. Y el SRP Batcher se rompe si usas MaterialPropertyBlock para variar propiedades por instancia. Es mejor mirar el Frame Debugger (Window > Analysis > Frame Debugger) para ver por qué dos objetos no se agruparon: dice el motivo exacto de cada batch.

5. El asesino silencioso: el Garbage Collector

Unity (Mono/IL2CPP) usa un Recolector de Basura o Garbage Collector. Cuando se reserva memoria administrada (un new, un array, un string concatenado, un closure), esa memoria se acumula hasta que el GC se ejecuta para liberarla. Y cuando el GC se ejecuta, para el hilo principal. El resultado es ese tirón puntual que se ve cada pocos segundos: el frame que normalmente va a 2 ms de repente va a 20 ms. No es bajo FPS, es un spike, y suele ser peor para la sensación de juego que una bajada constante.

La forma de cazarlo es la columna GC Alloc del Profiler en la vista Hierarchy. Generalmente, los métodos en Update, FixedUpdate o LateUpdate que reserven memoria cada frame suelen ser los culpables habituales:

C#
1void Update()
2{
3    // MAL: concatenar strings reserva un string nuevo cada frame
4    scoreText.text = "Score: " + score;
5
6    // MAL: LINQ y lambdas reservan iteradores y closures
7    var enemy = enemies.FirstOrDefault(e => e.IsAlive);
8
9    // MAL: GetComponent en caliente, ademas de lento, puede asignar
10    var rb = GetComponent<Rigidbody>();
11
12    // MAL: foreach sobre algunas colecciones reserva un enumerador (boxing)
13    foreach (var item in someCollection) { }
14}

La versión sin allocations del mismo Update:

C#
1Rigidbody rb;              // cacheado en Awake
2readonly List<Enemy> enemies = new();
3
4void Awake() => rb = GetComponent<Rigidbody>();
5
6void Update()
7{
8    // BIEN: actualiza el texto solo cuando cambia, y usa un formato sin garbage
9    if (score != lastScore)
10    {
11        scoreText.SetText("Score: {0}", score); // API de TMP sin allocations
12        lastScore = score;
13    }
14
15    // BIEN: bucle for indexado sobre una List<T>, sin enumerador
16    for (int i = 0; i < enemies.Count; i++)
17    {
18        if (enemies[i].IsAlive) { /* ... */ break; }
19    }
20}

Para sistemas que crean y destruyen objetos a menudo (balas, partículas, enemigos), suele ser mejor utilizar un patrón object pooling: reservar una vez al principio y reutilizar, en vez de Instantiate/Destroy que generan garbage. Unity trae UnityEngine.Pool.ObjectPool<T> desde 2021, no hace falta escribirlo a mano.

6. Deep Profile y ProfilerMarker

La vista normal del Profiler solo muestra los métodos de Unity y los que tienen marcadores. Si el pico está dentro de tu propio código y no sabes en qué función, existen dos opciones.

La rápida es Deep Profile (botón arriba del Profiler): instrumenta cada método de tu código. Te da el detalle completo, pero añade tanto overhead que los tiempos absolutos dejan de ser fiables. Sirve para ver proporciones (qué función pesa más respecto a otra), no para medir milisegundos reales.

La precisa es instrumentar tú mismo las zonas que te interesan con ProfilerMarker. Tiene coste casi nulo y aparece en la Timeline con el nombre que le pongas:

C#
1using Unity.Profiling;
2
3public class PathfindingSystem : MonoBehaviour
4{
5    static readonly ProfilerMarker s_PathfindMarker = new("Pathfinding.Solve");
6
7    void Update()
8    {
9        using (s_PathfindMarker.Auto())
10        {
11            // Todo lo que pase aqui dentro aparece como "Pathfinding.Solve"
12            // en la Timeline del Profiler, con su tiempo exacto.
13            SolvePaths();
14        }
15    }
16}

Con marcadores en los sistemas clave (IA, pathfinding, generación de mundo) dejas de adivinar: ves directamente en la Timeline qué bloque se pasa de presupuesto en el frame del pico.

Tabla de comparación de marcadores del Profiler de Unity con la mediana de tiempo de cada marcador a la izquierda y a la derecha del rango seleccionado
La vista de comparación de marcadores enfrenta la mediana de cada marcador en dos rangos: ideal para medir el antes y el después de una optimización

7. De los datos a los FPS

En resumen, con todos estas herramientas y utilidades, es posible seguir un flujo completo para detectar los cuellos de botella y los puntos más críticos del proyecto:

  1. Medir el estado actual. Development build en el dispositivo objetivo, no en el editor. Apunta los FPS y el tiempo de frame.
  2. ¿CPU o GPU? Baja la resolución a la mitad. Si suben los FPS, GPU-bound; si no, CPU-bound. Eso decide en qué módulo del Profiler te centras.
  3. Aislar el peor frame. Pausa en un pico, ordena Hierarchy por Time ms y mira lo de arriba. Revisa GC Alloc por si hay garbage por frame.
  4. Arreglar un caso concreto. La de mayor impacto según los datos, no la que te apetezca. Una sola, para poder medir su efecto.
  5. Volver a medir. ¿Subieron los FPS? Bien, sigue con el siguiente cuello. ¿No subieron? Revierte el cambio: no era el cuello de botella.

El Profiler no te dice qué optimizar. Te dice qué no tocar, que suele ser el 90% del código que creías lento. Ese es su verdadero valor: te ahorra el trabajo inútil.

La diferencia entre pelearse con el rendimiento durante días y arreglarlo en una tarde casi nunca está en saber más trucos de optimización. Está en mirar el número correcto antes de empezar.

Artículos Relacionados

Ver todos los artículos
Replicando el sistema genético de Mewgenics en Unity

Replicando el sistema genético de Mewgenics en Unity

Un análisis técnico de cómo diseñar un sistema genético flexible en Unity, inspirado en la mecánica de crianza de Mewgenics.

Pathfinding en Videojuegos: A* y sus Variantes

Pathfinding en Videojuegos: A* y sus Variantes

A* es el algoritmo de pathfinding más conocido, pero los juegos modernos van mucho más allá: NavMesh, JPS, HPA*. Este artículo explora por qué Dijkstra no escala, cómo funciona A* de verdad y cuándo usar cada variante.

Coyote time y buffering de inputs: el platformer que perdona

Coyote time y buffering de inputs: el platformer que perdona

Los mejores platformers se sienten justos porque hacen trampa a tu favor. Un análisis de coyote time, jump buffering y otros trucos de input que perdonan al jugador, con implementación en Unity/C#.