I don't know how to use ECS with my existing code

Hello everyone, I’m pretty new to Unity ECS, and I’m currently struggling to understand how I can apply it to my existing code.

For my project, I’m using a library to handle a large amount of bullets (I would like to handle 5000 of them at the same time). This library give me a list of bullet elements with a position, a rotation, a scale and a color.

Until now, I was using the Unity particle system (Shuriken) to display these bullets, doing something like that:

public void Update()
{
    var bulletParticles = new ParticleSystem.Particle[_bullets.Count];
    _particleSystem.GetParticles(bulletParticles);
  
    for (int i = 0; i < bulletParticles.Length; i++)
    {
        bulletParticles[i].position = _bullets[i].Position;
        bulletParticles[i].rotation = _bullets[i].Rotation;
        bulletParticles[i].startColor = _bullets[i].Color;
        bulletParticles[i].startSize = _bullets[i].Scale;
    }
  
    _particleSystem.SetParticles(bulletParticles, bulletParticles.Length);
}

But the particle system is not really done for that, so I would like to switch to the new ECS to have a better control.

I started doing a simple test like that:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
public static void Initialize()
{
    _entityManager = World.Active.GetOrCreateManager<EntityManager>();
}

private static void SpawnBullet(int index)
{
    var entity = _entityManager.CreateEntity(
        ComponentType.Create<Position>()
        ComponentType.Create<TransformMatrix>()
    );

    _entityManager.AddSharedComponentData(entity, _renderer);
}

So with this I can create entity with a simple Position component. Then, I create a system to move them according to the bullets position:

public class EntityMovementSystem : JobComponentSystem
{
    public struct Data
    {
        public readonly int Length;
        public ComponentDataArray<Position> Position;
    }

    [Inject] private Data data;

    [Unity.Burst.BurstCompile]
    struct PositionJob : IJobProcessComponentData<Position>
    {
        public void Execute(ref Position position)
        {
            // How can I know this Position component correspond to the proper bullet?
            position.Value = new float3(
                BulletManager.Bullets[i].Position.x,
                BulletManager.Bullets[i].Position.y,
                0f
            );
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var positionJob = new PositionJob();
        return positionJob.Schedule(this, 64, inputDeps);
    }
}

And here is my problem. When the Execute method is called, how can I have an index to make sure the position I update corresponding to the same bullet from my collection?

Is this the correct way to do what I want?

Thank a lot for your answer in advance!

ECS does not have much affinity with ordering by its nature unfortunately. Whether you use [Inject] or IJobProcessComponentData the idea is just to “do this to everything matched equally” without caring which one.

When you stop treating all items equally, trying to add ordering or add an “identifier” to specific item it always come at cost with ECS :

  • You can sort the data as a solution, which take time and should be avoided doing every inject. You can sort and cache them once in your bullet manager, but then those cached sorted data will not be update together with ECS database anymore so you will have to fight with some more bugs like forgetting to clear them at the end, etc.

As far as I know you cannot yet “sort and apply” and have the new ordering applied to the ECS database so that the next time they came out the way you want. ECS data is packed in a special way that it is not possible to touch them and not screw the system up.

  • You can assume the same ordering across multiple times of OnUpdate() injection refresh only if you are not going to touch their archetype at all. In my game even in some case I am really sure I would not touch them anymore and starting to assume the ordering (like an inject with always 5 items) It runs bug-free until someday I completely forgot about my own rule and touch them, those 5 items now have different ordering. It is very error-prone. Not recommended.

In this case I am assuming you are making a bullet hell/danmaku-type game. It is unlikely that all bullet will have its own unique behavior but usually it is a large amount of bullet with exactly the same behavior, just with a different starting condition (like position, some constant value, etc.) So when you set them off can go by its own automatically just from those starting condition.

The “same behaviour” can then be a system with IJobProcessComponentData to process every bullet equally (no need to know which bullet anymore), but whats not equal is that the bullet goes according to its own internal data e.g. your Position could be renamed to BulletPatternA. In addition to containing position now also a patternNumber, so a bullet with 1 goes straight and a bullet with 2 goes in bezier curve. Or contains Facing, so even a bezier curve bullet starting at the same position can go in different way creating a flower blooming pattern. etc. Your system logic which process bullet “equally” now use a switch case on that. If you want more pattern that is radically different and not appropriate to do a switch case anymore, you can create BulletPatternB with a new system to handle them.

Then you can now mix and match pattern. If you add both pattern A and B to one bullet then 2 systems will process it. From this idea instead of BulletPatternA with concrete pattern you can make the component into a smaller task like BulletAccel, BulletCurve, BulletLifetime, BulletNoise, BulletWaypoint, etc. then when designing a bullet it is just a matter of adding various components with differing data. Like adding BulletAccel with value = -0.5 for a decelerating bullet which goes by BulletWaypoint that contains several float2 coordinates, which disappear in 3 seconds according to attached BulletLifetime with value = 3. There are system to do each task for each component.

Also an another approach, the ECS framework has some transform/2D transform component with position, facing, speed etc. with matching systems to move them already. Maybe it is possible to leave the moving to those built-in systems, but before that your own systems will vary the components so that the final movement becomes what you want. e.g. to do bezier movement you try to vary only the facing, and let the built-in transform system to move them (But for better understanding I think writing your own systems completely without using the built-in magic is the way to go.)

Thanks a lot for your answer 5argon,

As you said, I don’t really want to do some sorting stuff, but just iterate over all my entities to change their data according to my bullet list.

Moreover, as you guessed, it’s for a bullet hell, and I use a library called BulletML that computes the bullets behaviour from an XML file. I can’t (and don’t want to) include bullet’s movement logic in my code, but just retrieve the data (position, rotation, scale, color) given by BulletML to display them in my game. The BulletML computation (the bullet.Update()) is currently not parallelized (I planned to look at it later), but the bottleneck for now is the display of a large amount of moving elements, which I can optimized using ECS I think.

I continued my searches, and found that what I would like could be done using a IJobParallelFor because the Execute method has an index as argument. If I have the same number of entities than the bullets in my list, I could work as I expect. But my early tests was not really good, I don’t really understand the value of the index received in the Execute method, it can be huge and I don’t know why for now…

So to use IJobParallelFor correctly, for the Schedule() functrion:

  • First argument should be either the length of the data array to be processed or a NativeList (it’s size will be deduced automatically when the job is run, you can pass it earlier and fill / remove elements earlier in a job sequence).
  • The 2nd parameter is always the batch size to run in (IJobParallelFor can run across multiple cores, the batch size determines the largest batch) and if there’s leftover elements smaller than the batch size (or the array is smaller than the batch size) it’ll still work correctly.
  • The final parameter is the job dependency.

For the index, it’s simply the corresponding index of the original array size.

Given a NativeArray: {0, 1, 2, 3, 4}

And a job:

struct AddOneJob
{
    public NativeArray<int> SomeInts;
    public int value;

    public void Execute(int index)
    {
        // for this job, index is the index in the original array [0,5)
        SomeInts[index] = SomeInts[index] + value;
    }
}

// then to use it...

protected override JobHandle OnUpdate(JobHandle inputDeps)
{
    var len = m_SomeInts.Length;
    if(len == 0) return inputDeps;
    
    var addOneJobHandle = new AddOneJob
    {
        SomeInts = m_SomeInts,
        Value = 5,
    }.Schedule(len, 32, inputDeps); // will process each add in batches of 32 or remaining len elements, can tune to find optimum batch size, if have multiple batches, can be split across multiple cores by scheduler. Cores Will work-steal from unprocessed batches if the opportunity opens up

    return addOneJobHandle;
}

Hopefully that clarifies it. Note you can use an array size that isn’t a 1 to 1 of the source data size IFF you have an appropriate mapping (like if you’re adding every odd and even index together and storing the result in a half-size array, you would use the half-size) with additional qualifiers.