Pull to refresh

Unity, ECS, Actors: как поднять FPS в своей игре в десять раз, когда оптимизировать уже нечего [с правками]

Reading time 7 min
Views 9.2K
Что такое ECS
Что такое Actors

Не раз слышал, как хорош шаблон ECS, и что Jobs и Burst из библиотеки Unity — решение всех проблем с быстродействием. Чтобы не добавлять каждый раз слово «наверное» и «может», рассуждая о быстродействии кода, решил проверить всё лично.

Моей целью было непредвзято разобраться, насколько это быстрый инструмент разработки, и стоит ли использовать распараллеливание для вычислений. И если стоит, то лучше использовать Unity.Jobs или System.Threading? Заодно выяснил, какова польза от ECS в реальных задачах.


Условия тестов (приближены к реальным игровым задачам):

  • Процессор i5 2500 (4 ядра без гипертрейдинга) и Unity2019.3.0f1
  • Каждый GameObject каждый кадр…

    А) перемещается по квадратичной кривой Безье в течение 10 минут от начальной точки до конечной.

    B) рассчитывает свой квадратный коллайдер (box 10fх10f), где используется math.sincos, math.asin, math.sqrt (одинаковые, достаточно сложные расчеты для всех тестов).
  • Объекты до замеров FPS выставляются в случайных позициях в рамках зоны 720fх1280f и двигаются к случайной точке в этой зоне.
  • Всё тестируется в релизе в IL2CPP на PC
  • Тесты записываются спустя несколько секунд после запуска, чтобы все стартовые предварительные расчеты и включение систем Unity не влияли на FPS. По этим же причинам показан только код апдейта каждого кадра.
  • Объекты не имеют визуального отображения в релизе, чтобы работа рендера не влияла на FPS.

Позиции тестирования и код апдейта


  1. MonoBehaviour sequential (условная маркировка).
    На объект «повешен» скрипт MonoBehaviour, в апдейте которого происходит расчет позиции, коллайдера и перемещение самого себя.

    Код апдейта
        void Update()
        {
    	    // расчет новой точки
    	    var velocityToOneFrame = velocityToOneSecond * Time.deltaTime;
    	    observedDistance += velocityToOneFrame;
    	    var t = observedDistance / distanceFull;
    	    if (t > 1f) t = 1f;
    	    var newPos = t.CalculateBesierPos(posToMove.c0, posToMove.c2,posToMove.c1);
    	    
    	    // Обновление коллайдера
    	    obj.properties.c0 = newPos;
    	    var posAndSize = new float2x2
    	    {
    		    c0 = newPos,
    		    c1 = obj.collBox.posAndSize.c1
    	    };
    	    obj.collBox = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ());
    
            // перемещение на новую позицию
            tr.position = new Vector3(newPos.x, newPos.y);
    
    #if UNITY_EDITOR
    	    DebugDrowBox(obj.collBox, Color.blue, Time.deltaTime);
    #endif
        }
    

  2. Actors sequential на компонент-классах без распараллеливания.

    Код апдейта
    
    	public void Tick(float delta)
    	{
    		foreach (ent entity in groupMoveBezier)
    		{
    			var cMoveBezier = entity.ComponentMoveBezier_noJob();
    			var cObject = entity.ComponentObject();
    			ref var obj = ref cObject.obj;
    			
    			// расчет новой точки
    			var velocityToOneFrame = cMoveBezier.velocityToOneSecond * delta;
    			cMoveBezier.observedDistance += velocityToOneFrame;
    			var t = cMoveBezier.observedDistance / cMoveBezier.distanceFull;
    			if (t > 1f) t = 1f;
    			var newPos = t.CalculateBesierPos(cMoveBezier.posToMove.c0, cMoveBezier.posToMove.c2,cMoveBezier.posToMove.c1);
    			
    			// Обновление коллайдера
    			obj.properties.c0 = newPos;
    			var posAndSize = new float2x2
    			{
    				c0 = newPos,
    				c1 = obj.collBox.posAndSize.c1
    			};
    			obj.collBox = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ());
    			
    			// перемещение на новую позицию
    			cObject.tr.position = new Vector3(newPos.x, newPos.y, 0); 
    			
    #if UNITY_EDITOR
    			DebugDrowBox(obj.collBox, Color.blue, Time.deltaTime);
    #endif
    		}
    	}
    

  3. Actors + Jobs + Burst

    Расчет и перемещение в Jobs из библиотек Unity.Jobs 0.1.1, Unity.Burst 1.1.2.
    Safety Checks — off
    Editor Attaching — off
    JobsDebbuger — off
    Для нормальной работы IJobParallelForTransform все перемещаемые объекты имеют «объекта-родителя» (до 255 штук объектов в каждом «родителе» по рекомендации для максимальной производительности).
    Код апдейта
    	public void Tick(float delta)
    	{
    		if (index <= 0) return;
    		
    		handlePositionUpdate.Complete();
    		
    #if UNITY_EDITOR
    		for (var i = 0; i < index; i++)
    		{
    			var obj = nObj[i];
    			DebugDrowBox(obj.collBox, Color.blue, Time.deltaTime);
    		}
    #endif	
    		
    		jobPositionUpdate.nSetMove = nSetMove;
    		jobPositionUpdate.nObj = nObj;
    		jobPositionUpdate.deltaTime = delta;
    		handlePositionUpdate = jobPositionUpdate.Schedule(transformsAccessArray);
    	}
    }
    
    
    [BurstCompile]
    struct JobPositionUpdate : IJobParallelForTransform
    {
    	public NativeArray<SetMove> nSetMove;
    	public NativeArray<Obj> nObj;
    	[Unity.Collections.ReadOnly] public float deltaTime;
    	
    	public void Execute(int index, TransformAccess transform)
    	{
    		var setMove = nSetMove[index];
    		var velocityToOneFrame = nSetMove[index].velocityToOneSecond * deltaTime;
    		
    		// расчет новой точки
    		setMove.observedDistance += velocityToOneFrame;
    		var t = setMove.observedDistance / setMove.distanceFull;
    		if (t > 1f) t = 1f;
    		var newPos = t.CalculateBesierPos(setMove.posToMove.c0, setMove.posToMove.c2,setMove.posToMove.c1);
    		nSetMove[index] = setMove;
    		
    		// Обновление коллайдера
    		var obj = nObj[index];
    		obj.properties.c0 = newPos;
    		var posAndSize = new float2x2
    		{
    			c0 = newPos,
    			c1 = obj.collBox.posAndSize.c1
    		};
    		obj.collBox = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ());
    		nObj[index] = obj;		
    		
    		// перемещение на новую позицию
    		transform.position = (Vector2) newPos;
    	}
    }
    
    
    public struct SetMove
    {
    	public float2x3 posToMove;
    	public float distanceFull;
    	public float velocityToOneSecond;	
    	public float observedDistance;
    }
    
  4. Actors + Parallel.For

    Вместо обычного цикла For по группе перемещающихся сущностей, используется Parallel.For из библиотеки System.Threading.Tasks. Он производит расчеты новой позиции и коллайдера в параллельных потоках. Перемещение объекта осуществляется в соседней группе.

    Код апдейта
    	public void Tick(float delta)
    	{
    		Parallel.For(0, groupMoveBezier.length, i =>
    		{
    			ref var entity = ref groupMoveBezier[i];
    			var cMoveBezier = entity.ComponentMoveBezier_actorsParallel();
    			ref var obj = ref entity.ComponentObject().obj;
    
    			// расчет новой точки
    			var velocityToOneFrame = cMoveBezier.velocityToOneSecond * delta;
    			cMoveBezier.observedDistance += velocityToOneFrame;
    			var t = cMoveBezier.observedDistance / cMoveBezier.distanceFull;
    			if (t > 1f) t = 1f;
    			var newPos = t.CalculateBesierPos(cMoveBezier.posToMove.c0, cMoveBezier.posToMove.c2,cMoveBezier.posToMove.c1);
    
    			// обновление коллайдера
    			obj.properties.c0 = newPos;
    			var posAndSize = new float2x2
    			{
    				c0 = newPos,
    				c1 = obj.collBox1.posAndSize.c1
    			};
    			obj.collBox1 = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ());
    		});
    			
    		// перемещение на новую позицию
    		foreach (ent entity1 in groupMoveBezier)
    		{
    			var cObject = entity1.ComponentObject();
    			cObject.tr.position = new Vector3(cObject.obj.properties.c0.x, cObject.obj.properties.c0.y, 0); 
    			
    #if UNITY_EDITOR
    			DebugDrowBox(cObject.obj.collBox1, Color.blue, Time.deltaTime);
    #endif				
    		}
    	}
    

Тестирование с перемещением[1]:


500 объектов



(картинка из редактора около текста с FPS, чтобы показать что там визуально происходит)

  1. MonoBehaviour sequential:

  2. Actors sequential:

  3. Actors + Jobs + Burst:

  4. Actors + Parallel.For:


5000 объектов




  1. MonoBehaviour sequential:

  2. Actors sequential:

  3. Actors + Jobs + Burst:

  4. Actors + Parallel.For:



50000 объектов



  1. MonoBehaviour sequential:

  2. Actors sequential:

  3. Actors + Jobs + Burst:

  4. Actors + Parallel.For:


Actors + Threaded (встроенное в Actors распараллеливание на System.Threading)


В Actors есть возможность держать все компоненты игры в структурах вместо классов. Это геморройнее с точки зрения написания кода, зато при таких условиях программа в большей степени работает со стеком, а не с управляемой кучей, что заметно сказывается на скорости ее работы.

Код апдейта
    public void Tick(float delta)
    {
      groupMoveBezier.Execute(delta);

      for (int i = 0; i < groupMoveBezier.length; i++)
      {
        ref var cObject = ref groupMoveBezier.entities[i].ComponentObject();
        cObject.tr.position = new Vector3(cObject.obj.properties.c0.x, cObject.obj.properties.c0.y, 0);
        #if UNITY_EDITOR
        DebugDrowBox(cObject.obj.collBox, Color.blue, Time.deltaTime);
        #endif
      }
    }
    static void HandleCalculation(SegmentGroup segment)
    {
      for (int i = segment.indexFrom; i < segment.indexTo; i++)
      {
        ref var entity      = ref segment.source.entities[i];
        ref var cMoveBezier = ref entity.ComponentMoveBezier();
        ref var cObject     = ref entity.ComponentObject();
        ref var obj         = ref cObject.obj;
        
        
        // расчет новой точки
        var velocityToOneFrame = cMoveBezier.velocityToOneSecond * segment.delta;
        cMoveBezier.observedDistance += velocityToOneFrame;
        var t         = cMoveBezier.observedDistance / cMoveBezier.distanceFull;
        if (t > 1f) t = 1f;
        var newPos    = t.CalculateBesierPos(cMoveBezier.posToMove.c0, cMoveBezier.posToMove.c2, cMoveBezier.posToMove.c1);

        // обновление коллайдера
        obj.properties.c0 = newPos;
        var posAndSize = new float2x2
        {
          c0 = newPos,
          c1 = obj.collBox.posAndSize.c1
        };
        obj.collBox = obj.entity.NewCollBox(posAndSize, new float2(10f, 10f), obj.rotation.ToEulerAnglesZ());
      }
    }


на компонентах-классах

на компонентах-структурах

В данном случае мы получаем +10% к FPS, но в примере всего два компонента-структуры, а не десятки, как это должно быть в конечном продукте. Тут возможен нелинейный рост FPS по мере замены компонентов программы reference types на value types.

Заключение


  • Во всех случаях FPS в Actors без Parallel.For увеличивается примерно в два раза, а с ним — в три раза по сравнению с MonoBehaviour sequential. С увеличением математических расчетов эти пропорции сохраняются.
  • Для меня дополнительное преимущество ECS Actors перед MonoBehaviour sequential в том, что дающее прибавку к скорости распараллеливание вычислений добавляется элементарно.
  • Использование Actors + Jobs + Burst повышает FPS примерно в десять раз, по сравнению с MonoBehaviour sequential
  • Надо признать, что такой прирост в FPS в большей степени заслуга Burst. Само собой, для его нормальной работы нужно использовать типы данных из Unity.Mathematics (к примеру, Vector3 заменяем на float3)
    И очень важно: чтобы на моем процессоре с 50000 объектами на экране поднять FPS с до !
    Нужно соблюдать следующие пункты:
    1)Если в расчетах можно обойтись без библиотеки, то лучше ее не использовать(красный маркер — плохо, зеленый — хорошо)

    2)Нельзя использовать библиотеку Mathf — только math, иначе burst не сможет векторизировать и обработать данные.

  • Судя по нескольким сторонним тестам MonoBehaviour sequential с 50000 объектами показывает везде одинаковые ~50fps. А вот работа на Actors + Jobs или Threaded сильно отличается.
    Также, чем более современный процессор, тем полезнее работу разбивать на несколько «вложенных в очередь» Jobs: расчет позиции, коллайдера, перемещение на позицию.
    Можно скачать тестовую программу и сравнить работу Actors+Jobs+Burst [один Job] с Actors+Jobs+Burst [четыре Job]. (На моем процессоре с четырьмя ядрами без гипертрейдинга первый тест быстрее на -0.2ms при 50000 объектов)
  • Эффективность ECS зависит от количества дополнительных элементов (рендер, физика Unity и т. д.).

[1]Каково быстродействие в других фреймворках на ECS, в системах ECS-Unity/DOTS, мне не известно.

Исходник тестов

Спасибо Oleg Morozov(BenjaminMoore) за правку по джобам, добавление SceneSelector и новый fps счетчик.
Спасибо iurii zakipnyi за наставления, правки и дополнительный тест Actors+Jobs+Burst [четыре Job]
Tags:
Hubs:
+11
Comments 12
Comments Comments 12

Articles