I made the same thing with ECS and classic C# ways and got similar performance results

365 Views Asked by At

Well, there is simple AI logic. In Idle state, each cube finds the closest cube as target and follows them until reaches it than, it searches another closest cube. I tried it with both C# and unity ECS but got similar performance results (both are about 70 fps ). Here are main parts of the codes.

C#:

public class SpawnCubes : MonoBehaviour
{
    public GameObject prefab;
    public float radius;
    // Start is called before the first frame update

    public CubeClass[] cubes;

    public float speed, stoppingDistance;
    void Start()
    {
        int count = 2000;

        cubes = new CubeClass[count];

        int id = 0;

        for(int i=0; i<count; i++)
        {
            Vector3 spawnVec = new Vector3(Random.Range(-radius, radius), 0, Random.Range(-radius, radius));

            Transform t = Instantiate(prefab, spawnVec, Quaternion.identity).transform;
            CubeClass c = new CubeClass();

            c.id = id;
            c.self = t;

            cubes[i] = c;

            id++;
        }
    }

    private void Update()
    {
        for(int i=0; i<cubes.Length; i++)
        {
            CubeClass cube = cubes[i];

            if(cube.state == HumanState.Idle)
            {
                float distance = 9999999f;
                int closest = -1;

                for (int k = 0; k < cubes.Length; k++)
                {
                    CubeClass cube2 = cubes[k];

                    float d = Vector3.SqrMagnitude(cube.self.position- cube2.self.position);

                    if (d < distance && d > stoppingDistance * 2f)
                    {
                        distance = d;
                        closest = cube2.id;
                    }
                }

                if(closest != -1)
                {
                    cube.targetId = closest;
                    cube.state = HumanState.Chasing;
                }
            }
            else
            {
                for (int k = 0; k < cubes.Length; k++)
                {
                    CubeClass cube2 = cubes[k];

                    if(cube2.id == cube.targetId)
                    {
                        Vector3 dir = cube2.self.position - cube.self.position;

                        if(dir.sqrMagnitude < stoppingDistance)
                        {
                            cube.state = HumanState.Idle;
                            break;
                        }

                        dir = dir.normalized;

                        cube.self.position += dir * Time.deltaTime * speed;

                        break;
                    }
                }
            }

            

        }
    }
}
public class CubeClass
{
    public HumanState state;
    public Transform self;

    public int id;

    public int targetId;
}

ECS:

public partial struct HumanMoveSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<HumanProperties>();
    }

    [BurstCompile]
    public void OnDestroy(ref SystemState state)
    {

    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        Entity humanP = SystemAPI.GetSingletonEntity<HumanProperties>();

        GameStateAspect gameStateAspect = SystemAPI.GetAspect<GameStateAspect>(humanP);

        GameStateType type = gameStateAspect.Value;

        if (type != GameStateType.Started)
        {
            return;
        }


        float deltaTime = SystemAPI.Time.DeltaTime;

        NativeList<float3> aspects = new NativeList<float3>(Allocator.Persistent);
        NativeList<int> ids = new NativeList<int>(Allocator.Persistent);

        foreach (var human in SystemAPI.Query<HumanActiveAspect>())
        {
            aspects.Add(human.transform.ValueRO.Position);
            ids.Add(human.stats.ValueRO.id);
        }

        MoveJob job = new MoveJob();

        job.aspects = aspects;
        job.ids = ids;

        job.deltaTime = deltaTime;

        job.Schedule();

    }

    [BurstCompile]
    public partial struct MoveJob : IJobEntity
    {
        public float deltaTime;
        public NativeList<float3> aspects;
        public NativeList<int> ids;
        void Execute(HumanActiveAspect aspect)
        {
            if (aspect.Value != HumanState.Chasing) return;

            for (int i = 0; i < aspects.Length; i++)
            {
                if (ids[i] == aspect.stats.ValueRO.targetID)
                {
                    if(aspect.Move(deltaTime, aspects[i]))
                    {
                        aspect.stats.ValueRW.state = HumanState.Idle;
                    }
                }
            }
        }
    }
[BurstCompile]
public partial struct HumanIdleSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<HumanProperties>();
    }

    [BurstCompile]
    public void OnDestroy(ref SystemState state)
    {

    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        Entity humanP = SystemAPI.GetSingletonEntity<HumanProperties>();

        GameStateAspect gameStateAspect = SystemAPI.GetAspect<GameStateAspect>(humanP);

        GameStateType type = gameStateAspect.Value;

        if (type != GameStateType.Started)
        {
            return;
        }




        float deltaTime = SystemAPI.Time.DeltaTime;

        NativeList<float3> aspects = new NativeList<float3>(Allocator.Persistent);
        NativeList<int> ids = new NativeList<int>(Allocator.Persistent);

        foreach (var human in SystemAPI.Query<HumanActiveAspect>())
        {
            aspects.Add(human.transform.ValueRO.Position);
            ids.Add(human.stats.ValueRO.id);
        }

        HumanChooseTargetJob job = new HumanChooseTargetJob();
        job.ids = ids;

        job.deltaTime = deltaTime;
        job.aspects = aspects;
        job.Schedule();

    }
    
    [BurstCompile]
    public partial struct HumanChooseTargetJob : IJobEntity
    {
        public float deltaTime;
        public NativeList<float3> aspects;
        public NativeList<int> ids;
        void Execute(HumanActiveAspect aspect)
        {
            if (aspect.Value != HumanState.Idle) return;

            int closest = -1;
            float distance = 99999999f;

            for(int i=0; i<aspects.Length; i++)
            {
                float3 dir = aspect.transform.ValueRO.Position - aspects[i];

                float d = math.lengthsq(dir);

                if (d < distance && d > aspect.stats.ValueRO.stopDistance * 2f)
                {
                    distance = d;
                    closest = ids[i];
                }
            }

            if (closest == -1) return;

            aspect.SetTarget(closest);
            aspect.stats.ValueRW.state = HumanState.Chasing;
        }
    }
}

public readonly partial struct HumanActiveAspect : IAspect
{
    public readonly Entity entity;

    public readonly RefRW<LocalTransform> transform;

    public readonly RefRW<HumanStats> stats;

    public HumanState Value => stats.ValueRO.state;

    public int ID => stats.ValueRO.id;

    public float ReturnDistance(float3 pos)
    {
        return math.distance(pos, transform.ValueRO.Position);
    }
    
    public bool Move(float deltaTime, float3 target)
    {

        float3 direction = target - transform.ValueRW.Position;

        float magnitude = math.lengthsq(direction);

        if (magnitude < stats.ValueRW.stopDistance) return true;

        transform.ValueRW.Position += direction * deltaTime * stats.ValueRW.moveSpeed / magnitude;

        return false;
    }

    /*public (bool away, bool targetDead) Attack(float deltaTime, float3 target)
    {

        float3 direction = target - transform.ValueRW.Position;

        float magnitude = math.length(direction);

        if (magnitude < stats.ValueRW.stopDistance) return (true, false);

        bool targetDead = IDMaker.Attack(stats.ValueRO.targetID, 10);

        return (false, targetDead);
    }*/

    public void SetTarget(int id)
    {
        stats.ValueRW.targetID = id;
    }
}

public enum HumanState { Idle, Chasing, Attacking, None }
public struct HumanStats : IComponentData
{
    public HumanState state;
    public int health, maxHealth;
    public float moveSpeed;
    public float stopDistance;

    public int targetID;

    public int id;
}

1

There are 1 best solutions below

0
On

I think you need to understand two things here:

  1. Burst compiler makes mathematical calculations faster by turning any math into packed simd operations.
  2. ECS (as it is implemented in Unity) is a set of tools to help you structure program data in arrays to enable use of fast linear read/write access patterns.

It means that:

  1. If your program has little need for mathematical calculations (algebra, trigonometry, vector and/or matrix transformations etc) then your program will not benefit from Burst.

  2. If your program requires that memory is rarely to be read linearly or most of that read is wasted* then it will not benefit from all this ECS infrastructure.

  3. Programs with little math and random memory access patterns will be as fast as anywhere else.

*wasted = data takes space in a struct but is not read by given code

PS: Also make sure Burst compilation is enabled Jobs/Burst/Enable Compilation.

MonoBehaviour implementation

Here, as FightCube.cs, is an example of faster implementation you can compare with:

https://gist.github.com/andrew-raphael-lukasik/9582abd02b237b30d7abbdeb5c5d5b51

FightCube

What I changed:

  • Got rid of GameObjects (you can't have thousands of these and expect good perf because these were designed in OOP style)
  • I laid out the data in struct arrays to make it's data close to each other (MyClass[] arrays will be spread all over the RAM)
  • I made sure to read memory only when necessary & in linear sequence where possible (ie. read index 0, 1, 2, 3 and not randomly 3, 0, 21, 2)

2000 instance wasn't even sweeting my CPU. But at 20k instances CPU started to get visibly slow. To push performance of this code much further the code that finds nearest neighbor needs to be replaced with a Octree or something like that.

2k instances

This should answer why your MonoBehaviour code is slow.

ECS com.unity.entities 1.0.11 implementation

FightCubeAuthoring.cs under the same link contains implementation that shows some good practices of writing ECS code for com.unity.entities 1.0.11

https://gist.github.com/andrew-raphael-lukasik/9582abd02b237b30d7abbdeb5c5d5b51

Some of my suggestions:

  • Abandon IAspects as it makes code less transparent. It's main lure of convenience is not worth this cost.
  • Don't default to Persistent allocators. Allocator.Persistent is for data that needs to exist for multiple frames or application lifetime. In OnUpdate method use either Allocator.Temp (fastest allocation) or Allocator.TempJob (fast alloc + can be used by jobs).
  • Break big IComponentData, like this HumanProperties, into many small that holds singular bits of information.
  • Avoid iterations of O(n²) type. You can get away with them while number of n elements in is in dozens of maybe in hundreds but it will blow up once you reach thousands for sure. Optimizing or removing this loop is critical for performance stability as n grows.
  • Prefer use of state.RequireForUpdate<MySingleton>(); to makes system wait (not call OnUpdate) until specific singleton exists. This singleton can be a system state or a game state (like: game_started,game_paused etc).
  • Avoid allocating sth every frame. But exceptions exist; if allocation makes program faster then it's good one.
  • math.lengthsq is not an optimization. Focus on how you read memory and try to make these reads linear & tiny, get rid of unnecessary ones where possible - this is optimization that gives big results. Burst will optimize the hell out of any math you give it. But Burst can't make AMOUNT of DATA to calculate smaller - this is where you come in.