top of page
Writer's pictureNostalgiq

RPG Builder Mod: Improving SearchTarget Performance

Updated: Feb 7

Intro

This mod isn't glamorous in that it adds any new or interesting feature, but it should improve the performance of the SearchTarget method in the AIEntity class. In layman terms, you should be able to put more mobs into your game before you take a performance hit with this mod than vanilla currently allows for.


The current primary way to improve performance is to reduce the Max Distance from Player setting on the NPCSpawner. This despawns NPCs that are greater than that distance from the player. In a top-down game you could get away with having this at a low value since the camera field of vision is small. However in a third person RPG having this value too small will result in NPCs popping in and out of existence, which is not ideal.

 

What is the SearchTarget method?

It's a bit of code in AIEntity.cs that lets the AI find targets while they're standing around, roaming, or patrolling. This is the code that tells your AI there's something nearby that is eligible for it to attack.

 

First off, what led me to the point of wanting to optimize this function? I noticed that as I added mobs to my game the performance started to take a hit, and I wasn't sure why. I ruled out a number of things, but the Profiler led me to the SearchTarget method as you can see in the below screenshot.

These are firing off often, and their cost is non-trivial at 0.4%, and I've seen it as high as 0.7%. At 0.4% the theoretical cap for AI's in your game scene is 100/0.4 = 250. In practice it's lower since you aren't dedicating all your resources to just this one task.


I tracked down the code and here's what we've got as of RPGB 2.0.6:

public virtual CombatEntity SearchTarget (float viewDistance, float viewAngle, float autoAggroDistance)
{
    foreach (var entity in GameState.combatEntities)
    {
        if (entity == null || entity == ThisCombatEntity || entity.IsStealth() || entity.IsDead()) continue;
        if(!CanAggroTarget(entity)) continue;
        float distance = Vector3.Distance(transform.position, entity.transform.position);
        if (distance > viewDistance) continue;
        if (distance <= autoAggroDistance) return entity;
        var pointDirection = entity.transform.position - transform.position;
        var angle = Vector3.Angle(transform.forward, pointDirection);
        if (angle < viewAngle)
        {
            return entity;
        }
    }

    return null;
}

The code loops through all of the combatEntities in the game scene, irrespective of the actual location of the other NPCs. If we want to improve the performance of this bit of the code, one thing we need to do is limit the size of the foreach loop, as it currently grows as a permutation (each pair generates two SearchTarget checks). In other words, if there are 10 mobs there are P(10, 2) = 90 iterations. If there are 100 mobs there are P(100, 2) = 9,900 iterations!


The reality is that all games suffer performance issues when there's a lot of things going on in the game scene, and so optimization is always needed at some point.


What follows is my approach to limit the size of that list for each AIEntity that calls the SearchTarget method.


Solution

My requirements are as follows:

  • In practice there will not be dozens of mobs within 30 meters of each other, instead there will usually be just one, a few, or a handful...very precise I know :D

  • We want NPCSpawners to be active hundreds of meters away from the player.

  • The solution must be event-driven.


Consider the diagram above. There are 3 mobs that are "floating" by themselves, and 6 distinct groups in green that have some proximity to each other.

The current code would check P(19, 2) = 342 iterations in the foreach loop everytime the SearchTarget method is called. The upper bound for our code to check is P(4, 2) + 2*P(3, 2) + 3*P(2, 2) = 30. This is a reduction by almost an order of magnitude. I say upper bound, because in practice we don't need mobs to check each all the time if they're not potential enemies. Let's say only two of the mobs are hostile with each other, then in that case you would only have 2 checks happening at any given time. This is a huge difference!


Disclaimer: There is no guarantee or future support offered regarding these changes. I tested them out at the time I wrote them, and they worked within the scope of my needs. However, I am not the author of RPGB and therefore am not the authoritative source on whether these mods are complete. Use at your own risk, and always backup your projects.


Modded RPGB v2.0.6. New code additions are always highlighted in green, whilst RPGB code is always highlighted in blue.


My Solution

I introduce a new component that needs to be added to my NPC Prefabs. It consists of:

  • A script named AIEntityTracker.cs

  • A Rigidbody Component

  • A Sphere Collider

AIEntityTracker Script

using System.Collections.Generic;
using BLINK.RPGBuilder.AI;
using BLINK.RPGBuilder.Combat;
using UnityEngine;

namespace _Delora_Assets._Scripts
{
    public class AIEntityTracker : MonoBehaviour
    {
        public int potentialCombatEntityCount;
        public int trackingEntityCount;
        private SphereCollider _aiEntityTrackerSphere;
        private AIEntity _thisAIEntity;
        private readonly List<CombatEntity> _trackedCombatEntities = new List<CombatEntity>();

        private void Start()
        {
            _thisAIEntity = GetComponentInParent<AIEntity>();
            _aiEntityTrackerSphere = transform.GetComponent<SphereCollider>();
        }  
  
        private void OnEnable()
        {
            CombatEvents.PlayerFactionStanceChanged += DeloraRefreshCombatEntities;
            CombatEvents.PlayerDied += ClearCombatEntities;
        }

        private void OnDisable()
        {
            CombatEvents.PlayerFactionStanceChanged -= DeloraRefreshCombatEntities;
            CombatEvents.PlayerDied -= ClearCombatEntities;
        }

        public void OnTriggerEnter(Collider other)
        {
            if (other.CompareTag("AIEntityTracker"))
            {
                CombatEntity otherCombatEntity = GetCombatEntityComponent(other);
                _trackedCombatEntities.Add(otherCombatEntity);
trackingEntityCount = _trackedCombatEntities.Count;
                if (otherCombatEntity == null || otherCombatEntity.IsStealth() || otherCombatEntity.IsDead()) return;
                if (!_thisAIEntity.CanAggroTarget(otherCombatEntity)) return;
                _thisAIEntity.deloraCombatEntities.Add(otherCombatEntity);
                potentialCombatEntityCount = _thisAIEntity.deloraCombatEntities.Count;
            }
        }

        public void OnTriggerExit(Collider other)
        {
            if (other.CompareTag("AIEntityTracker"))
            {
                CombatEntity otherCombatEntity = GetCombatEntityComponent(other);
                _trackedCombatEntities.Remove(otherCombatEntity);
                _thisAIEntity.deloraCombatEntities.Remove(otherCombatEntity);
                trackingEntityCount = _trackedCombatEntities.Count; 
                potentialCombatEntityCount = _thisAIEntity.deloraCombatEntities.Count;
            }
        }

        public void DeloraRefreshCombatEntities()
        {
            foreach (CombatEntity otherCombatEntity in _trackedCombatEntities)
            {
                if (otherCombatEntity == null || otherCombatEntity.IsStealth() || otherCombatEntity.IsDead()) return;
                if (!_thisAIEntity.CanAggroTarget(otherCombatEntity)) return;
                _thisAIEntity.deloraCombatEntities.Add(otherCombatEntity);
            }
        }

        private CombatEntity GetCombatEntityComponent(Collider other)
        {
            CombatEntity otherCombatEntity = other.GetComponentInParent<MobCombatEntity>();
            if (otherCombatEntity != null)
            {
                return otherCombatEntity;
            }
            else
            {
                return other.GetComponentInParent<PlayerCombatEntity>();
            }
        }
        
        private void ClearCombatEntities()
        {
            _thisAIEntity.deloraCombatEntities.Clear();
            _trackedCombatEntities.Clear();
        }
    }
}

This script keeps track of eligible AI's that are colliding with each other. Only the eligible AI are able to be considered for SearchTarget checks. Eligible AI are defined by AI that meet the following criteria (this criteria was taken directly from the SearchTarget method above):

                if (otherCombatEntity == null || otherCombatEntity.IsStealth() || otherCombatEntity.IsDead()) return;
                if (!_thisAIEntity.CanAggroTarget(otherCombatEntity)) return;

The difference now is that this check only happens when the AI's are in proximity to each other, rather than all the time and irrespective of their location.


The AIEntityTracker Prefab

This prefab needs to be placed as a child on your NPC prefab, since it reaches into the parent for the AIEntity. It also needs to be placed on your Player prefab, but for the player you don't need the script, just the collider and rigidbody.


Make sure it's tagged with a tag named AIEntityTracker. This lets us register only collisions we're interested in. The Potential Combat Entity Count and Tracking Entity Count are simply for debugging, and let you see how many AI's an NPC is eligible to Search at any given moment. The method DeloraRefreshCombatEntities could be called, for example, anytime a faction changes, or you need to manually scan for AI's to update.


Mod to SearchTarget.cs

First you define a new list at the top that keeps track of the AI to check:

...
public class AIEntity : MonoBehaviour
{
    public List<CombatEntity> deloraCombatEntities = new List<CombatEntity>();
    [HideInInspector] public CombatEntity ThisCombatEntity;
    [HideInInspector] public float nextTargetCheck;
...

Finally, we replace the existing SearchTarget method with the one below. It's almost the same as the current implementation, with the top-level checks moved to the AIEntityTracker script.

public virtual CombatEntity SearchTarget (float viewDistance, float viewAngle, float autoAggroDistance)
{
    foreach (var entity in deloraCombatEntities)
    {
        float distance = Vector3.Distance(transform.position, entity.transform.position);
        if (distance > viewDistance) continue;
        if (distance <= autoAggroDistance)
        {
            CombatEntity targetEntity = GameState.combatEntities.Find(x => x == entity);
            return targetEntity;
        }
        var pointDirection = entity.transform.position - transform.position;
        var angle = Vector3.Angle(transform.forward, pointDirection);
        if (angle < viewAngle)
        {
            CombatEntity targetEntity = GameState.combatEntities.Find(x => x == entity);
            return targetEntity;
        }
    }

    return null;
}

Benchmarks

Below are two benchmarks I did checking on how the performance was. An NPCSpawner was used to spawn roaming AI's within a 10 meter radius for all of the tests. Post Roam means I checked the FPS after the mobs all stopped moving during their roam. Player Moving means I did a run through those mobs and checked the FPS. The blue line is the current implementation in RPGB 2.0.6. The red line is if SearchTarget is disabled. The orange line is with this mod. You can see that this mod allows for a huge performance boost. The current implementation was unplayable for me when around 80 mobs were in the scene, and the frames were in the single digits when there were over 120 mobs. This mod allows for you to have over 120 mobs in the scene and still maintain a very high framerate.




Final Thoughts

That's it! With these changes you should notice an immediate improvement in your framerate in situations where a lot of mobs are on the screen at the same time. Here is how the Profiler looked after I made these code changes in the zone we're working on in our game. Far fewer calls, and the calls that were made are very cheap (0.0% compared to the 0.4% I shared above).


It would also be a good idea if you don't have the possibility for NPCs to fight each other, to disable the ability for NPCs to check each other altogether, and only have them scan the player. No need to spend resources on a computation you'll never gain anything from after all.



Comments


Commenting has been turned off.
bottom of page