Optimización Avanzado 13 min de lectura

LOD para IA: agentes lejanos tickeando menos

El secreto de Cities Skylines y Total War: la mayoría de NPCs no necesitan pensar cada frame. Tres tiers, budget por frame, eventos que despiertan.

Publicado: · Por Juanjo "Banyo" López

0. La mentira que sostiene los mundos abiertos

Cities: Skylines simula 80.000 ciudadanos. Total War: 10.000 unidades en pantalla. Skyrim: cada NPC tiene rutina diaria. ¿Cómo no se cae el frame?

Trampa: la inmensa mayoría no piensan cada frame. Solo los que tienes cerca o los que el jugador puede ver corren la lógica completa. El resto vive en estados aproximados o directamente en pausa, despertándose solo cuando algo importante pasa cerca.

Eso es LOD para IA: graphics LOD, pero aplicado al ciclo de pensamiento del NPC.

~0.00 ms/frame ✓ dentro de 16.6
Full · 0Approx · 0Dormant · 0

Click sostenido para mover la cámara (el círculo azul). Los NPCs verdes están en Full — lógica completa cada frame, los ves moverse fluido. Los ámbar son Approx — solo procesan cada N frames; los ves a saltos. Los grises Dormant están totalmente parados. Activa "todos a Full" y mira cómo el ms/frame explota.

Figura 1 — 300 NPCs caminando entre waypoints. Click sostenido para mover la cámara. Verde = Full (lógica completa cada frame). Ámbar = Approx (cada N frames). Gris = Dormant. Activa “todos a Full” y mira el ms/frame explotar.

1. Los tres niveles canónicos

TierFrecuenciaLógicaCosto típico
FullCada frameBT/FSM completo, pathfind, animation update~80 µs
ApproxCada N framesFSM simplificada, “fake animation”, path estático~25 µs
DormantNuncaSolo guarda estado, no piensa~0 µs

Las cifras son órdenes de magnitud, no benchmarks. Lo importante: Approx es 3× más barato que Full, Dormant es prácticamente gratis.

1.1 Full

Lógica completa. Es el NPC que el jugador está viendo o con el que va a interactuar. Aquí no se ahorra: ejecutas el behavior tree, evalúas percepción, corres pathfinding si toca, actualizas animaciones a 60 fps.

1.2 Approx

Aquí sucede el truco interesante. El NPC tickea cada 4–8 frames, pero los frames sin tick no quedan congelados. El truco es:

  • Movimiento interpolado: guardas la posición target y velocidad; cada frame “regular” interpolas. La lógica decide a dónde ir cada N frames; entre medio el NPC se desliza.
  • FSM simplificada: en vez de chequear 12 condiciones de transición, solo chequeas las 2 que importan. “¿Veo enemigo? ¿Llegué a destino?” — el resto no.
  • No anim updates por frame: usas un crossfade simple o saltas a poses keyframe.

Aplicado bien, un NPC en Approx se ve casi idéntico a uno en Full. Mal aplicado, se ve como si fuera a 5 fps.

1.3 Dormant

Cero CPU. El NPC tiene posición, estado, inventario… pero no llama a Update(). Está literalmente en pausa hasta que algo lo promociona. Si abres una sub-zona del juego donde hay 200 ciudadanos y el jugador no está cerca, los 200 entran a Dormant y la pestaña te cuesta nada.

2. Budget por frame: el cap duro

30 Full + 269 Approx + 501 Dormant·con LOD: 9.13 ms·sin LOD: 64.00 ms

El budget de 16.6 ms es el target para 60 FPS. Cada agente Full cuesta unos 80 µs (lógica BT/FSM, pathfind, animación). Approx baja a 25 µs, Dormant es 0. Sube el slider de agentes totales y observa cómo el "sin LOD" se sale del frame mientras el "con LOD" sigue cómodo dentro.

Figura 2 — Mismo número de agentes, dos modos. La línea negra es el budget de 16.6 ms (60 FPS). Sin LOD, todo el mundo cae al rojo apenas pasas de 200 agentes. Con LOD, el budget Full se mantiene constante y la cifra total queda controlada.

2.1 Por qué un budget duro

Si dejas que cualquier NPC suba a Full, te encuentras con casos donde 80 NPCs entran al frustum simultáneamente y el frame revienta. La solución: budget = N agentes Full máximo, sin importar qué pasa.

Ejemplo: fullBudget = 30. Aunque haya 50 NPCs cerca de la cámara, solo los 30 más prioritarios (más cerca, más importantes para la narrativa) van a Full. Los otros 20 quedan en Approx o Dormant.

2.2 Cómo elegir el budget

Mide. Profila tu juego en una zona típica con LOD desactivado. Cuenta cuántos NPCs Full caben en 16.6 ms (a 60 FPS) o 33 ms (a 30 FPS). Eso te da el budget. Resta un margen del 30% para que el resto del juego (rendering, audio, physics) tenga aire.

Para un juego típico:

  • Móvil: 10–20 Full
  • PC mid-range / consola última gen: 30–50 Full
  • PC high-end con presupuesto laxo: 80–120 Full

2.3 Stagger del Approx

Si tienes 200 NPCs en Approx y todos tickean cada 8 frames, en 1 de cada 8 frames pagas el costo de los 200 a la vez — y el frame se cae. Stagger: distribuye el tick por (npc.id + frameIndex) % approxEvery == 0. Así, en cada frame solo 200/8 = 25 NPCs ejecutan tick. Carga uniforme.

function shouldApproxTick(npc, every, frameIdx):
    return ((npc.id + frameIdx) % every) == 0

Pequeño detalle, gran impacto.

3. Heurística de promoción

¿Cómo decide el manager qué tier asignar a cada NPC? Score por agente:

function score(npc, camera, events):
    base = distance(npc, camera)
    for event in events:
        d = distance(npc, event)
        if d < event.wakeRadius:
            base = min(base, d - event.intensity * factor)
    if npc.important:
        base -= 100  # NPCs cuesty/críticos tienen prioridad
    return base

El NPC con menor score gana primer slot Full. Hasta llenar el budget. Después, el resto cae a Approx (si está dentro del approxRadius) o Dormant.

3.1 Eventos que despiertan

Full · 0Approx · 0Dormant · 0

Click en cualquier punto: dispara un evento de "alarma sonora" (un disparo, un grito, una alerta del jugador). La onda expansiva despierta NPCs cercanos — los Dormant pasan a Approx, los Approx pasan a Full mientras dura el evento. Es lo que evita que un NPC parado a 2 metros de una explosión se quede mirando al horizonte.

Figura 3 — Click para disparar un evento (alarma, disparo, alerta). Los NPCs dentro del wake radius ascienden de tier mientras dura el evento. Sin esto, un NPC parado a 2 metros de una explosión seguiría dormido — el efecto rompería la inmersión.

Eventos típicos:

  • Sonidos fuertes (disparos, explosiones, gritos): radius = 80–200m según intensidad.
  • Visión del jugador (al entrar al frustum): radius = frustumDepth.
  • Líneas de quest: si una conversación marca a un NPC como “objetivo activo”, se promociona aunque esté lejos.
  • Combate adyacente: NPCs que escuchan a aliados peleando.

La duración del boost dura mientras el evento esté “vivo” + un cool-down (NPCs no pueden volver a Dormant inmediatamente, se ve mal).

3.2 NPCs marcados como críticos

Algunos NPCs nunca deben ser Dormant: el quest giver del que el jugador depende, el boss que persigue al jugador, los compañeros de party. Marca con un flag y respeta la prioridad en el score.

4. Pseudocódigo del manager

class AgentLODManager
    config: LODConfig
    frameIdx: int = 0

    function update(agents, camera, events):
        frameIdx++

        # 1) score por agente
        scored = []
        for npc in agents:
            scored.append((npc, score(npc, camera, events)))
        scored.sort by score asc

        # 2) asignar Full hasta budget
        fullCount = 0
        for (npc, s) in scored:
            d = distance(npc, camera)
            nearEvent = any(distance(npc, ev) < ev.wakeRadius for ev in events)
            if fullCount < config.fullBudget and (d < config.fullRadius or nearEvent or npc.important):
                npc.tier = Full
                fullCount++
            elif d < config.approxRadius or nearEvent:
                npc.tier = Approx
            else:
                npc.tier = Dormant

        # 3) ejecutar lógica según tier
        for npc in agents:
            if npc.tier == Full:
                npc.update(dt)
            elif npc.tier == Approx and shouldApproxTick(npc, config.approxEvery, frameIdx):
                npc.updateApprox(dt * config.approxEvery)
            # Dormant: nada

5. Implementación en Unity / C#

public enum LODTier { Full, Approx, Dormant }

public class AgentLODManager : MonoBehaviour {
    [Header("Budget")]
    public int fullBudget = 30;
    public int approxTickEvery = 4;

    [Header("Radios")]
    public float fullRadius = 30f;
    public float approxRadius = 80f;
    public float wakeRadius = 25f;

    public Transform camera;
    public List<NPCAgent> agents;
    public List<AlarmEvent> activeEvents;

    int frameIdx;
    readonly List<(NPCAgent npc, float score)> scored = new();

    void Update() {
        frameIdx++;
        var camPos = camera.position;

        // 1) scoring
        scored.Clear();
        foreach (var npc in agents) {
            float baseS = Vector3.Distance(npc.transform.position, camPos);
            foreach (var ev in activeEvents) {
                float ed = Vector3.Distance(npc.transform.position, ev.position);
                if (ed < wakeRadius) baseS = Mathf.Min(baseS, ed - ev.intensity * 30f);
            }
            if (npc.important) baseS -= 100f;
            scored.Add((npc, baseS));
        }
        scored.Sort((a, b) => a.score.CompareTo(b.score));

        // 2) asignar tiers
        int fullAssigned = 0;
        foreach (var (npc, _) in scored) {
            float d = Vector3.Distance(npc.transform.position, camPos);
            bool nearEvent = activeEvents.Any(ev => Vector3.Distance(npc.transform.position, ev.position) < wakeRadius);
            if (fullAssigned < fullBudget && (d < fullRadius || nearEvent || npc.important)) {
                npc.tier = LODTier.Full;
                fullAssigned++;
            } else if (d < approxRadius || nearEvent) {
                npc.tier = LODTier.Approx;
            } else {
                npc.tier = LODTier.Dormant;
            }
        }

        // 3) tick por tier
        foreach (var npc in agents) {
            switch (npc.tier) {
                case LODTier.Full:
                    npc.TickFull(Time.deltaTime);
                    break;
                case LODTier.Approx:
                    if (((npc.id + frameIdx) % approxTickEvery) == 0)
                        npc.TickApprox(Time.deltaTime * approxTickEvery);
                    break;
                case LODTier.Dormant:
                    break;
            }
        }
    }
}

6. Casos reales

6.1 Cities: Skylines

Simula 80.000 ciudadanos. Los que están en pantalla y a < 100m del cursor del jugador van a Full. Los del resto del mapa solo actualizan rutina diaria (trabajo, casa, escuela) cada 30+ segundos. Las animaciones individuales se interpolan a partir de la última posición/destino conocidos.

6.2 Total War

Hasta 10.000 unidades por batalla. Las unidades fuera de cámara o lejos del frente activo entran a “battalion-level AI”: ya no decide cada soldado a quién atacar; decide el batallón como un todo y los soldados copian. El jugador no nota la diferencia porque desde 200 metros un batallón se ve uniforme.

6.3 RimWorld

Es zenital y siempre ves todo el mapa, así que no usa LOD por distancia a cámara. Pero usa despawning aware: cuando entras a la pestaña de “logística” o “investigación”, los pawns dejan de pensar mientras estás en menus. Vuelves al mapa y resumen. Es LOD por contexto, no por distancia.

6.4 The Last of Us Part II

Los NPCs enemigos en combate están todos en Full mientras el combat encounter está activo. Cuando termina y el jugador se aleja, entran a Dormant pero recuerdan su estado (heridas, munición). Si vuelves al sitio, despiertan en el estado correcto.

7. Errores comunes

  1. Approx con tick uniforme — todos los NPCs Approx tickeando el mismo frame. Pico cada N frames. Soluciónalo con stagger por id.
  2. No persistir estado en Dormant — el NPC olvida que estaba herido cuando despierta, o el inventario se resetea. Dormant es solo CPU, no memoria.
  3. fullRadius == approxRadius — todos están Full o Dormant, sin transición intermedia. Es como no tener LOD.
  4. Budget sin priorización — si los primeros 30 NPCs encontrados van a Full, podría tocarte 30 NPCs irrelevantes y los importantes quedan en Approx. Siempre score y sort.
  5. No mover en Dormant — si un NPC Dormant nunca se mueve, el mundo se siente muerto cuando vuelves. Solución: cada Dormant teleporta una vez por minuto a la siguiente parada de su rutina, sin animar.

8. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Tu juego tiene 1000 NPCs. Sin LOD, el frame está en 120 ms. Aplicas LOD con budget Full = 30 y approx_every = 4. ¿Estimación de costo?

  2. Disparas un cohete. Hay un NPC Dormant a 5 metros. ¿Qué debería pasar?

  3. Los NPCs en Approx 'tartamudean' visualmente — se ve que tickean cada N frames. ¿Causa probable?

  4. Marcas a 200 NPCs como 'important = true' para que nunca entren a Dormant. ¿Qué pasa?

9. Siguientes pasos

Con LOD para IA cierras la tanda de optimización: los algoritmos previos (BFS, A*, BTs, steering) tienen ahora la infraestructura para correr en miles de agentes sin caer FPS. Lo que viene en próximas tandas son los diferenciadores avanzados: generación procedural, utility AI, GOAP — herramientas que separan una IA correcta de una que se siente viva.