No3371 / DevBlog

0 stars 0 forks source link

Randomness in Unity ECS IJobChunk #15

Open No3371 opened 4 years ago

No3371 commented 4 years ago

Randomness in Unity ECS IJobChunk

Posted on HackMD

This article assumes you know Unity ECS.

Before ECS, most of our code runs within MonoBehaviour which is restricted to be on MainThread, so whenever we need Randomness, we generated the RNG with a seed and keep getting random values from it.

The only thing we care back then is assuring the random seed is different from time to time we use the RNG, we usaully use time for this purpose.

As you may know, with IJobChunk, Unity ECS perform your Execute() on all chunks of one same archetype to achieve high performance.

To schdule a IJobChunk, you ususally make a IJobChunk struct then call Scheduel()/ScheduleParallel(), this means you are passing a set of parameters into all job execution.

The Unity.Mathematics API comes with a Random struct which just like most Random API, needs a seed to initialize. If you wants random number per Job, you'd need a way to guarantee every job execution use different seed.

Include one Random struct into the IJobChunk then is definitely a no-no. For example you have 2 chunks of same archetype, that 2 IJobChunk execution will get identical values from the Random.

What about instead of a made Random struct, we include a seed into the IJobChunk? Unfortunately this is essentially same as including a Random which is a wrapper of a seed value.

So eventually, we need a value vary from execution to execution, to guarantee different RNG between jobs of same batch.

ThreadIndex

We are scheduling jobs to multiple threads, if there's something is guaranteed to be different between all parallel running jobs, it'd be the thread index.

Unity provides us a way to inject the thread index value into a int field: Unity.Collections.LowLevel.Unsafe.NativeSetThreadIndexAttribute

It's quite easy and handy, you simply tag a int field in your IJobChunk struct, then the value will be automatically injected at runtime.

[BurstCompile]
    struct RandomJob : IJobChunk
    {
        [Unity.Collections.LowLevel.Unsafe.NativeSetThreadIndex]
        public int threadIndex;
        public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
        {
            var random = new Random((uint) threadIndex);

        }
    }

Now, the question is: Does this guarantee randomness?

No.

Limited randomness

ThreadIndex is limited by the host machines CPU specs, this method provide you a limited randomness that guaranteed to work perfectly until, the total chunks begin executed exceeds your threads number.

In that case, more then one execution shares same thread index value, because a thread may continue working on another job scheduled of this IJobChunk type after it has finished one, which will results in identical random output sequence.

We need to introduce more factors for more possibilities.

Momentary randomness

If you take a look on Execute() signature, you'd notice there's chunkIndex and firstEntitiyIndex, which are basically the serial number of the chunk being operated on.

If we integrate these value into the Random seed, we can make seed duplication only happens when the job worker thread works on one same chunk twice.

[BurstCompile]
    struct RandomJob : IJobChunk
    {
        [Unity.Collections.LowLevel.Unsafe.NativeSetThreadIndex]
        public int threadIndex;
        public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
        {
            var random = new Random((uint) ((threadIndex + 1) * (chunkIndex + 1)));

        }
    }

That is basically impossible... for 1 Schedule().

It's already good enough for some, but as long as you keep scheduling the job, previous used seed will keep being generated again and again.

Almost full randomness

Take a look back on top of this article, you remember what did we use as RNG seed? Time.

We've already guaranteed that no jobs in one Schedule() will share RNG, let's just integrate a value vary from time to time.

We don't have to use the old UnityEngine.Time API, in SystemBase there's a inherited property Time, which contains a ElapsedTime value, storing the total cumulative elapsed time in seconds.

We pass this value into our jobs... Now we have full randomness.

[BurstCompile]
    struct RandomJob : IJobChunk
    {
        [Unity.Collections.LowLevel.Unsafe.NativeSetThreadIndex]
        public int threadIndex;
        public int randomSeed;
        public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
        {
            var random = new Random(((uint) ((threadIndex + 1) * (chunkIndex + 1) * randomSeed) *& uint.MaxValue));

        }
    }

    protected override void OnUpdate()
    {
        var job = new RandomJob()
        {
            randomSeed = (int) (Time.Elapsed*100+1),
        };

        Dependency = job.ScheduleParallel(m_Group, Dependency);
    }

Full randomness

At this stage, since the start of your game/system, you job can always get random value, but you get same RNG everytime you restart your game.

That means we have to introduce one more factor that makes everytime you start your game, the RNG seed would be different.

It's quite simple, just use time of day.

    double baseTime = System.DateTime.Now.TimeOfDay.TotalSeconds;
    protected override void OnUpdate()
    {
        var job = new RandomJob()
        {
            randomSeed = (int) (float) (baseTime + Time.ElapsedTime*100),
        };

        Dependency = job.ScheduleParallel(m_Group, Dependency);
    }