Unity Coroutine combined delay is longer than expected with multiple WaitForSeconds

109 views Asked by At

I am starting a coroutine which iterates a loop number of times and waiting a certain time between each iteration. But complation of the loop takes way more longer than the expected. Here is the code:

private IEnumerator CO_AddBladesInTime(int count, Blade blade)
    {
        var _spawnPos = blade.transform.position;
        var _counter = 0;

        var _firstTime = Time.time;
        var _calculatedTime = 0f;

        Debug.Log("entered coroutine at " + _firstTime);

        while (_counter < count)
        {
            _counter++;
            // AddNewBlade(_spawnPos, blade.Level);
            var _waitTime = bladeSpawnFreq / count;
            _calculatedTime += _waitTime;
            yield return new WaitForSeconds(_waitTime);
        }

        var _secondTime = Time.time;
        Debug.Log("exited coroutine at " + _secondTime);
        Debug.Log($"waited {_secondTime - _firstTime}");
        Debug.Log($"expected wait time {_calculatedTime}");
    }

Here is the results:

Log

Am I missing something?

1

There are 1 answers

0
derHugo On

As was mentioned before currently your Coroutine will wait for a minimum of count frames.

WaitForSeconds basically kind of works similar to

for(var time = 0f; time < timeToWait; time += Time.deltaTime)
{
    yield return null;
}

so even if the timeToWait is very very small like e.g. 0.00001 it will still wait for an entire frame - which assuming a frame-rate of 60 f/s has a length of 0.017 seconds!

Also not sure why you did use this

var _waitTime = bladeSpawnFreq / count; 

I would assume bladeSpawnFreq already refers to how many objects to spawn per second. Instead I would rather expect you to calculate

var _waitTime = 1 / bladeSpawnFreq;

the time in seconds between two spawns.


So this might be going too fancy now and there might be more straight forward solutions but what you could do is going asynchronous.

// Wrapper for a thread-safe counter
private class CounterWrapper
{
    private int counter;
    
    public void Add()
    {
        Interlocked.Increment(ref counter);
    }

    public bool Reset(out int value)
    {
        value = Interlocked.Exchange(ref counter, 0);
        
        return value > 0;
    }
}

private async Task ScheduleSpawns(int count, CounterWrapper counter)
{
    var millisecondsBetweenSpawns = Mathf.RoundToInt(bladeSpawnFreq * 1000);

    for(var i = 0; i < count; i++)
    {
        counter.Add();
        await Task.Delay(millisecondsBetweenSpawns);
    }
}

private IEnumerator CO_AddBladesInTime(int count, Blade blade)
{
    var _spawnPos = blade.transform.position;

    var counter = new CounterWrapper();
    var task = Task.Run(async () => await ScheduleSpawns(count, counter));

    var _firstTime = Time.time;
    var _expectedDuration = 1 / bladeSpawnFreq * count;
    
    while(!task.IsCompleted || counter.Reset(out var toSpawn))
    {
        // each frame you pawn as many items as scheduled by the task to fulfill the spawn rate
        for(var i = 0; i < toSpawn; i++)
        {
            AddNewBlade(_spawnPos, blade.Level);
        }

        // this yields for one frame
        yield return null;
    }

    var _secondTime = Time.time;
    Debug.Log("exited coroutine at " + _secondTime);
    Debug.Log($"duration {_secondTime - _firstTime}");
    Debug.Log($"expected duration {_expectedDuration}");
}

So what happens here is

  • You start the ScheduleSpawns task as an asynchronous task (-> might even be running on a different thread).
  • The Task.Delay is happening on a milliseconds basis and way more precise than the Unity frames (as said for 60 fps one frame alone is already 17 ms long)
  • You use a thread-safe counter for the scheduled spawns
  • The async task more precisely increases the counter for how many items should be spawned
  • The Corotuine on the Unity main thread each frame spawns all the required items while resetting the counter again

Note that still of course this might be very slightly to long in the end due to the often mentioned nature of frames being quite long ;)