AI in ECS. What approaches do we have?

In the beginning, we had spaghetti hard-coded If/Else AI.
Then, we had finite state machines.
Behaviour trees were born.
Goal Oriented Action Planning gave us new possibilities.
Unity is working on their own AI Planner which seems to be GOAP focused.

Now, it seems the future holds the promise of performance by default via Data Oriented Technology Stack with ECS and friends. But as I play with ECS, I feel as though I’m thrust back into the dark ages of AI. I struggle to reconcile the concept of a Behaviour Tree with with Systems, Components, and linear data access. I wonder how the pieces fit together, how I could promote rapid iteration of AI design, how to generally create an intelligently acting agent that’s not a nightmare to change and improve on.

How do you approach AI for your ECS entity agents? Have you found anything that works for you? What options do we have?

4 Likes

ECS IJobForEach is about table processing. It processes components on the same entity in linear order in the most optimal way possible.

Some problems can’t be expressed with linear processing of memory. ECS code doesn’t require you to do that. It just encourages it for getting the best performance where it is possible.

ComponentDataFromEntity combined with a component with Entity myOtherEntity; referencing another entity gives you all the flexibility you need to represent anything from a graph, referencing other entities, consuming/writing their data etc.

ComponentDataFromEntity<> implies random memory access but if the problem you are solving is inherently not a linear memory access problem thats fine. Moving your code to a a Job and using Burst will still give you a massive speedup. And if you are only reading data from a ComponentDataFromEntity<> in a job you can easily IJobParallelFor it, thus gaining parallelism on top of it.

I’d recommend that when learning ecs for AI:

  1. Use the simplest possible but jobified approach (IJobForEach.Schedule & IJobForEach.ScheduleSingle) By writing jobified & bursted code you learn the constraints that unlock performance. Also i find that code gets cleaner as you get better at writing this kind of code and understand how to split jobs in a good way.
  2. Use ComponentDataFromEntity<> don’t worry about random memory access too much
  3. Avoid using ISharedComponentData as a solution, it’s 95% of the time not something you should use for AI code
  4. If you need arrays on an entity remember you have DynamicBuffer for that

Find out how you can express your code in the simplest possible way without much boiler plate.
I would expect that even though this approach isn’t linear memory access you can still have 10-20x speedups over writing normal mainn thread MonoBehaviour style code. That is often enough of a speedup.

27 Likes

Thanks man this sounds like some really good advice. I suppose I was constraining my thinking too much by thinking about linear data access. I didn’t quite realise that you could expect such a speed up even with random memory access. I’ll go over my previous thought processes without the artificial linear constraint, and a more liberal use of entities and entity references and see what I come up with.

Cheers!

Can give some insight as I have been working on a 3rd person shooter w/ ECS for some months. I’m using Utility AI and plan to explore implementing a HTN planner next. Units make decisions based on scores for score in a UtilityPlanningSystem, when their ActorBrain currentDecision != lastDecision the system will add appropriate components to make them perform action.

EX: Flee, Pursue, Wander all give a Moving tag. This tag allows the entities to start the pathfind proccesse that looks like this:

QueryPositionsIndividualJob : IJobProcessComponentDataWithEntity<MovementRequest,ActorBrain,Target>

The PositionQueryJob reads ActorBrain to determine the specifications of the PositionQuery based on current action.l When a position is found another PathRequest component is added and their MovementRequest.ready flag is set to 0. With a successful position found a PathRequest tag is added

With the PathRequest tag on, the unit will begin pathfinding and if the path fails the MovementRequest’s sample size is increased to allow more eligible points.

With the PathRequest and a Moving tag that was placed based on if the action is moving they will follow their path.

If the action changes to something like Shoot or Reload which are both stationary, the Moving tag is removed and they won’t follow their current path. The PathRequest is removed if a PathRequest exist but the entity is not moving.

Actions like reload and shoot are handled in two different ways which I will alter when I have more actions. Shooting is like this and there are UnInterruptible Actions that need to be finished before another one can be selected.

For handling damage the shooting job adds the Entity held in the Target component to a NativeMultiHashMap with some information about the damage they attacker is inflicting. This is a nice solution because we avoid collisions that would come from writing to the same Health component. Using this queue also lets me simulate AI accuracy as we can use the stats from the job to simulate misses and hits.

public struct EndActionJob : IJobProcessComponentDataWithEntity<UnInteruptableAction>{
        [ReadOnly]public EntityCommandBuffer buffer;
        public void Execute(Entity entity, int index, [ChangedFilter]ref UnInteruptableAction actionLock){
            if(actionLock.finished != 1)
                return;
               
           
            buffer.RemoveComponent<Reload>(entity);
           
           
            buffer.RemoveComponent<UnInteruptableAction>(entity);
        }
    }

    [RequireComponentTag(typeof(Shoot))]
    public struct EndShootingJob : IJobProcessComponentDataWithEntity<ActorBrain>{
        [ReadOnly]public EntityCommandBuffer buffer;

        public void Execute(Entity entity, int index, [ChangedFilter][ReadOnly] ref ActorBrain actor){
            if(actor.currentAction != DecisionFlag.Shoot){
                buffer.RemoveComponent<Shoot>(entity);
                buffer.RemoveComponent<Audible>(entity);
            }
        }
5 Likes

In my game I use something that looks similar to Goal Oriented Action Planning, althought I am not quite sure if it is exactly the same since I didn’t hear of it before.
[EDIT]: in fact I think it is not like GOAP since I don’t really have a global planner

I have several “goals” and “actions” that can be performed simulteously or not (in that it differs from a behavioural tree I guess).

Goals are generally represented by a single component attached to an entity, for instance an Attack goal component would be added to ask an entity to attack something, but it will not specify in which way. The Escape goal component can also be added to tell an AI that it should escape a danger, but it doesn’t specify how to escape. The jobs that process these components can choose which action to perform to achieve this goal depending on the situation. I also have something like an Farm component to ask the agent to farm.

If an agent has no goal, then the IdleJob (a job component system), will assign one depending on the current situation. For instance it will ask an agent to attack by adding the Attack component to it. I also have another job, the ShouldEscapeJob that add the Escape goal at any time. That means that an agent can be at the same time in the attack and escape states, which allows him to escape and still attack another agent if he has this opportunity. The EscapeJob is then in charge of making any agent with the Escape component well… escape. When escaping is a success, it can then have a fully agressive strategy by removing the Escape component since it is not required anymore. The jobs handling goals can also cancel their goals or planify another depending on the situation, still by adding/Removing goal components.

Some goals however cannot be achieved while the agent has some other goals. These priorities are simply handled by adding [ExcludeComponent] tags to the jobs. For instance I don’t want an agent to try to farm while escaping. Thus the FarmJob will have [ExcludeComponent(typeof(Escape))]. Once the escape is done, it can go back to farming again.

Then I have actions. Actions are also represented by components, such as ReachAction, FireAction, PickAction, etc. and they each have corresponding jobs that perform the action, or cancel it if it cannot be done anymore (this is simply done by removing the action component). The goal systems use actions to achieve their intended goal by adding different actions depending on the situation. Dependencies between systems can help to prioritize some over others.

On top of that, I have a pathfinder job that uses costs computed from game-specific heuristics to also help the AI choose more “intelligent” paths when moving arround (this is allows to have some kind of “passive” actions like picking things on the way or avoiding danger areas when possible).

I don’t think this is the best approach but in my case it is working quite well and provides very interesting behaviour.

5 Likes

Both very interesting posts! I’ve been imagining a system similar to flobocs, but also trying to think of understand how some other tools come into play like riskparitygawd’s solution.

I’ve been considering modelling stretegic/tactical/operational layers where strategic might run a behaviour tree once every couple of seconds to make a decision which gets applied as a goal or decision component, tactical would run more often also probably utilising tags but requiring less data perhaps, and operational would just be your if/else in systems, running every frame as data oriented as possible.

I think in the end changing structure with tags seems like the ecs way for the most part, but maybe with these layers archetypes can stay relatively stable, and I can throw in random memory access structures on the higher layers.

Like @riskparitygawd I too have been thinking about implementing Utility AI in ECS, following Dave Mark’s “Infinite Axis” model.

There will probably be a lot of random memory access unless I first do a “data gathering” phase and then process the data and calculate scoring.

Good comment here by Dave himself with links to some of his lectures if you are interested:
https://www.reddit.com/r/gameai/comments/5paxt2/utility_based_ai/

Oh and he also wrote this excellent book on the subject :slight_smile:
https://www.amazon.com/gp/product/B00B7REBDW/

3 Likes

My response is not regarding ECS directly, but I created other day Utility AI thread and there is simple Monobehaviour based Utility AI script.

I suggest check out.
** Utility AI (Discussion) **

Then you can based on that, try to create ECS version.

Just a thought.

2 Likes

Great post Antypodish,

I’ll move some of my utility-specific discussion to that thread.

I’ve been working on a utility system in our MMO sim game Seed (https://seed-project.io/) for around a year now. While it works pretty well we’ve been dealing with some perf/scalability issues I’m hoping to address with a slightly different architecture and leveraging DOTS.

We also made the mistake of relying too much on dynamic ranks instead of using the Inifnite Axis model for scoring (basically each action can have a varying number of scoring factors).

2 Likes

My implementation is also based on Dave’s Infinite Axis, I’m enjoy its performance and extensibility right now for individual behaviors. I implemented Archetypes/DecisionSets with a monobehaviour containing a scriptable object that links the Actor Entity to an entity that has a buffer of eligible decisions as enums. Whole thing uses a static class and is very fast. Well worth the time.

2 Likes

Very cool :slight_smile:

I’m curious to hear how you structured your “discovery and scoring flow”. ie when an agent is looking for possible decisions, I assume you start by running some query in a job?

An ECS query could give you a set of all currently possible activities, but then they need to be individually scored for each agent (who can have different stats, locations etc).

Could you give me a rough outline of your decision flow?

There’s a Detection System that keeps track of eight eligible targets in DynamicBuffers. All actors score decisions in a single IJobProccessComponentDataWithEntity (IJobForEach now?). If an action should look at multiple targets it will ScoreDecision for each target.

Decisions look like this and there’s a switch case to get the right raw score then transform it with a response curve.

public static float ScoreDecision(int decision,
        Entity unitEntity, Entity target, float bonus, float min,
        UnitStats unitData, Health health, Weapon weapon, ComponentDataFromEntity<Translation> positions, int visibility = 0
    ){
        switch(decision){
            case (int) DecisionFlag.Shoot:
                var array = new NativeArray<int>(4,Allocator.Temp);
                array[0] = (int) ConsiderationFlag.HasLineOfSight;
                array[1] = (int) ConsiderationFlag.TargetInRange;
                array[2] = (int) ConsiderationFlag.HealthHigh;
                array[3] = (int) ConsiderationFlag.RemainingAmmoHigh;
                var score = Score(array, unitEntity, target, bonus, min, unitData, health, weapon, positions,visibility);
                array.Dispose();
                return score;

The scoring method.

public static float Score(
        NativeArray<int> considerations, Entity unitEntity, Entity target, float bonus, float min,
        UnitStats unitData, Health health, Weapon weapon, ComponentDataFromEntity<Translation> positions, int visiblity
    ){
        float finalScore = bonus;
        foreach(int consideration in considerations){
            if((0.0f > finalScore || (finalScore < min)))
                return 0.0f;
           
            float score = GetConsiderationScore(consideration, unitEntity, target, unitData, health, weapon, positions, visiblity);
            float response = ComputeResponseCurve(consideration,score);

            ///<remark> All consideration scores  are multiplied together to get a single Decisions utility. </remark>
            finalScore *= math.clamp(response,0.0f,1.0f);
        }
        ///<remark> A compensation factor is added for the varying lengths of considerations per decision. </remark>
        var modF = ((1-finalScore)*bonus) * (1 - (1/considerations.Length));
        return finalScore + math.abs((modF * finalScore));
    }
1 Like

Switching from foreach to for in NativeArray will save you ton of performance.

Does it? I thought it doesn’t matter anymore, while in past foreach was indeed slower.
Does ECS compilator and burst treats it differently?

https://jacksondunstan.com/articles/4713

6 Likes

This is good reading. Thx.
I mean, I always used FOR loop instead FOREACH whenever I could.
I haven’t tested myself. But I would thought, burst will be clever enough, to reduce FOREACH, into FOR.
Good lesson anyway. :sunglasses:

2 Likes

In that particular code the array is just unnecessary indirection and a performance hit. I would take all that code put it into a struct with an initializer that takes all the data except the decision id. Then a method that takes the decision id and calls an eval method for each consideration flag.

GetConsiderationScore is also suspect, I wonder if there is work being done there that’s the same per consideration flag, that could be moved higher up in the logic chain.

Also, for evaluating curves generally you should look at caching. Ai is not as sensitive precision wise as say things related to rendering. Rounding inputs to some value that makes caching memory efficient can give big gains in performance here.

2 Likes

I was not looking into the implementation. I just gave a suggestion to everyone who uses NativeArrays in their code.

1 Like

Are you familiar with the UtilityAI? I don’t see a more efficient way to score a decision, you are still going to need to have an identifier for each Action, the point of the NativeArray is that at some point Decisions can be created inside of the editor with modular actions. The goal is to represent Decisions as structs containing ConsiderationDefinitions that let designers create behaviour as in Mark Dave’s talks, but these would still be in a NativeArray.

GetConsiderationScore is a switch case that just gets the right part of the state and score’s it, then uses a static function depending on what the consideration is to get the raw score.

case (int) ConsiderationFlag.DoesNotHaveLineOfSight:
                return visiblity < 2 ? 1.0f:0.0f;
case (int) ConsiderationFlag.TargetInRange:   
                return Consideration.TargetInRange(unitData, positions[unitEntity].Value, positions[target].Value);

Curves

public static class ResponseCurve{
    public static float Exponentional(float x, float power=2){
        return math.pow(x,power);
    }

    public static float Linear(float x,float slope=1){
        return x*slope;
    }

    public static float Decay(float t, float mag){
        return math.pow(mag,t);
    }

    public static float Sigmoid(float t, float k){
        return k*t/ (k - t + 1);
    }
}
public static float TargetInRange(UnitStats unit, float3 unitPos, float3 enemyPos){
            return math.clamp((math.distance(unitPos,enemyPos) - unit.min_range) / (unit.max_range - unit.min_range), 0.01f, 1.0f);
}

This is definitely not the optimized version, I could see calculating the raw % for ammo and health as great optimizations but, optimizations like that are not the goal when first implementing something like this. Now that it’s mentioned I’ll definitely get around to it one day. The bonus factor and minimum score generally discard most decisions so we aren’t doing that many calculations.

In terms of caching it could be helpful if you elaborate but there is a momentum bonus given to actions and prioritization to what Decisions are scored first so that we just skip the least important actions, nothing is allocated for them. Running the system on a timer would help too but it’s pretty fast too. I’m dumping mad NativeArrays in the position query system too and it runs pretty fast. All insights are appreciated though.

Thank you for the reminder, that was actually there in order to test whether or not it was slower but I never changed it back.

2 Likes